Repository: JohnPaton/airbase

Issue:
cli request confuses "CO" with "Co"
when attempting to download `CO` data, no data is found

``` bash
$ airbase pollutant CO --year 2023
Generating CSV download links...
100%|███████████████████████████████████████████████████████| 40/40 [00:52<00:00,  1.32s/it]
Generated 0 CSV links ready for downloading
Downloading CSVs to data...
0it [00:00, ?it/s]
```

After some debut, I found out that this is a problem in typer (tiangolo/typer#570).
The `CO` CLI option is interpreted as `Co`, which has no data...

Discussion:

Patch:
diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml
index 38335b5..cc7b6f8 100644
--- a/.github/workflows/cicd.yaml
+++ b/.github/workflows/cicd.yaml
@@ -12,8 +12,8 @@ jobs:
       matrix:
         python: [3.7, 3.8, 3.9, '3.10', '3.11']
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
       - run: |
@@ -25,8 +25,8 @@ jobs:
     needs: unit-test
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.x
       - run: |
@@ -37,9 +37,9 @@ jobs:
   lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
-      - uses: pre-commit/action@v2.0.3
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+      - uses: pre-commit/action@v3.0.0
         with:
           extra_args: --all-files
 
@@ -47,8 +47,8 @@ jobs:
     needs: [unit-test, integration-test, lint]
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.x
       - run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 30cd9d7..4ffb83a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@ repos:
     exclude: '(docs/.*)|(setup\.py)'
     additional_dependencies: [types-aiofiles, types-requests]
 - repo: https://github.com/pycqa/isort
-  rev: 5.10.1
+  rev: 5.12.0
   hooks:
   - id: isort
     name: isort (python)
diff --git a/airbase/cli.py b/airbase/cli.py
index ca61a9a..e4b2807 100644
--- a/airbase/cli.py
+++ b/airbase/cli.py
@@ -1,19 +1,15 @@
 from __future__ import annotations
 
+import sys
 from datetime import date
 from enum import Enum
 from pathlib import Path
-from typing import List
 
-import typer
+import click
 
 from . import __version__
 from .airbase import AirbaseClient
 
-main = typer.Typer(
-    no_args_is_help=True,
-    add_completion=False,
-)
 client = AirbaseClient()
 
 
@@ -39,39 +35,97 @@ class Pollutant(str, Enum):
         return self.name
 
 
-def version_callback(value: bool):
-    if not value:
-        return
+@click.group(invoke_without_command=True, no_args_is_help=True)
+@click.option(
+    "--version",
+    "-V",
+    is_flag=True,
+    help=f"Show {__package__} version and exit.",
+)
+def main(version: bool):
+    """Download Air Quality Data from the European Environment Agency (EEA)"""
+    if version:
+        click.echo(f"{__package__} v{__version__}")
+        sys.exit(0)
+
+
+countries = click.option(
+    "-c",
+    "--country",
+    "countries",
+    type=click.Choice(Country),  # type:ignore [arg-type]
+    multiple=True,
+)
+country = click.argument(
+    "country",
+    type=click.Choice(Country),  # type:ignore [arg-type]
+)
+
+pollutants = click.option(
+    "-p",
+    "--pollutant",
+    "pollutants",
+    type=click.Choice(Pollutant),  # type:ignore [arg-type]
+    multiple=True,
+)
+pollutant = click.argument(
+    "pollutant",
+    type=click.Choice(Pollutant),  # type:ignore [arg-type]
+)
+
 
-    typer.echo(f"{__package__} v{__version__}")
-    raise typer.Exit()
+path = click.option(
+    "--path",
+    default="data",
+    type=click.Path(exists=True, dir_okay=True, writable=True),
+)
+year = click.option("--year", default=date.today().year, type=int)
+overwrite = click.option(
+    "-O",
+    "--overwrite",
+    is_flag=True,
+    help="Re-download existing files.",
+)
+quiet = click.option(
+    "-q",
+    "--quiet",
+    is_flag=True,
+    help="No progress-bar.",
+)
 
 
-@main.callback()
-def root_options(
-    version: bool = typer.Option(
-        False,
-        "--version",
-        "-V",
-        callback=version_callback,
-        help=f"Show {__package__} version and exit.",
-    ),
+def _download(
+    countries: list[Country],
+    pollutants: list[Pollutant],
+    path: Path,
+    year: int,
+    overwrite: bool,
+    quiet: bool,
 ):
-    """Download Air Quality Data from the European Environment Agency (EEA)"""
+    request = client.request(
+        countries or None,  # type:ignore[arg-type]
+        pollutants or None,  # type:ignore[arg-type]
+        year_from=str(year),
+        year_to=str(year),
+        verbose=not quiet,
+    )
+    request.download_to_directory(path, skip_existing=not overwrite)
 
 
 @main.command()
+@countries
+@pollutants
+@path
+@year
+@overwrite
+@quiet
 def download(
-    countries: List[Country] = typer.Option([], "--country", "-c"),
-    pollutants: List[Pollutant] = typer.Option([], "--pollutant", "-p"),
-    path: Path = typer.Option(
-        "data", exists=True, dir_okay=True, writable=True
-    ),
-    year: int = typer.Option(date.today().year),
-    overwrite: bool = typer.Option(
-        False, "--overwrite", "-O", help="Re-download existing files."
-    ),
-    quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress-bar."),
+    countries: list[Country],
+    pollutants: list[Pollutant],
+    path: Path,
+    year: int,
+    overwrite: bool,
+    quiet: bool,
 ):
     """Download all pollutants for all countries
 
@@ -82,74 +136,72 @@ def download(
     - download only SO2, PM10 and PM2.5 observations
       airbase download -p SO2 -p PM10 -p PM2.5
     """
-
-    request = client.request(
-        countries or None,  # type:ignore[arg-type]
-        pollutants or None,  # type:ignore[arg-type]
-        year_from=str(year),
-        year_to=str(year),
-        verbose=not quiet,
-    )
-    request.download_to_directory(path, skip_existing=not overwrite)
+    _download(countries, pollutants, path, year, overwrite, quiet)
 
 
 def deprecation_message(old: str, new: str):  # pragma: no cover
-    old = typer.style(f"{__package__} {old}", fg=typer.colors.RED, bold=True)
-    new = typer.style(f"{__package__} {new}", fg=typer.colors.GREEN, bold=True)
-    typer.echo(
+    old = click.style(f"{__package__} {old}", fg="red", bold=True)
+    new = click.style(f"{__package__} {new}", fg="green", bold=True)
+    click.echo(
         f"{old} has been deprecated and will be removed on v1. Use {new} all instead.",
     )
 
 
 @main.command(name="all")
+@countries
+@pollutants
+@path
+@year
+@overwrite
+@quiet
 def download_all(
-    countries: List[Country] = typer.Option([], "--country", "-c"),
-    pollutants: List[Pollutant] = typer.Option([], "--pollutant", "-p"),
-    path: Path = typer.Option(
-        "data", exists=True, dir_okay=True, writable=True
-    ),
-    year: int = typer.Option(date.today().year),
-    overwrite: bool = typer.Option(
-        False, "--overwrite", "-O", help="Re-download existing files."
-    ),
-    quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress-bar."),
+    countries: list[Country],
+    pollutants: list[Pollutant],
+    path: Path,
+    year: int,
+    overwrite: bool,
+    quiet: bool,
 ):  # pragma: no cover
     """Download all pollutants for all countries (deprecated)"""
     deprecation_message("all", "download")
-    download(countries, pollutants, path, year, overwrite, quiet)
+    _download(countries, pollutants, path, year, overwrite, quiet)
 
 
 @main.command(name="country")
+@country
+@pollutants
+@path
+@year
+@overwrite
+@quiet
 def download_country(
     country: Country,
-    pollutants: List[Pollutant] = typer.Option([], "--pollutant", "-p"),
-    path: Path = typer.Option(
-        "data", exists=True, dir_okay=True, writable=True
-    ),
-    year: int = typer.Option(date.today().year),
-    overwrite: bool = typer.Option(
-        False, "--overwrite", "-O", help="Re-download existing files."
-    ),
-    quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress-bar."),
+    pollutants: list[Pollutant],
+    path: Path,
+    year: int,
+    overwrite: bool,
+    quiet: bool,
 ):  # pragma: no cover
     """Download specific pollutants for one country (deprecated)"""
     deprecation_message("country", "download")
-    download([country], pollutants, path, year, overwrite, quiet)
+    _download([country], pollutants, path, year, overwrite, quiet)
 
 
 @main.command(name="pollutant")
+@pollutant
+@countries
+@path
+@year
+@overwrite
+@quiet
 def download_pollutant(
     pollutant: Pollutant,
-    countries: List[Country] = typer.Option([], "--country", "-c"),
-    path: Path = typer.Option(
-        "data", exists=True, dir_okay=True, writable=True
-    ),
-    year: int = typer.Option(date.today().year),
-    overwrite: bool = typer.Option(
-        False, "--overwrite", "-O", help="Re-download existing files."
-    ),
-    quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress-bar."),
+    countries: list[Country],
+    path: Path,
+    year: int,
+    overwrite: bool,
+    quiet: bool,
 ):  # pragma: no cover
     """Download specific countries for one pollutant (deprecated)"""
     deprecation_message("pollutant", "download")
-    download(countries, [pollutant], path, year, overwrite, quiet)
+    _download(countries, [pollutant], path, year, overwrite, quiet)
diff --git a/setup.cfg b/setup.cfg
index 66d0440..f867f0b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -27,7 +27,7 @@ install_requires =
     importlib_resources; python_version < "3.11"
     tqdm
     typing_extensions; python_version < "3.8"
-    typer
+    click
 packages = find:
 include_package_data = True
 


Test patch:
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index f23dd60..2f6f96d 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -1,17 +1,27 @@
 from pathlib import Path
 
-from typer.testing import CliRunner
+import pytest
+from click.testing import CliRunner
 
 from airbase.cli import main
 
 runner = CliRunner()
 
 
-def test_download(tmp_path: Path):
-    country, year, pollutant, id = "NO", 2021, "NO2", 8
+@pytest.mark.parametrize(
+    "country,year,pollutant,id",
+    (
+        pytest.param("NO", 2021, "NO2", 8, id="NO2"),
+        pytest.param("NO", 2021, "CO", 10, id="CO"),
+    ),
+)
+def test_download(
+    country: str, year: int, pollutant: str, id: int, tmp_path: Path
+):
     options = f"download --quiet --country {country} --pollutant {pollutant} --year {year} --path {tmp_path}"
-    result = runner.invoke(main, options.split())
-    assert result.exit_code == 0
+    with runner.isolated_filesystem(temp_dir=tmp_path):
+        result = runner.invoke(main, options.split())
+        assert result.exit_code == 0
+        files = tmp_path.glob(f"{country}_{id}_*_{year}_timeseries.csv")
 
-    files = tmp_path.glob(f"{country}_{id}_*_{year}_timeseries.csv")
     assert list(files)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0639e5e..c132ad0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
+
 import pytest
-from typer.testing import CliRunner
+from click.testing import CliRunner
 
 from airbase import __version__
 from airbase.cli import Country, Pollutant, main
@@ -22,4 +24,4 @@ def test_version(options: str):
     result = runner.invoke(main, options.split())
     assert result.exit_code == 0
     assert "airbase" in result.output
-    assert __version__ in result.output
+    assert str(__version__) in result.output