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
20 changes: 13 additions & 7 deletions .github/workflows/code-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions cwmscli/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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()
77 changes: 77 additions & 0 deletions cwmscli/utils/ssl_errors.py
Original file line number Diff line number Diff line change
@@ -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"
)
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.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ explicit_start = false
preserve_quotes = true

[tool.poetry.scripts]
cwms-cli = "cwmscli.__main__:cli"
cwms-cli = "cwmscli.__main__:main"