cwms-cli 0.7.4__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/PKG-INFO +32 -2
  2. cwms_cli-0.8.1/README.md +68 -0
  3. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/__main__.py +49 -8
  4. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/_generated/ownership_data.py +11 -1
  5. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/blob.py +37 -20
  6. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/clob.py +44 -16
  7. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/commands_cwms.py +67 -11
  8. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/writer.py +4 -0
  9. cwms_cli-0.8.1/cwmscli/commands/env.py +543 -0
  10. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/import_critfile.py +3 -0
  11. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/import_infile.py +8 -0
  12. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/users.py +13 -0
  13. cwms_cli-0.8.1/cwmscli/dss/__init__.py +5 -0
  14. cwms_cli-0.8.1/cwmscli/dss/cli.py +352 -0
  15. cwms_cli-0.8.1/cwmscli/dss/naming.py +318 -0
  16. cwms_cli-0.8.1/cwmscli/dss/transfer.py +218 -0
  17. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location_ids.py +11 -0
  18. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location_ids_bygroup.py +3 -0
  19. cwms_cli-0.8.1/cwmscli/load/root.py +335 -0
  20. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries.py +3 -2
  21. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries_data.py +161 -157
  22. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries_ids.py +3 -0
  23. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/requirements.py +16 -0
  24. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getUSGS_ratings_cda.py +9 -0
  25. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getusgs_cda.py +10 -1
  26. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getusgs_measurements_cda.py +44 -36
  27. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/rating_ini_file_import.py +4 -1
  28. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/__init__.py +1 -0
  29. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/auth.py +7 -8
  30. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/click_help.py +29 -24
  31. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/deps.py +3 -2
  32. cwms_cli-0.8.1/cwmscli/utils/env_store.py +133 -0
  33. cwms_cli-0.8.1/cwmscli/utils/friendly_errors.py +356 -0
  34. cwms_cli-0.8.1/cwmscli/utils/interaction.py +45 -0
  35. cwms_cli-0.8.1/cwmscli/utils/links.py +5 -0
  36. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/logging/__init__.py +1 -1
  37. cwms_cli-0.8.1/cwmscli/utils/paths.py +23 -0
  38. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/ssl_errors.py +14 -2
  39. cwms_cli-0.8.1/cwmscli/utils/update.py +155 -0
  40. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/version_cli.py +7 -1
  41. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/pyproject.toml +8 -5
  42. cwms_cli-0.7.4/README.md +0 -42
  43. cwms_cli-0.7.4/cwmscli/load/root.py +0 -190
  44. cwms_cli-0.7.4/cwmscli/utils/friendly_errors.py +0 -147
  45. cwms_cli-0.7.4/cwmscli/utils/links.py +0 -1
  46. cwms_cli-0.7.4/cwmscli/utils/update.py +0 -61
  47. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/LICENSE +0 -0
  48. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/__init__.py +0 -0
  49. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/_generated/__init__.py +0 -0
  50. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/callbacks/__init__.py +0 -0
  51. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  52. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/README.md +0 -0
  53. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  54. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  55. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/config.py +0 -0
  56. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  57. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  58. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/parser.py +0 -0
  59. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  60. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  61. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  62. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  63. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  64. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  65. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  66. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  67. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  68. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  69. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/transform.py +0 -0
  70. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  71. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  72. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  73. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  74. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  75. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/__init__.py +0 -0
  76. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/shef_parameters.csv +0 -0
  77. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/README.md +0 -0
  78. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/__init__.py +0 -0
  79. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/__main__.py +0 -0
  80. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location.py +0 -0
  81. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/ownership.py +0 -0
  82. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/__init__.py +0 -0
  83. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/__main__.py +0 -0
  84. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/callback_success.html +0 -0
  85. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/colors.py +0 -0
  86. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/intervals.py +0 -0
  87. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/io.py +0 -0
  88. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/logging/formatters.py +0 -0
  89. {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.7.4
3
+ Version: 0.8.1
4
4
  Summary: Command line utilities for Corps Water Management Systems (CWMS) python scripts. This is a collection of shared scripts across the enterprise Water Management Enterprise System (WMES) teams.
5
5
  License: LICENSE
6
6
  License-File: LICENSE
@@ -16,8 +16,12 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Provides-Extra: dss
19
20
  Requires-Dist: click (>=8.1.8,<9.0.0)
20
21
  Requires-Dist: colorama (>=0.4.6,<0.5.0)
22
+ Requires-Dist: cwms-python (>=1.0.7) ; extra == "dss"
23
+ Requires-Dist: hec-python-library (>=0.9.5) ; extra == "dss"
24
+ Requires-Dist: hecdss (>=0.1.24) ; extra == "dss"
21
25
  Requires-Dist: requests (>=2.32.0,<3.0.0) ; python_version == "3.9"
22
26
  Requires-Dist: requests (>=2.33.0,<3.0.0) ; python_version >= "3.10"
23
27
  Project-URL: Repository, https://github.com/HydrologicEngineeringCenter/cwms-cli
@@ -39,7 +43,7 @@ Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your
39
43
 
40
44
  ### Update
41
45
  ```sh
42
- pip install cwms-cli --upgrade
46
+ python -m pip install --upgrade cwms-cli
43
47
  ```
44
48
 
45
49
  Or as of version `0.3.0+`
@@ -47,6 +51,12 @@ Or as of version `0.3.0+`
47
51
  cwms-cli update
48
52
  ```
49
53
 
54
+ `cwms-cli update` displays the Python executable, environment, package metadata
55
+ location, and editable project location when applicable before asking for
56
+ confirmation. It runs pip through the same Python interpreter that is running
57
+ `cwms-cli`, avoiding accidental updates to a different Python installation or
58
+ virtual environment.
59
+
50
60
  To install a specific version:
51
61
  ```sh
52
62
  cwms-cli update --target-version 0.7.1 --yes
@@ -66,3 +76,23 @@ from cwmscli.usgs.getusgs_measurements_cda import getusgs_measurements_cda
66
76
  from cwmscli.usgs.getUSGS_ratings_cda import getusgs_rating_cda
67
77
  ```
68
78
 
79
+ ## Development environment
80
+
81
+ The repository includes a Linux/Python 3.12 development container exercised by
82
+ a dedicated CI job. Open the repository with the VS Code Dev Containers
83
+ extension, or use the Dev Container CLI:
84
+
85
+ ```sh
86
+ devcontainer up --workspace-folder .
87
+ devcontainer exec --workspace-folder . poetry run pytest -q
88
+ ```
89
+
90
+ The container installs project dependencies with Poetry and uses
91
+ `/home/vscode/.venv`, so it does not reuse a host operating system's `.venv`.
92
+ Poetry itself is kept in a separate `/opt/poetry` environment. The workspace
93
+ root is also added to `PYTHONPATH`, so the checked-out source and local test
94
+ modules are available throughout a Codespace without copying them into the
95
+ image. Use the container for changes involving time zones, paths, native
96
+ libraries, or other operating-system-dependent behavior. Standard Python 3.9
97
+ and Python 3.12 installations remain covered by the existing CI matrix.
98
+
@@ -0,0 +1,68 @@
1
+ # cwms-cli
2
+
3
+ A collection of scripts to create, read, update, list, and delete data through CWMS Data API (CDA) and other commonly used API in the US Army Corps of Engineers water management. CWMS-CLI wraps these API in a friendly to use terminal based interface.
4
+
5
+ [![Docs](https://readthedocs.org/projects/cwms-cli/badge/?version=latest)](https://cwms-cli.readthedocs.io/en/latest/cli.html#cwms-cli) - 📖 Read the docs: https://cwms-cli.readthedocs.io/en/latest/
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install cwms-cli
11
+ ```
12
+ Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your path.
13
+
14
+
15
+ ### Update
16
+ ```sh
17
+ python -m pip install --upgrade cwms-cli
18
+ ```
19
+
20
+ Or as of version `0.3.0+`
21
+ ```sh
22
+ cwms-cli update
23
+ ```
24
+
25
+ `cwms-cli update` displays the Python executable, environment, package metadata
26
+ location, and editable project location when applicable before asking for
27
+ confirmation. It runs pip through the same Python interpreter that is running
28
+ `cwms-cli`, avoiding accidental updates to a different Python installation or
29
+ virtual environment.
30
+
31
+ To install a specific version:
32
+ ```sh
33
+ cwms-cli update --target-version 0.7.1 --yes
34
+ ```
35
+
36
+ ## Command line implementation
37
+
38
+ View the help in terminal:
39
+ ```sh
40
+ cwms-cli --help
41
+ ```
42
+
43
+ ## run from within python
44
+ ```python
45
+ from cwmscli.usgs.getusgs_cda import getusgs_cda
46
+ from cwmscli.usgs.getusgs_measurements_cda import getusgs_measurements_cda
47
+ from cwmscli.usgs.getUSGS_ratings_cda import getusgs_rating_cda
48
+ ```
49
+
50
+ ## Development environment
51
+
52
+ The repository includes a Linux/Python 3.12 development container exercised by
53
+ a dedicated CI job. Open the repository with the VS Code Dev Containers
54
+ extension, or use the Dev Container CLI:
55
+
56
+ ```sh
57
+ devcontainer up --workspace-folder .
58
+ devcontainer exec --workspace-folder . poetry run pytest -q
59
+ ```
60
+
61
+ The container installs project dependencies with Poetry and uses
62
+ `/home/vscode/.venv`, so it does not reuse a host operating system's `.venv`.
63
+ Poetry itself is kept in a separate `/opt/poetry` environment. The workspace
64
+ root is also added to `PYTHONPATH`, so the checked-out source and local test
65
+ modules are available throughout a Codespace without copying them into the
66
+ image. Use the container for changes involving time zones, paths, native
67
+ libraries, or other operating-system-dependent behavior. Standard Python 3.9
68
+ and Python 3.12 installations remain covered by the existing CI matrix.
@@ -7,10 +7,17 @@ import click
7
7
  from click.core import ParameterSource
8
8
 
9
9
  from cwmscli.commands import commands_cwms
10
+ from cwmscli.commands.env import env_group
11
+ from cwmscli.dss import dss_group
10
12
  from cwmscli.load import __main__ as load
11
13
  from cwmscli.usgs import usgs_group
12
14
  from cwmscli.utils.click_help import add_version_to_help_tree
13
- from cwmscli.utils.friendly_errors import to_user_facing_error
15
+ from cwmscli.utils.friendly_errors import (
16
+ cda_stack_trace,
17
+ format_cda_stack_trace,
18
+ to_user_facing_error,
19
+ )
20
+ from cwmscli.utils.links import BUG_REPORT_URL
14
21
  from cwmscli.utils.logging import (
15
22
  LoggingConfig,
16
23
  apply_logging_policies,
@@ -57,6 +64,15 @@ from cwmscli.utils.version_cli import show_version_and_exit
57
64
  default=False,
58
65
  help="Suppress routine output; warnings and errors still print.",
59
66
  )
67
+ @click.option(
68
+ "--non-interactive/--interactive",
69
+ default=None,
70
+ envvar="CWMS_CLI_NON_INTERACTIVE",
71
+ help=(
72
+ "Disable prompts. Automatically enabled when standard input is not a "
73
+ "terminal or a CI environment is detected."
74
+ ),
75
+ )
60
76
  @click.pass_context
61
77
  def cli(
62
78
  ctx: click.Context,
@@ -64,6 +80,7 @@ def cli(
64
80
  no_color: bool,
65
81
  log_level: str,
66
82
  quiet: bool,
83
+ non_interactive: Optional[bool],
67
84
  ) -> None:
68
85
  level = getattr(logging, log_level.upper(), logging.INFO)
69
86
  level = apply_logging_policies(
@@ -76,7 +93,7 @@ def cli(
76
93
 
77
94
  # Disable colors if stdout isn't a TTY (piped/redirected)
78
95
  tty = sys.stdout.isatty()
79
- color = (not no_color) and tty
96
+ color = (not no_color) and ("NO_COLOR" not in os.environ) and tty
80
97
  setup_logging(LoggingConfig(level=level, log_file=log_file, color=color))
81
98
 
82
99
 
@@ -88,7 +105,9 @@ cli.add_command(commands_cwms.update_cli_cmd)
88
105
  cli.add_command(commands_cwms.blob_group)
89
106
  cli.add_command(commands_cwms.clob_group)
90
107
  cli.add_command(commands_cwms.users_group)
108
+ cli.add_command(env_group)
91
109
  cli.add_command(load.load_group)
110
+ cli.add_command(dss_group)
92
111
  add_version_to_help_tree(cli)
93
112
 
94
113
 
@@ -111,25 +130,47 @@ def main() -> None:
111
130
  except SystemExit:
112
131
  raise
113
132
  except click.ClickException as e:
133
+ debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
134
+ if debug:
135
+ server_stack_trace = cda_stack_trace(e)
136
+ if server_stack_trace is not None:
137
+ click.echo(format_cda_stack_trace(server_stack_trace), err=True)
138
+ raise SystemExit(e.exit_code)
139
+ else:
140
+ if is_cert_verify_error(e):
141
+ click.echo(ssl_help_text(), err=True)
142
+ raise SystemExit(2)
143
+ friendly_error = to_user_facing_error(e)
144
+ if friendly_error is not None:
145
+ logging.debug("Suppressed traceback for CLI exception", exc_info=e)
146
+ friendly_error.show()
147
+ raise SystemExit(friendly_error.exit_code)
114
148
  e.show()
115
149
  raise SystemExit(e.exit_code)
116
150
  except Exception as e:
151
+ # The environment switch supports failures before Click configures logging.
152
+ # Once CLI setup has run, --log-level DEBUG enables the same behavior.
153
+ debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
154
+
117
155
  if is_cert_verify_error(e) and not debug:
118
- # Keep this short, no stack trace.
119
- logging.error(
120
- "SSL certificate verification failed while connecting to the server."
121
- )
122
156
  click.echo(ssl_help_text(), err=True)
123
157
  raise SystemExit(2)
124
158
 
125
- if not debug:
159
+ if debug:
160
+ server_stack_trace = cda_stack_trace(e)
161
+ if server_stack_trace is not None:
162
+ click.echo(format_cda_stack_trace(server_stack_trace), err=True)
163
+ raise SystemExit(1)
164
+ else:
126
165
  friendly_error = to_user_facing_error(e)
127
166
  if friendly_error is not None:
128
167
  logging.debug("Suppressed traceback for CLI exception", exc_info=e)
129
168
  friendly_error.show()
130
169
  raise SystemExit(friendly_error.exit_code)
131
170
 
132
- # If debug is enabled (or it's not a cert verify error), keep the normal failure behavior.
171
+ click.echo(f"Unexpected error. Report it at {BUG_REPORT_URL}", err=True)
172
+ # Preserve raw exception behavior when CDA did not provide a server stack
173
+ # trace so an issue report includes useful diagnostic details.
133
174
  raise
134
175
 
135
176
 
@@ -15,6 +15,16 @@ OWNERSHIP_DATA = {
15
15
  "name": "Charles Graham"
16
16
  }
17
17
  ],
18
+ "cwms-cli env": [
19
+ {
20
+ "email": "mitchell.s.weier@usace.army.mil",
21
+ "name": "Mitch Weier"
22
+ },
23
+ {
24
+ "email": "charles.r.graham@usace.army.mil",
25
+ "name": "Charles Graham"
26
+ }
27
+ ],
18
28
  "cwms-cli load": [
19
29
  {
20
30
  "email": "charles.r.graham@usace.army.mil",
@@ -25,7 +35,7 @@ OWNERSHIP_DATA = {
25
35
  "name": "Eric Novotny"
26
36
  }
27
37
  ],
28
- "cwms-cli shefcritimport": [
38
+ "cwms-cli shef": [
29
39
  {
30
40
  "email": "eric.v.novotny@usace.army.mil",
31
41
  "name": "Eric Novotny"
@@ -20,6 +20,10 @@ from cwmscli.utils import (
20
20
  )
21
21
  from cwmscli.utils.click_help import DOCS_BASE_URL
22
22
  from cwmscli.utils.deps import requires
23
+ from cwmscli.utils.friendly_errors import (
24
+ is_actionable_service_error,
25
+ is_fatal_service_error,
26
+ )
23
27
 
24
28
  # used to rebuild data URL for images
25
29
  DATA_URL_RE = re.compile(r"^data:(?P<mime>[^;]+);base64,(?P<data>.+)$", re.I | re.S)
@@ -122,7 +126,9 @@ def _default_download_dest(blob_id: str) -> str:
122
126
  def _blob_media_type(cwms_module, office: str, blob_id: str) -> Optional[str]:
123
127
  try:
124
128
  result = cwms_module.get_blobs(office_id=office, blob_id_like=blob_id)
125
- except Exception:
129
+ except Exception as error:
130
+ if is_fatal_service_error(error):
131
+ raise
126
132
  return None
127
133
 
128
134
  df = getattr(result, "df", result)
@@ -222,12 +228,11 @@ def store_blob(**kwargs):
222
228
  cwms.store_blobs(blob, fail_if_exists=kwargs.get("overwrite"))
223
229
  logging.info(f"Successfully stored blob with ID: {blob_id}")
224
230
  logging.info(f"View: {view_url}")
225
- except requests.HTTPError as e:
226
- # Include response text when available
227
- detail = getattr(e.response, "text", "") or str(e)
228
- logging.error(f"Failed to store blob (HTTP): {detail}")
229
- sys.exit(1)
231
+ except requests.HTTPError:
232
+ raise
230
233
  except Exception as e:
234
+ if is_actionable_service_error(e):
235
+ raise
231
236
  logging.error(f"Failed to store blob: {e}")
232
237
  sys.exit(1)
233
238
 
@@ -257,11 +262,11 @@ def retrieve_blob(**kwargs):
257
262
  media_type_hint=_blob_media_type(cwms, kwargs.get("office"), blob_id),
258
263
  )
259
264
  logging.info(f"Downloaded blob to: {blob_id}")
260
- except requests.HTTPError as e:
261
- detail = getattr(e.response, "text", "") or str(e)
262
- logging.error(f"Failed to retrieve blob (HTTP): {detail}")
263
- sys.exit(1)
265
+ except requests.HTTPError:
266
+ raise
264
267
  except Exception as e:
268
+ if is_actionable_service_error(e):
269
+ raise
265
270
  logging.error(f"Failed to retrieve blob: {e}")
266
271
  sys.exit(1)
267
272
 
@@ -279,11 +284,11 @@ def delete_blob(**kwargs):
279
284
  blob_id=kwargs.get("blob_id").upper(),
280
285
  )
281
286
  logging.info(f"Successfully deleted blob with ID: {blob_id}")
282
- except requests.HTTPError as e:
283
- details = getattr(e.response, "text", "") or str(e)
284
- logging.error(f"Failed to delete blob (HTTP): {details}")
285
- sys.exit(1)
287
+ except requests.HTTPError:
288
+ raise
286
289
  except Exception as e:
290
+ if is_actionable_service_error(e):
291
+ raise
287
292
  logging.error(f"Failed to delete blob: {e}")
288
293
  sys.exit(1)
289
294
 
@@ -550,6 +555,8 @@ def upload_cmd(
550
555
  )
551
556
  logging.info(f"View: {view_url}")
552
557
  except requests.HTTPError as e:
558
+ if is_fatal_service_error(e):
559
+ raise
553
560
  failures += 1
554
561
  detail = getattr(e.response, "text", "") or str(e)
555
562
  logging.error(
@@ -560,6 +567,8 @@ def upload_cmd(
560
567
  )
561
568
  )
562
569
  except Exception as e:
570
+ if is_fatal_service_error(e):
571
+ raise
563
572
  failures += 1
564
573
  logging.error(
565
574
  colors.c(
@@ -613,15 +622,13 @@ def download_cmd(
613
622
  try:
614
623
  blob_content = cwms.get_blob(office_id=office, blob_id=bid)
615
624
  target = dest or _default_download_dest(bid)
616
- _save_blob_content(
625
+ saved_target = _save_blob_content(
617
626
  blob_content,
618
627
  dest=target,
619
628
  media_type_hint=_blob_media_type(cwms, office, bid),
620
629
  )
621
- logging.info(f"Downloaded blob to: {target}")
622
- except requests.HTTPError as e:
623
- detail = getattr(e.response, "text", "") or str(e)
624
- logging.error(f"Failed to download (HTTP): {detail}")
630
+ logging.info(f"Downloaded blob to: {saved_target}")
631
+ except requests.HTTPError:
625
632
  log_scoped_read_hint(
626
633
  credential_kind=credential_kind,
627
634
  anonymous=anonymous,
@@ -629,9 +636,19 @@ def download_cmd(
629
636
  action="download",
630
637
  resource="blob content",
631
638
  )
632
- sys.exit(1)
639
+ raise
633
640
  except Exception as e:
641
+ if is_actionable_service_error(e):
642
+ log_scoped_read_hint(
643
+ credential_kind=credential_kind,
644
+ anonymous=anonymous,
645
+ office=office,
646
+ action="download",
647
+ resource="blob content",
648
+ )
649
+ raise
634
650
  logging.error(format_local_download_error(e, BLOB_DOCS_URL))
651
+ # Local write/path failures are not CDA credential scope problems.
635
652
  if not isinstance(e, (OSError, ValueError)):
636
653
  log_scoped_read_hint(
637
654
  credential_kind=credential_kind,
@@ -12,10 +12,16 @@ from cwms import api as cwms_api
12
12
  from cwmscli.utils import (
13
13
  format_local_download_error,
14
14
  get_api_key,
15
+ get_saved_login_token,
15
16
  has_invalid_chars,
17
+ init_cwms_session,
16
18
  log_scoped_read_hint,
17
19
  validate_default_download_dest,
18
20
  )
21
+ from cwmscli.utils.click_help import DOCS_BASE_URL
22
+ from cwmscli.utils.friendly_errors import is_actionable_service_error
23
+
24
+ CLOB_DOCS_URL = f"{DOCS_BASE_URL}/cli/clob.html"
19
25
 
20
26
 
21
27
  def _join_api_url(api_root: str, path: str) -> str:
@@ -28,6 +34,16 @@ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Option
28
34
  return get_api_key(api_key, None)
29
35
 
30
36
 
37
+ def _resolve_credential_kind(api_key: Optional[str], anonymous: bool) -> Optional[str]:
38
+ if anonymous:
39
+ return None
40
+ if get_saved_login_token():
41
+ return "token"
42
+ if _resolve_optional_api_key(api_key, anonymous):
43
+ return "api_key"
44
+ return None
45
+
46
+
31
47
  def _write_clob_content(content: str, dest: str) -> str:
32
48
  os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
33
49
  with open(dest, "w", encoding="utf-8", newline="") as f:
@@ -36,12 +52,17 @@ def _write_clob_content(content: str, dest: str) -> str:
36
52
 
37
53
 
38
54
  def _default_download_dest(clob_id: str) -> str:
39
- return validate_default_download_dest(clob_id, resource_name="Clob")
55
+ return validate_default_download_dest(
56
+ clob_id,
57
+ resource_name="Clob",
58
+ docs_url=CLOB_DOCS_URL,
59
+ )
40
60
 
41
61
 
42
62
  def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
43
63
  normalized = clob_id.upper()
44
64
  if has_invalid_chars(normalized):
65
+ # Path-like IDs use the placeholder route and keep the real ID in query params.
45
66
  return "ignored", normalized
46
67
  return normalized, None
47
68
 
@@ -167,11 +188,11 @@ def upload_cmd(
167
188
  )
168
189
  else:
169
190
  logging.info(f"View: {view_url}")
170
- except requests.HTTPError as e:
171
- detail = getattr(e.response, "text", "") or str(e)
172
- logging.error(f"Failed to upload (HTTP): {detail}")
173
- sys.exit(1)
191
+ except requests.HTTPError:
192
+ raise
174
193
  except Exception as e:
194
+ if is_actionable_service_error(e):
195
+ raise
175
196
  logging.error(f"Failed to upload: {e}")
176
197
  sys.exit(1)
177
198
 
@@ -190,8 +211,8 @@ def download_cmd(
190
211
  f"DRY RUN: would GET {api_root} clob with clob-id={clob_id} office={office}."
191
212
  )
192
213
  return
193
- resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
194
- cwms.init_session(api_root=api_root, api_key=resolved_api_key)
214
+ credential_kind = _resolve_credential_kind(api_key, anonymous)
215
+ init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous)
195
216
  bid = clob_id.upper()
196
217
  logging.debug(f"Office={office} clobID={bid}")
197
218
 
@@ -211,19 +232,26 @@ def download_cmd(
211
232
  target = dest or _default_download_dest(bid)
212
233
  _write_clob_content(content, target)
213
234
  logging.info(f"Downloaded clob to: {target}")
214
- except requests.HTTPError as e:
215
- detail = getattr(e.response, "text", "") or str(e)
216
- logging.error(f"Failed to download (HTTP): {detail}")
235
+ except requests.HTTPError:
217
236
  log_scoped_read_hint(
218
- api_key=resolved_api_key,
237
+ credential_kind=credential_kind,
219
238
  anonymous=anonymous,
220
239
  office=office,
221
240
  action="download",
222
241
  resource="clob content",
223
242
  )
224
- sys.exit(1)
243
+ raise
225
244
  except Exception as e:
226
- logging.error(format_local_download_error(e, ""))
245
+ if is_actionable_service_error(e):
246
+ log_scoped_read_hint(
247
+ credential_kind=credential_kind,
248
+ anonymous=anonymous,
249
+ office=office,
250
+ action="download",
251
+ resource="clob content",
252
+ )
253
+ raise
254
+ logging.error(format_local_download_error(e, CLOB_DOCS_URL))
227
255
  sys.exit(1)
228
256
 
229
257
 
@@ -305,8 +333,8 @@ def list_cmd(
305
333
  api_key: str,
306
334
  anonymous: bool = False,
307
335
  ):
308
- resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
309
- cwms.init_session(api_root=api_root, api_key=resolved_api_key)
336
+ credential_kind = _resolve_credential_kind(api_key, anonymous)
337
+ init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous)
310
338
  try:
311
339
  df = list_clobs(
312
340
  office=office,
@@ -319,7 +347,7 @@ def list_cmd(
319
347
  )
320
348
  except Exception:
321
349
  log_scoped_read_hint(
322
- api_key=resolved_api_key,
350
+ credential_kind=credential_kind,
323
351
  anonymous=anonymous,
324
352
  office=office,
325
353
  action="list",
@@ -1,7 +1,6 @@
1
1
  import logging
2
2
  import os
3
3
  import subprocess
4
- import sys
5
4
  import textwrap
6
5
  from pathlib import Path
7
6
  from typing import Optional
@@ -24,9 +23,12 @@ from cwmscli.utils import (
24
23
  )
25
24
  from cwmscli.utils.auth import DEFAULT_REDIRECT_HOST, DEFAULT_REDIRECT_PORT
26
25
  from cwmscli.utils.deps import requires
26
+ from cwmscli.utils.interaction import is_non_interactive
27
27
  from cwmscli.utils.update import (
28
28
  build_update_package_spec,
29
+ get_update_environment,
29
30
  launch_windows_update,
31
+ looks_like_externally_managed_environment,
30
32
  looks_like_missing_version,
31
33
  )
32
34
  from cwmscli.utils.version import get_cwms_cli_version
@@ -416,6 +418,7 @@ def csv2cwms_cmd(**kwargs):
416
418
  def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
417
419
  current_version = get_cwms_cli_version()
418
420
  package_spec = build_update_package_spec(target_version)
421
+ update_environment = get_update_environment()
419
422
 
420
423
  click.echo(
421
424
  "Current cwms-cli version: " f"{colors.c(current_version, 'cyan', bright=True)}"
@@ -428,12 +431,41 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
428
431
  else:
429
432
  click.echo("Requested cwms-cli version: latest available release")
430
433
 
431
- cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_spec]
434
+ click.echo("Update environment:")
435
+ click.echo(f" Python executable: {update_environment.python_executable}")
436
+ click.echo(
437
+ f" Environment: {update_environment.environment_prefix} "
438
+ f"({update_environment.environment_type})"
439
+ )
440
+ click.echo(f" Package metadata location: {update_environment.package_location}")
441
+ if update_environment.editable_project_location:
442
+ click.echo(
443
+ " Editable project location: "
444
+ f"{update_environment.editable_project_location}"
445
+ )
446
+
447
+ cmd = [
448
+ update_environment.python_executable,
449
+ "-m",
450
+ "pip",
451
+ "install",
452
+ "--upgrade",
453
+ package_spec,
454
+ ]
432
455
  if pre:
433
456
  cmd.append("--pre")
434
457
 
435
458
  if not yes:
436
- proceed = click.confirm("Proceed with updating cwms-cli via pip?", default=True)
459
+ if is_non_interactive():
460
+ raise click.ClickException(
461
+ "The update command requires confirmation, but prompting is "
462
+ "disabled in non-interactive mode. Re-run with --yes to authorize "
463
+ "the update."
464
+ )
465
+ proceed = click.confirm(
466
+ "Proceed with updating cwms-cli in this environment via pip?",
467
+ default=True,
468
+ )
437
469
  if not proceed:
438
470
  click.echo(colors.warn("Update canceled."))
439
471
  return
@@ -472,6 +504,16 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
472
504
 
473
505
  if result.returncode != 0:
474
506
  pip_output = "\n".join(part for part in [result.stdout, result.stderr] if part)
507
+ if looks_like_externally_managed_environment(pip_output):
508
+ raise click.ClickException(
509
+ colors.err(
510
+ "The selected Python installation is externally managed, so pip "
511
+ "refused to update cwms-cli. Install cwms-cli in a virtual "
512
+ "environment or with pipx, then run that installation's "
513
+ "cwms-cli update command. cwms-cli will not use "
514
+ "--break-system-packages automatically."
515
+ )
516
+ )
475
517
  if target_version and looks_like_missing_version(pip_output, package_spec):
476
518
  raise click.ClickException(
477
519
  colors.err(
@@ -492,13 +534,15 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
492
534
  @click.group(
493
535
  "blob",
494
536
  help="Manage CWMS Blobs (upload, download, delete, update, list)",
495
- epilog=textwrap.dedent("""
537
+ epilog=textwrap.dedent(
538
+ """
496
539
  Example Usage:\n
497
540
  - Store a PDF/image as a CWMS blob with optional description\n
498
541
  - Download a blob by id to your local filesystem\n
499
542
  - Update a blob's name/description/mime-type\n
500
543
  - Bulk list blobs for an office
501
- """),
544
+ """
545
+ ),
502
546
  )
503
547
  def blob_group():
504
548
  pass
@@ -671,10 +715,15 @@ def update_cmd(**kwargs):
671
715
  show_default=True,
672
716
  help="Sort descending instead of ascending.",
673
717
  )
674
- @click.option("--limit", type=int, default=None, help="Max rows to show.")
718
+ @click.option(
719
+ "--limit",
720
+ type=click.IntRange(min=1),
721
+ default=None,
722
+ help="Max rows to show.",
723
+ )
675
724
  @click.option(
676
725
  "--page-size",
677
- type=int,
726
+ type=click.IntRange(min=1),
678
727
  default=None,
679
728
  help="Max rows to request from the blob endpoint. Defaults to --limit if set, otherwise no pagination (all results in one page).",
680
729
  )
@@ -706,12 +755,14 @@ def list_cmd(**kwargs):
706
755
  @click.group(
707
756
  "clob",
708
757
  help="Manage CWMS Clobs (upload, download, delete, update, list)",
709
- epilog=textwrap.dedent("""
758
+ epilog=textwrap.dedent(
759
+ """
710
760
  Example Usage:\n
711
761
  - Download a clob by id to your local filesystem\n
712
762
  - Update a clob's name/description/mime-type\n
713
763
  - Bulk list clobs for an office
714
- """),
764
+ """
765
+ ),
715
766
  )
716
767
  @requires(reqs.cwms)
717
768
  def clob_group():
@@ -837,10 +888,15 @@ def update_cmd(**kwargs):
837
888
  show_default=True,
838
889
  help="Sort descending instead of ascending.",
839
890
  )
840
- @click.option("--limit", type=int, default=None, help="Max rows to show.")
891
+ @click.option(
892
+ "--limit",
893
+ type=click.IntRange(min=1),
894
+ default=None,
895
+ help="Max rows to show.",
896
+ )
841
897
  @click.option(
842
898
  "--page-size",
843
- type=int,
899
+ type=click.IntRange(min=1),
844
900
  default=None,
845
901
  help="Max rows to request from the clob endpoint. Defaults to --limit when set.",
846
902
  )