Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
/cwmscli/commands/blob.py charles.r.graham@usace.army.mil
/docs/cli/blob.rst charles.r.graham@usace.army.mil
/cwmscli/utils/update.py charles.r.graham@usace.army.mil
/cwmscli/utils/deps.py charles.r.graham@usace.army.mil eric.v.novotny@usace.army.mil
/cwmscli/requirements.py charles.r.graham@usace.army.mil eric.v.novotny@usace.army.mil
/tests/utils/test_deps.py charles.r.graham@usace.army.mil eric.v.novotny@usace.army.mil
/docs/cli/update.rst charles.r.graham@usace.army.mil
/tests/cli/test_update_command.py charles.r.graham@usace.army.mil
/cwmscli/load/ charles.r.graham@usace.army.mil eric.v.novotny@usace.army.mil
Expand Down
3 changes: 2 additions & 1 deletion cwmscli/requirements.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Shared minimum version requirements for optional dependencies used by
# Shared version requirements for optional dependencies used by
# the `@requires` decorator in `cwmscli.utils.deps`.

cwms = {
"module": "cwms",
"package": "cwms-python",
"version": "1.0.7",
"max_version": "2.0.0",
"desc": "CWMS REST API Python client",
"link": "https://github.com/HydrologicEngineeringCenter/cwms-python",
}
Expand Down
139 changes: 51 additions & 88 deletions cwmscli/utils/deps.py
Original file line number Diff line number Diff line change
@@ -1,79 +1,50 @@
import importlib
import importlib.metadata
import os
from typing import Callable
import sys

import click
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version


def _pip_command():
# Check OS to determine pip vs pip3
if os.name == "nt":
return "pip"
# Avoid potential issues with multiple python (2/3) versions on Unix/Linux systems
else:
return "pip3"
return f'"{sys.executable}" -m pip'


def requires(*requirements):
"""
Decorator that ensures required Python modules are installed and meet optional minimum version constraints.

Parameters:
*requirements: One or more dictionaries describing a module requirement.
Each dictionary may contain the following keys:

- module (str): The importable module name (e.g., "requests").

- package (str, optional): The name of the package to install via pip.
Use this if the pip install name differs from the import name
(e.g., module="cwms", package="cwms-python").

- version (str, optional): A minimum required version string (e.g., "2.30.0").

- desc (str, optional): A short description of what the module is or why it's needed.
Included in the error message to help users understand the dependency.

- link (str, optional): A URL pointing to documentation or the package's homepage.

Example:
@requires(
{
"module": "cwms",
"package": "cwms-python",
"version": "1.0.7",
"desc": "CWMS REST API Python client",
"link": "https://github.com/hydrologicengineeringcenter/cwms-python"
},
{
"module": "requests",
"version": "2.30.0",
"desc": "Required for HTTP API access"
}
)
"""Check that command dependencies are installed and in their supported range.

Each requirement dictionary accepts:
- module: importable module name.
- package: distribution name, if different from the module name.
- version: inclusive minimum version (optional).
- max_version: exclusive upper version bound (optional).
- desc: description included in missing-module errors (optional).
- link: documentation URL (optional).

For example, {"module": "cwms", "package": "cwms-python",
"version": "1.0.7", "max_version": "2.0.0"} accepts >=1.0.7,<2.0.0.
Requirements without max_version retain their minimum-only behavior.
"""

def decorator(func):
def wrapper(*args, **kwargs):
missing = []
version_issues = []

# choose a version parsing function: prefer packaging, fallback to pkg_resources
try:
from packaging.version import parse as _parse_version
except Exception:
try:
from pkg_resources import parse_version as _parse_version
except Exception:
_parse_version = None

for req in requirements:
mod = req["module"]
pkg = req.get("package", mod)
min_version = req.get("version")
constraints = []
if req.get("version"):
constraints.append(f">={req['version']}")
if req.get("max_version"):
constraints.append(f"<{req['max_version']}")
version_range = ",".join(constraints)
supported = SpecifierSet(version_range)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Enovotny

This should resolve the issue you found with it being treated as 0.1 instead of 10 on the PATCH bump

install_target = f'"{pkg}{version_range}"' if version_range else pkg
desc = req.get("desc")
link = req.get("link")
# Check if the provided requirement is already imported
try:
importlib.import_module(mod)
except ImportError:
Expand All @@ -82,60 +53,52 @@ def wrapper(*args, **kwargs):
msg += f" — {desc}"
if link:
msg += f" [docs]({link})"
missing.append((msg, pkg))
missing.append((msg, install_target))
continue
# Confirm the minimum version is met
if min_version:

if version_range:
try:
actual_version = importlib.metadata.version(pkg)
if _parse_version is not None:
try:
if _parse_version(actual_version) < _parse_version(
min_version
):
version_issues.append(
f"- python package `{pkg}` version `{actual_version}` found, "
f"but `{min_version}` or higher is required.\n\t"
f"Update the package to the required minimum version to use this command."
)
except Exception:
# Fall back to string comparison if parsing fails
if actual_version < min_version:
version_issues.append(
f"- python package `{pkg}` version `{actual_version}` found, "
f"but `{min_version}` or higher is required.\n\t"
f"Update the package to the required minimum version to use this command."
)
else:
# No parser available — fall back to lexical comparison
if actual_version < min_version:
version_issues.append(
f"- python package `{pkg}` version `{actual_version}` found, "
f"but `{min_version}` or higher is required.\n\t"
f"Update the package to the required minimum version to use this command."
)
parsed_version = Version(actual_version)
# Preserve support for installed prereleases within the range;
# PEP 440 still excludes prereleases of the upper boundary.
if not supported.contains(parsed_version, prereleases=True):
version_issues.append(
f"- python package `{pkg}` version `{actual_version}` found, "
f"but this command requires `{version_range}`.\n"
f" Upgrade cwms-cli to check for support for newer dependencies:\n"
f" {_pip_command()} install --upgrade cwms-cli\n"
f" Or install a version supported by this command:\n"
f" {_pip_command()} install --upgrade {install_target}"
)
except importlib.metadata.PackageNotFoundError:
version_issues.append(
f"- `{pkg}` is installed but version could not be verified"
)
# Build out the error response
except InvalidVersion:
version_issues.append(
f"- `{pkg}` has an invalid version `{actual_version}`; "
f"version could not be verified.\n"
f" Reinstall a supported version:\n"
f" {_pip_command()} install --upgrade --force-reinstall "
f"{install_target}"
)

if missing or version_issues:
error_lines = []
if missing:
error_lines.append("Missing module(s):")
for msg, _ in missing:
error_lines.append(msg)
install_cmd = f"{_pip_command()} install " + " ".join(
pkg for _, pkg in missing
install_cmd = f"{_pip_command()} install --upgrade " + " ".join(
target for _, target in missing
)
error_lines.append(
f"\nInstall missing packages:\n {install_cmd}"
)

if version_issues:
error_lines.append("\nVersion issues:")
error_lines.extend(version_issues)

raise click.ClickException("\n".join(error_lines))

return func(*args, **kwargs)
Expand Down
12 changes: 12 additions & 0 deletions maintainers.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ owners = ["charles"]
pattern = "/cwmscli/utils/update.py"
owners = ["charles"]

[[codeowners.rule]]
pattern = "/cwmscli/utils/deps.py"
owners = ["charles", "eric"]

[[codeowners.rule]]
pattern = "/cwmscli/requirements.py"
owners = ["charles", "eric"]

[[codeowners.rule]]
pattern = "/tests/utils/test_deps.py"
owners = ["charles", "eric"]

[[codeowners.rule]]
pattern = "/docs/cli/update.rst"
owners = ["charles"]
Expand Down
4 changes: 2 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ requests = [
]
hecdss = { version = ">=0.1.24", optional = true } # Via https://github.com/HydrologicEngineeringCenter/hec-python-library/blob/main/hec/shared.py#L9-10
hec-python-library = { version = ">=0.9.5", optional = true }
cwms-python = { version = ">=1.0.7", optional = true}
cwms-python = { version = ">=1.0.7,<2.0.0", optional = true}
packaging = ">=24.2,<27"
colorama = "^0.4.6"

[tool.poetry.extras]
Expand Down
18 changes: 15 additions & 3 deletions tests/cli/test_api_key_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ def fake_getusgs_cda(**kwargs):

monkeypatch.setitem(sys.modules, "cwmscli.usgs.getusgs_cda", fake_module)
monkeypatch.setattr(deps.importlib, "import_module", lambda name: object())
monkeypatch.setattr(deps.importlib.metadata, "version", lambda name: "999.0.0")
monkeypatch.setattr(
deps.importlib.metadata,
"version",
lambda name: "1.0.7" if name == "cwms-python" else "999.0.0",
)
monkeypatch.setattr(usgs_module, "get_api_key", utils.get_api_key)
result = CliRunner().invoke(
cli,
Expand Down Expand Up @@ -88,7 +92,11 @@ def fake_import_shef_critfile(**kwargs):
sys.modules, "cwmscli.commands.shef.import_critfile", fake_module
)
monkeypatch.setattr(deps.importlib, "import_module", lambda name: object())
monkeypatch.setattr(deps.importlib.metadata, "version", lambda name: "999.0.0")
monkeypatch.setattr(
deps.importlib.metadata,
"version",
lambda name: "1.0.7" if name == "cwms-python" else "999.0.0",
)
monkeypatch.setattr(commands_cwms, "get_api_key", utils.get_api_key, raising=False)
result = CliRunner().invoke(
cli,
Expand Down Expand Up @@ -128,7 +136,11 @@ def fake_import_shef_infile(**kwargs):

monkeypatch.setitem(sys.modules, "cwmscli.commands.shef.import_infile", fake_module)
monkeypatch.setattr(deps.importlib, "import_module", lambda name: object())
monkeypatch.setattr(deps.importlib.metadata, "version", lambda name: "999.0.0")
monkeypatch.setattr(
deps.importlib.metadata,
"version",
lambda name: "1.0.7" if name == "cwms-python" else "999.0.0",
)
monkeypatch.setattr(commands_cwms, "get_api_key", utils.get_api_key, raising=False)
result = CliRunner().invoke(
cli,
Expand Down
6 changes: 5 additions & 1 deletion tests/cli/test_usgs_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ def fake_getusgs_cda(**kwargs):

monkeypatch.setitem(sys.modules, "cwmscli.usgs.getusgs_cda", fake_module)
monkeypatch.setattr(deps.importlib, "import_module", lambda name: object())
monkeypatch.setattr(deps.importlib.metadata, "version", lambda name: "999.0.0")
monkeypatch.setattr(
deps.importlib.metadata,
"version",
lambda name: "1.0.7" if name == "cwms-python" else "999.0.0",
)

result = CliRunner().invoke(
cli,
Expand Down
6 changes: 5 additions & 1 deletion tests/cli/test_usgs_error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ def fake_getusgs_cda(**kwargs):

monkeypatch.setitem(sys.modules, "cwmscli.usgs.getusgs_cda", fake_module)
monkeypatch.setattr(deps.importlib, "import_module", lambda name: object())
monkeypatch.setattr(deps.importlib.metadata, "version", lambda name: "999.0.0")
monkeypatch.setattr(
deps.importlib.metadata,
"version",
lambda name: "1.0.7" if name == "cwms-python" else "999.0.0",
)

result = CliRunner().invoke(
cli,
Expand Down
Loading
Loading