diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml new file mode 100644 index 0000000..bcfd7ed --- /dev/null +++ b/.github/workflows/cli-tests.yml @@ -0,0 +1,41 @@ +name: CLI Tests + +on: + pull_request: + branches: [main] + +jobs: + test: + name: Test CLI on ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + matrix: + # Test 3.9 for T7, 3.12 for general/cloud use + python-version: ["3.9", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: abatilo/actions-poetry@v4 + + - name: Configure Poetry to use in-project virtualenv + run: poetry config virtualenvs.in-project true + + - name: Install dependencies (including dev/test) and project + run: poetry install --with dev --no-interaction + + - name: Smoke-test CLI on Python 3.9 + if: matrix.python-version == '3.9' + run: poetry run python scripts/smoke_test_cli.py + + - name: Run full test suite (Python >= 3.10) + if: matrix.python-version != '3.9' + run: poetry run pytest -q diff --git a/cwmscli/commands/blob.py b/cwmscli/commands/blob.py index 09278cc..fd65f9c 100644 --- a/cwmscli/commands/blob.py +++ b/cwmscli/commands/blob.py @@ -7,10 +7,6 @@ import sys from typing import Optional, Sequence -import cwms -import pandas as pd -import requests - from cwmscli.utils import get_api_key from cwmscli.utils.deps import requires @@ -90,6 +86,9 @@ def _save_base64( def store_blob(**kwargs): + import cwms + import requests + file_data = kwargs.get("file_data") blob_id = kwargs.get("blob_id", "").upper() # Attempt to determine what media type should be used for the mime-type if one is not presented based on the file extension @@ -145,6 +144,9 @@ def store_blob(**kwargs): def retrieve_blob(**kwargs): + import cwms + import requests + blob_id = kwargs.get("blob_id", "").upper() if not blob_id: logging.warning( @@ -172,14 +174,17 @@ def retrieve_blob(**kwargs): def delete_blob(**kwargs): + import cwms + import requests + blob_id = kwargs.get("blob_id").upper() logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}") try: - # cwms.delete_blob( - # office_id=kwargs.get("office"), - # blob_id=kwargs.get("blob_id").upper(), - # ) + cwms.delete_blob( + office_id=kwargs.get("office"), + blob_id=kwargs.get("blob_id").upper(), + ) logging.info(f"Successfully deleted blob with ID: {blob_id}") except requests.HTTPError as e: details = getattr(e.response, "text", "") or str(e) @@ -197,8 +202,11 @@ def list_blobs( sort_by: Optional[Sequence[str]] = None, ascending: bool = True, limit: Optional[int] = None, -) -> pd.DataFrame: +): logging.info(f"Listing blobs for office: {office!r}...") + import cwms + import pandas as pd + result = cwms.get_blobs(office_id=office, blob_id_like=blob_id_like) # Accept either a DataFrame or a JSON/dict-like response @@ -250,6 +258,9 @@ def upload_cmd( api_root: str, api_key: str, ): + import cwms + import requests + cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, "")) try: file_size = os.path.getsize(input_file) @@ -307,6 +318,9 @@ def upload_cmd( def download_cmd( blob_id: str, dest: str, office: str, api_root: str, api_key: str, dry_run: bool ): + import cwms + import requests + if dry_run: logging.info( f"DRY RUN: would GET {api_root} blob with blob-id={blob_id} office={office}." @@ -331,6 +345,8 @@ def download_cmd( def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run: bool): + import cwms + if dry_run: logging.info( f"DRY RUN: would DELETE {api_root} blob with blob-id={blob_id} office={office}" @@ -352,6 +368,8 @@ def update_cmd( api_root: str, api_key: str, ): + import cwms + if dry_run: logging.info( f"DRY RUN: would PATCH {api_root} blob with blob-id={blob_id} office={office}" @@ -398,6 +416,9 @@ def list_cmd( api_root: str, api_key: str, ): + import cwms + import pandas as pd + cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None)) df = list_blobs( office=office, diff --git a/cwmscli/commands/commands_cwms.py b/cwmscli/commands/commands_cwms.py index 6b75cb0..48ecd36 100644 --- a/cwmscli/commands/commands_cwms.py +++ b/cwmscli/commands/commands_cwms.py @@ -107,7 +107,6 @@ def csv2cwms_cmd(**kwargs): """ ), ) -@requires(reqs.cwms) def blob_group(): pass @@ -137,6 +136,7 @@ def blob_group(): ) @click.option("--dry-run", is_flag=True, help="Show request; do not send.") @common_api_options +@requires(reqs.cwms) def blob_upload(**kwargs): from cwmscli.commands.blob import upload_cmd @@ -156,6 +156,7 @@ def blob_upload(**kwargs): ) @click.option("--dry-run", is_flag=True, help="Show request; do not send.") @common_api_options +@requires(reqs.cwms) def blob_download(**kwargs): from cwmscli.commands.blob import download_cmd @@ -169,6 +170,7 @@ def blob_download(**kwargs): @click.option("--blob-id", required=True, type=str, help="Blob ID to delete.") @click.option("--dry-run", is_flag=True, help="Show request; do not send.") @common_api_options +@requires(reqs.cwms) def delete_cmd(**kwargs): from cwmscli.commands.blob import delete_cmd @@ -204,6 +206,7 @@ def delete_cmd(**kwargs): help="If true, replace existing blob.", ) @common_api_options +@requires(reqs.cwms) def update_cmd(**kwargs): from cwmscli.commands.blob import update_cmd @@ -243,6 +246,7 @@ def update_cmd(**kwargs): help="If set, write results to this CSV file.", ) @common_api_options +@requires(reqs.cwms) def list_cmd(**kwargs): from cwmscli.commands.blob import list_cmd diff --git a/poetry.lock b/poetry.lock index 9d68abd..25305e7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -307,6 +307,25 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "filelock" version = "3.19.1" @@ -379,6 +398,19 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "isort" version = "5.13.2" @@ -962,6 +994,23 @@ docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx- test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] type = ["mypy (>=1.18.2)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "pre-commit" version = "3.8.0" @@ -1223,6 +1272,31 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1676,4 +1750,4 @@ ruyaml = ">=0.91.0" [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "7ab03cbabe9011d7cbf14e3c328258a1977604214d85565a93c5008018cd199c" +content-hash = "c723b5ad9dbb174e0ebd77e88fb0db45719cd4708f7015b222b7ec36d13947f5" diff --git a/pyproject.toml b/pyproject.toml index 3382e87..3b23743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ black = "^24.2.0" isort = "^5.13.2" mypy = "^1.9.0" pre-commit = "^3.6.2" -#pytest = "^8.1.1" +pytest = { version = "^9.0.2", python = ">=3.10" } #pytest-cov = "^4.1.0" #pandas-stubs = "^2.2.1.240316" yamlfix = "^1.16.0" diff --git a/scripts/smoke_test_cli.py b/scripts/smoke_test_cli.py new file mode 100644 index 0000000..2c90f0a --- /dev/null +++ b/scripts/smoke_test_cli.py @@ -0,0 +1,10 @@ +# Used to ensure cli will run under Python 3.9 + +from click.testing import CliRunner + +from cwmscli.__main__ import cli + +runner = CliRunner() +result = runner.invoke(cli, ["--help"]) +assert result.exit_code == 0, "CLI failed to run under Python 3.9" +print("CLI loads and runs under Python 3.9") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d91daea --- /dev/null +++ b/tests/README.md @@ -0,0 +1,18 @@ +# Testing + +Tests located here will invoke the CliRunner that `click` provides. + +https://click.palletsprojects.com/en/stable/testing/ + +These tests are more for testing the runner itself and ensuring input/output does not change unexpectedly. + +Further testing exists within the individual scripts to test the functionality of those scripts regardless of the `click` integrations and/or API targets. + +## Goals + +- Assign extensive tests per root `cli` command: + - Should have each of the sub commands covered + - Tests each argument + - Have comparisons for expected output both file and stdout +- Maintain tests as new features are added +- Ensure PR blocking to main in the event a given test fails diff --git a/tests/cli/test_all_commands_help.py b/tests/cli/test_all_commands_help.py new file mode 100644 index 0000000..23e93bb --- /dev/null +++ b/tests/cli/test_all_commands_help.py @@ -0,0 +1,49 @@ +import pytest +from click.testing import CliRunner + +from cwmscli.__main__ import cli + +## Expectations +# - The help commands should run without requiring an import +# - Help text should include "Usage: --help" +# - Every command and subcommand should be tested for help text to ensure help renders as expected and no early import errors occur + + +def iter_commands(cmd, path=()): + """ + Recursively walk all commands under a Click Group. + + Yields (path_tuple, command_obj), where path_tuple is like: + ("usgs", "ratings", "etc") + """ + commands = getattr(cmd, "commands", {}) + for name, sub in commands.items(): + new_path = path + (name,) + yield new_path, sub + # If the subcommand is itself a Group, recurse + if hasattr(sub, "commands"): + yield from iter_commands(sub, new_path) + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_root_help(runner): + """Top-level CLI should have working help.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + +@pytest.mark.parametrize("path,command", list(iter_commands(cli))) +def test_every_command_has_help(runner, path, command): + """ + Run through every command and subcommand, ensuring that the help page renders. + This ensures that no early import errors occur in any command. + """ + args = list(path) + ["--help"] + result = runner.invoke(cli, args) + assert result.exit_code == 0, f"Failed on: {' '.join(args)}" + assert "Usage:" in result.output