diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml index 74eaaf2..2d9defc 100644 --- a/.github/workflows/code-check.yml +++ b/.github/workflows/code-check.yml @@ -7,13 +7,19 @@ jobs: # Run basic code quality checks. check-code: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 - # Verify that python files are formatted using black and isort. Both of the actions - # below simply check the source code and fail if they find any files that need to be - # formatted. The code is not automatically reformatted like it is when running the - # pre-commit hooks. - - uses: psf/black@26.1.0 - - uses: isort/isort-action@v1 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry (no venv) + install deps + run: | + poetry config virtualenvs.create false + poetry install --no-interaction + + - uses: pre-commit/action@v3.0.1 diff --git a/cwmscli/__main__.py b/cwmscli/__main__.py index 84ef976..e3d277d 100644 --- a/cwmscli/__main__.py +++ b/cwmscli/__main__.py @@ -1,8 +1,15 @@ +from __future__ import annotations + +import logging +import os +import sys + import click from cwmscli.commands import commands_cwms from cwmscli.load import __main__ as load from cwmscli.usgs import usgs_group +from cwmscli.utils.ssl_errors import is_cert_verify_error, ssl_help_text @click.group() @@ -15,3 +22,35 @@ def cli(): cli.add_command(commands_cwms.csv2cwms_cmd) cli.add_command(commands_cwms.blob_group) cli.add_command(load.load_group) + + +def main() -> None: + """ + Entrypoint wrapper so we can print friendly guidance without a traceback + for known TLS/cert issues. + """ + debug = os.getenv("CWMS_CLI_DEBUG", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + try: + cli(standalone_mode=False) + except SystemExit: + raise + except Exception as e: + if is_cert_verify_error(e) and not debug: + # Keep this short, no stack trace. + logging.error( + "SSL certificate verification failed while connecting to the server." + ) + click.echo(ssl_help_text(), err=True) + raise SystemExit(2) + + # If debug is enabled (or it's not a cert verify error), keep the normal failure behavior. + raise + + +if __name__ == "__main__": + main() diff --git a/cwmscli/utils/ssl_errors.py b/cwmscli/utils/ssl_errors.py new file mode 100644 index 0000000..ea64b84 --- /dev/null +++ b/cwmscli/utils/ssl_errors.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +import ssl +import sys +from typing import Iterable + + +def _walk_exception_chain(exc: BaseException) -> Iterable[BaseException]: + seen: set[int] = set() + cur: BaseException | None = exc + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + yield cur + cur = cur.__cause__ or cur.__context__ + + +def is_cert_verify_error(exc: BaseException) -> bool: + try: + import requests + except Exception: + requests = None + + try: + import urllib3 + except Exception: + urllib3 = None + + for e in _walk_exception_chain(exc): + if isinstance(e, ssl.SSLCertVerificationError): + return True + if isinstance(e, ssl.SSLError) and "CERTIFICATE_VERIFY_FAILED" in str(e): + return True + if requests is not None: + if isinstance(e, getattr(requests.exceptions, "SSLError", ())): + if ( + "CERTIFICATE_VERIFY_FAILED" in str(e) + or "certificate verify failed" in str(e).lower() + ): + return True + if urllib3 is not None: + if isinstance(e, getattr(urllib3.exceptions, "SSLError", ())): + if ( + "CERTIFICATE_VERIFY_FAILED" in str(e) + or "certificate verify failed" in str(e).lower() + ): + return True + return False + + +def ssl_help_text() -> str: + if os.name == "nt" or sys.platform.startswith("win"): + return ( + "TLS certificate verification failed.\n\n" + "Windows fix (recommended):\n" + " python -m pip install --upgrade pip-system-certs\n\n" + "Then re-run your command.\n" + ) + + if sys.platform.startswith(("sunos", "sunos5", "solaris")): + return ( + "TLS certificate verification failed.\n\n" + "Solaris fix: configure Python/requests to use your system/DoD trust bundle.\n" + "Add one of these to your shell profile (e.g., ~/.bashrc) and start a new shell:\n\n" + " export SSL_CERT_FILE=/path/to/your/dod_ca_bundle.pem\n" + " # or\n" + " export REQUESTS_CA_BUNDLE=/path/to/your/dod_ca_bundle.pem\n\n" + "Use the bundle path required by your environment.\n" + ) + + return ( + "TLS certificate verification failed.\n\n" + "Fix: configure Python/requests to use your organization trust bundle.\n" + "Common options:\n" + " export SSL_CERT_FILE=/path/to/ca-bundle.pem\n" + " export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem\n" + ) diff --git a/poetry.lock b/poetry.lock index 25305e7..1b33e5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -264,7 +264,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\" and python_version >= \"3.11\""} +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\" and python_version >= \"3.10\" or platform_system == \"Windows\""} [[package]] name = "cwms-python" @@ -1263,7 +1263,7 @@ description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version >= \"3.11\"" +markers = "python_version >= \"3.10\"" files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, diff --git a/pyproject.toml b/pyproject.toml index 0340ef7..c4cee9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,4 +44,4 @@ explicit_start = false preserve_quotes = true [tool.poetry.scripts] -cwms-cli = "cwmscli.__main__:cli" +cwms-cli = "cwmscli.__main__:main"