cwms-cli 0.8.0__tar.gz → 0.9.0__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 (84) hide show
  1. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/PKG-INFO +8 -5
  2. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/README.md +6 -4
  3. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/__main__.py +20 -5
  4. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/blob.py +34 -18
  5. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/clob.py +16 -8
  6. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/commands_cwms.py +12 -9
  7. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/writer.py +4 -0
  8. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/env.py +39 -4
  9. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/shef/import_critfile.py +3 -0
  10. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/shef/import_infile.py +8 -0
  11. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/users.py +49 -8
  12. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/dss/transfer.py +4 -1
  13. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/location/location_ids.py +3 -0
  14. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/location/location_ids_bygroup.py +3 -0
  15. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries_data.py +161 -157
  16. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries_ids.py +3 -0
  17. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/getUSGS_ratings_cda.py +9 -0
  18. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/getusgs_cda.py +117 -1
  19. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/getusgs_measurements_cda.py +44 -36
  20. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/rating_ini_file_import.py +4 -1
  21. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/auth.py +4 -2
  22. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/click_help.py +9 -2
  23. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/deps.py +36 -6
  24. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/friendly_errors.py +136 -15
  25. cwms_cli-0.9.0/cwmscli/utils/interaction.py +45 -0
  26. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/ssl_errors.py +14 -2
  27. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/version_cli.py +7 -1
  28. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/pyproject.toml +1 -1
  29. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/LICENSE +0 -0
  30. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/__init__.py +0 -0
  31. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/_generated/__init__.py +0 -0
  32. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/_generated/ownership_data.py +0 -0
  33. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/callbacks/__init__.py +0 -0
  34. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  35. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/README.md +0 -0
  36. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  37. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  38. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/config.py +0 -0
  39. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  40. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  41. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/parser.py +0 -0
  42. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  43. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  44. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  45. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  46. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  47. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  48. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  49. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  50. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  51. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  52. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/transform.py +0 -0
  53. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  54. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  55. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  56. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  57. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  58. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/shef/__init__.py +0 -0
  59. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/commands/shef/shef_parameters.csv +0 -0
  60. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/dss/__init__.py +0 -0
  61. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/dss/cli.py +0 -0
  62. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/dss/naming.py +0 -0
  63. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/README.md +0 -0
  64. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/__init__.py +0 -0
  65. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/__main__.py +0 -0
  66. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/location/location.py +0 -0
  67. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/root.py +0 -0
  68. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries.py +0 -0
  69. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/ownership.py +0 -0
  70. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/requirements.py +0 -0
  71. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/__init__.py +0 -0
  72. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/usgs/__main__.py +0 -0
  73. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/__init__.py +0 -0
  74. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/callback_success.html +0 -0
  75. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/colors.py +0 -0
  76. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/env_store.py +0 -0
  77. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/intervals.py +0 -0
  78. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/io.py +0 -0
  79. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/links.py +0 -0
  80. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/logging/__init__.py +0 -0
  81. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/logging/formatters.py +0 -0
  82. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/paths.py +0 -0
  83. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/update.py +0 -0
  84. {cwms_cli-0.8.0 → cwms_cli-0.9.0}/cwmscli/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
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,6 +16,7 @@ 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
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Provides-Extra: dss
20
21
  Requires-Dist: click (>=8.1.8,<9.0.0)
21
22
  Requires-Dist: colorama (>=0.4.6,<0.5.0)
@@ -89,8 +90,10 @@ devcontainer exec --workspace-folder . poetry run pytest -q
89
90
 
90
91
  The container installs project dependencies with Poetry and uses
91
92
  `/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. Use the container
93
- for changes involving time zones, paths, native libraries, or other
94
- operating-system-dependent behavior. Standard Python 3.9 and Python 3.12
95
- installations remain covered by the existing CI matrix.
93
+ Poetry itself is kept in a separate `/opt/poetry` environment. The workspace
94
+ root is also added to `PYTHONPATH`, so the checked-out source and local test
95
+ modules are available throughout a Codespace without copying them into the
96
+ image. Use the container for changes involving time zones, paths, native
97
+ libraries, or other operating-system-dependent behavior. Standard Python 3.9
98
+ and Python 3.12 installations remain covered by the existing CI matrix.
96
99
 
@@ -60,7 +60,9 @@ devcontainer exec --workspace-folder . poetry run pytest -q
60
60
 
61
61
  The container installs project dependencies with Poetry and uses
62
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. Use the container
64
- for changes involving time zones, paths, native libraries, or other
65
- operating-system-dependent behavior. Standard Python 3.9 and Python 3.12
66
- installations remain covered by the existing CI matrix.
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.
@@ -64,6 +64,15 @@ from cwmscli.utils.version_cli import show_version_and_exit
64
64
  default=False,
65
65
  help="Suppress routine output; warnings and errors still print.",
66
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
+ )
67
76
  @click.pass_context
68
77
  def cli(
69
78
  ctx: click.Context,
@@ -71,6 +80,7 @@ def cli(
71
80
  no_color: bool,
72
81
  log_level: str,
73
82
  quiet: bool,
83
+ non_interactive: Optional[bool],
74
84
  ) -> None:
75
85
  level = getattr(logging, log_level.upper(), logging.INFO)
76
86
  level = apply_logging_policies(
@@ -83,7 +93,7 @@ def cli(
83
93
 
84
94
  # Disable colors if stdout isn't a TTY (piped/redirected)
85
95
  tty = sys.stdout.isatty()
86
- color = (not no_color) and tty
96
+ color = (not no_color) and ("NO_COLOR" not in os.environ) and tty
87
97
  setup_logging(LoggingConfig(level=level, log_file=log_file, color=color))
88
98
 
89
99
 
@@ -126,6 +136,15 @@ def main() -> None:
126
136
  if server_stack_trace is not None:
127
137
  click.echo(format_cda_stack_trace(server_stack_trace), err=True)
128
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)
129
148
  e.show()
130
149
  raise SystemExit(e.exit_code)
131
150
  except Exception as e:
@@ -134,10 +153,6 @@ def main() -> None:
134
153
  debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
135
154
 
136
155
  if is_cert_verify_error(e) and not debug:
137
- # Keep this short, no stack trace.
138
- logging.error(
139
- "SSL certificate verification failed while connecting to the server."
140
- )
141
156
  click.echo(ssl_help_text(), err=True)
142
157
  raise SystemExit(2)
143
158
 
@@ -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(
@@ -619,9 +628,7 @@ def download_cmd(
619
628
  media_type_hint=_blob_media_type(cwms, office, bid),
620
629
  )
621
630
  logging.info(f"Downloaded blob to: {saved_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}")
631
+ except requests.HTTPError:
625
632
  log_scoped_read_hint(
626
633
  credential_kind=credential_kind,
627
634
  anonymous=anonymous,
@@ -629,8 +636,17 @@ 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))
635
651
  # Local write/path failures are not CDA credential scope problems.
636
652
  if not isinstance(e, (OSError, ValueError)):
@@ -19,6 +19,7 @@ from cwmscli.utils import (
19
19
  validate_default_download_dest,
20
20
  )
21
21
  from cwmscli.utils.click_help import DOCS_BASE_URL
22
+ from cwmscli.utils.friendly_errors import is_actionable_service_error
22
23
 
23
24
  CLOB_DOCS_URL = f"{DOCS_BASE_URL}/cli/clob.html"
24
25
 
@@ -187,11 +188,11 @@ def upload_cmd(
187
188
  )
188
189
  else:
189
190
  logging.info(f"View: {view_url}")
190
- except requests.HTTPError as e:
191
- detail = getattr(e.response, "text", "") or str(e)
192
- logging.error(f"Failed to upload (HTTP): {detail}")
193
- sys.exit(1)
191
+ except requests.HTTPError:
192
+ raise
194
193
  except Exception as e:
194
+ if is_actionable_service_error(e):
195
+ raise
195
196
  logging.error(f"Failed to upload: {e}")
196
197
  sys.exit(1)
197
198
 
@@ -231,9 +232,7 @@ def download_cmd(
231
232
  target = dest or _default_download_dest(bid)
232
233
  _write_clob_content(content, target)
233
234
  logging.info(f"Downloaded clob to: {target}")
234
- except requests.HTTPError as e:
235
- detail = getattr(e.response, "text", "") or str(e)
236
- logging.error(f"Failed to download (HTTP): {detail}")
235
+ except requests.HTTPError:
237
236
  log_scoped_read_hint(
238
237
  credential_kind=credential_kind,
239
238
  anonymous=anonymous,
@@ -241,8 +240,17 @@ def download_cmd(
241
240
  action="download",
242
241
  resource="clob content",
243
242
  )
244
- sys.exit(1)
243
+ raise
245
244
  except Exception as 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
246
254
  logging.error(format_local_download_error(e, CLOB_DOCS_URL))
247
255
  sys.exit(1)
248
256
 
@@ -23,6 +23,7 @@ from cwmscli.utils import (
23
23
  )
24
24
  from cwmscli.utils.auth import DEFAULT_REDIRECT_HOST, DEFAULT_REDIRECT_PORT
25
25
  from cwmscli.utils.deps import requires
26
+ from cwmscli.utils.interaction import is_non_interactive
26
27
  from cwmscli.utils.update import (
27
28
  build_update_package_spec,
28
29
  get_update_environment,
@@ -455,6 +456,12 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
455
456
  cmd.append("--pre")
456
457
 
457
458
  if not yes:
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
+ )
458
465
  proceed = click.confirm(
459
466
  "Proceed with updating cwms-cli in this environment via pip?",
460
467
  default=True,
@@ -527,15 +534,13 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
527
534
  @click.group(
528
535
  "blob",
529
536
  help="Manage CWMS Blobs (upload, download, delete, update, list)",
530
- epilog=textwrap.dedent(
531
- """
537
+ epilog=textwrap.dedent("""
532
538
  Example Usage:\n
533
539
  - Store a PDF/image as a CWMS blob with optional description\n
534
540
  - Download a blob by id to your local filesystem\n
535
541
  - Update a blob's name/description/mime-type\n
536
542
  - Bulk list blobs for an office
537
- """
538
- ),
543
+ """),
539
544
  )
540
545
  def blob_group():
541
546
  pass
@@ -748,14 +753,12 @@ def list_cmd(**kwargs):
748
753
  @click.group(
749
754
  "clob",
750
755
  help="Manage CWMS Clobs (upload, download, delete, update, list)",
751
- epilog=textwrap.dedent(
752
- """
756
+ epilog=textwrap.dedent("""
753
757
  Example Usage:\n
754
758
  - Download a clob by id to your local filesystem\n
755
759
  - Update a clob's name/description/mime-type\n
756
760
  - Bulk list clobs for an office
757
- """
758
- ),
761
+ """),
759
762
  )
760
763
  @requires(reqs.cwms)
761
764
  def clob_group():
@@ -1010,7 +1013,7 @@ def users_roles_list_user(user_name, office, api_root, api_key, api_key_loc):
1010
1013
  multiple=True,
1011
1014
  default=None,
1012
1015
  callback=csv_to_list,
1013
- help="enter admin, readonly, readwrite, or individual role name(s) to add. Repeat the option or pass a comma/pipe-separated list.",
1016
+ help="enter admin, readonly, readwrite, batchadmin, or individual role name(s) to add. Repeat the option or pass a comma/pipe-separated list.",
1014
1017
  )
1015
1018
  @requires(reqs.cwms)
1016
1019
  def users_roles_add(office, api_root, api_key, api_key_loc, user_name, roles):
@@ -2,6 +2,8 @@ import traceback
2
2
 
3
3
  import cwms
4
4
 
5
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
6
+
5
7
 
6
8
  def write_timeseries(
7
9
  file_name, ts_data, config_item, office, dry_run, config_path, logger
@@ -26,6 +28,8 @@ def write_timeseries(
26
28
  )
27
29
  logger.info(f"Stored {ts_object['name']} values")
28
30
  except Exception as e:
31
+ if is_fatal_service_error(e):
32
+ raise
29
33
  logger.error(
30
34
  f"Error posting data for {file_name}: {e}\n{traceback.format_exc()}"
31
35
  )
@@ -16,6 +16,9 @@ from cwmscli.utils.env_store import (
16
16
  load_env,
17
17
  save_env,
18
18
  )
19
+ from cwmscli.utils.friendly_errors import to_user_facing_error
20
+ from cwmscli.utils.interaction import is_non_interactive
21
+ from cwmscli.utils.ssl_errors import is_cert_verify_error, ssl_help_text
19
22
 
20
23
  SENSITIVE_KEYS = {"CDA_API_KEY"}
21
24
 
@@ -43,11 +46,16 @@ def _check_env(env_config: Dict[str, str]) -> Dict:
43
46
  resp = requests.get(url, timeout=5)
44
47
  latency_ms = int((time.monotonic() - t0) * 1000)
45
48
  except requests.RequestException as e:
49
+ if is_cert_verify_error(e):
50
+ error = ssl_help_text().strip()
51
+ else:
52
+ friendly = to_user_facing_error(e)
53
+ error = friendly.format_message() if friendly is not None else str(e)
46
54
  return {
47
55
  "reachable": False,
48
56
  "latency_ms": None,
49
57
  "auth": "skipped",
50
- "error": str(e),
58
+ "error": error,
51
59
  }
52
60
 
53
61
  if resp.status_code >= 400:
@@ -69,12 +77,17 @@ def _check_env(env_config: Dict[str, str]) -> Dict:
69
77
 
70
78
  try:
71
79
  auth_resp = requests.get(url, headers={"Authorization": api_key}, timeout=5)
72
- except requests.RequestException:
80
+ except requests.RequestException as e:
81
+ if is_cert_verify_error(e):
82
+ error = ssl_help_text().strip()
83
+ else:
84
+ friendly = to_user_facing_error(e)
85
+ error = friendly.format_message() if friendly is not None else str(e)
73
86
  return {
74
87
  "reachable": True,
75
88
  "latency_ms": latency_ms,
76
89
  "auth": "failed",
77
- "error": None,
90
+ "error": error,
78
91
  }
79
92
 
80
93
  if auth_resp.status_code == 401:
@@ -82,7 +95,21 @@ def _check_env(env_config: Dict[str, str]) -> Dict:
82
95
  "reachable": True,
83
96
  "latency_ms": latency_ms,
84
97
  "auth": "failed",
85
- "error": None,
98
+ "error": (
99
+ "CDA rejected the API key (HTTP 401). Update CDA_API_KEY in this "
100
+ "environment, then retry."
101
+ ),
102
+ }
103
+
104
+ if auth_resp.status_code == 403:
105
+ return {
106
+ "reachable": True,
107
+ "latency_ms": latency_ms,
108
+ "auth": "failed",
109
+ "error": (
110
+ "CDA recognized the credentials but denied access (HTTP 403). "
111
+ "Confirm the account's roles and office access."
112
+ ),
86
113
  }
87
114
 
88
115
  return {"reachable": True, "latency_ms": latency_ms, "auth": "ok", "error": None}
@@ -210,6 +237,8 @@ def show_cmd(check: bool):
210
237
  auth_str = click.style("authenticated", fg="green")
211
238
  elif result["auth"] == "failed":
212
239
  auth_str = click.style("auth failed", fg="red")
240
+ if result["error"]:
241
+ auth_str += f" — {result['error']}"
213
242
 
214
243
  click.echo(f" Connect: {reach_str}")
215
244
  if auth_str:
@@ -227,6 +256,12 @@ def delete_cmd(env_name: str, yes: bool):
227
256
  cwms-cli env delete myenv
228
257
  cwms-cli env delete myenv --yes
229
258
  """
259
+ if not yes and is_non_interactive():
260
+ raise click.ClickException(
261
+ "Deleting an environment requires confirmation, but prompting is "
262
+ "disabled in non-interactive mode. Re-run with --yes to authorize "
263
+ "the deletion."
264
+ )
230
265
  if not yes and not click.confirm(f"Delete environment '{env_name}'?"):
231
266
  click.echo("Cancelled")
232
267
  return
@@ -6,6 +6,7 @@ import cwms
6
6
  import pandas as pd
7
7
 
8
8
  from cwmscli.utils import init_cwms_session
9
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
9
10
 
10
11
 
11
12
  def import_shef_critfile(
@@ -97,6 +98,8 @@ def import_shef_critfile(
97
98
  f'SUCCESS Stored timeseries ID {data["Timeseries ID"]} to {group_id}'
98
99
  )
99
100
  except Exception as error:
101
+ if is_fatal_service_error(error):
102
+ raise
100
103
  logging.error(
101
104
  f'FAIL Data could not be stored to CWMS database for --> {data["Timeseries ID"]},{data["Alias"]} error = {error}'
102
105
  )
@@ -50,6 +50,8 @@ from typing import Optional
50
50
 
51
51
  import pandas as pd
52
52
 
53
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
54
+
53
55
  try:
54
56
  import cwms
55
57
  import cwms.api as cwms_api
@@ -599,6 +601,8 @@ def _filter_existing_tsids(
599
601
  cwms.get_timeseries_identifier(ts_id=e["tsid"], office_id=office_id)
600
602
  valid.append(e)
601
603
  except Exception as exc:
604
+ if is_fatal_service_error(exc):
605
+ raise
602
606
  log.warning(
603
607
  "Skipping missing TSID '%s' (%s)",
604
608
  e["tsid"],
@@ -619,6 +623,8 @@ def _save(
619
623
  log.info("SUCCESS — group stored via store_timeseries_groups.")
620
624
  return
621
625
  except Exception as exc:
626
+ if is_fatal_service_error(exc):
627
+ raise
622
628
  log.warning(
623
629
  "store_timeseries_groups raised %s: %s — retrying with update ...",
624
630
  type(exc).__name__,
@@ -651,6 +657,8 @@ def store_group(
651
657
  _save(group_json, group_id, office_id, fail_if_exists)
652
658
  return
653
659
  except Exception as exc:
660
+ if is_fatal_service_error(exc):
661
+ raise
654
662
  if entries is None:
655
663
  raise
656
664
  log.warning(
@@ -5,6 +5,7 @@ from typing import Optional, Union
5
5
  import click
6
6
 
7
7
  from cwmscli.utils import colors, init_cwms_session
8
+ from cwmscli.utils.interaction import is_non_interactive
8
9
 
9
10
 
10
11
  def _format_table(headers: list[str], rows: list[list[str]]) -> str:
@@ -57,11 +58,45 @@ def _fetch_roles(cwms_module) -> list[str]:
57
58
  _handle_api_error(error, cwms_module)
58
59
 
59
60
 
61
+ # Role shortcut expansions used by `_expand_role_shortcuts` and
62
+ # referenced during validation so expanded roles are accepted even when the
63
+ # CWMS role catalog returned by the API does not include all shortcut values.
64
+ ROLE_SHORTCUTS: dict[str, list[str]] = {
65
+ "admin": [
66
+ "All Users",
67
+ "CWMS Users",
68
+ "TS ID Creator",
69
+ "CWMS User Admins",
70
+ "CWMS PD Users",
71
+ "Data Acquisition Mgr",
72
+ ],
73
+ "readonly": ["All Users", "CWMS Users"],
74
+ "readwrite": ["All Users", "CWMS Users", "TS ID Creator"],
75
+ "batchadmin": [
76
+ "All Users",
77
+ "CWMS Users",
78
+ "TS ID Creator",
79
+ "Data Acquisition Mgr",
80
+ ],
81
+ }
82
+
83
+
60
84
  def _fetch_users(
61
85
  cwms_module, office=None, username_like=None, page_size: int = 5000
62
86
  ) -> list[dict]:
63
87
  users: list[dict] = []
64
88
 
89
+ # If a username_like filter was provided, ensure it is wrapped with
90
+ # leading and trailing wildcards so CDA receives a contains-style query.
91
+ if username_like:
92
+ _ul = str(username_like).strip()
93
+ if _ul:
94
+ if not _ul.startswith("*"):
95
+ _ul = "*" + _ul
96
+ if not _ul.endswith("*"):
97
+ _ul = _ul + "*"
98
+ username_like = _ul
99
+
65
100
  try:
66
101
  response = cwms_module.get_users(
67
102
  office_id=office, username_like=username_like, page_size=page_size
@@ -134,19 +169,13 @@ def _split_roles(
134
169
 
135
170
 
136
171
  def _expand_role_shortcuts(roles: list[str]) -> list[str]:
137
- emap = {
138
- "admin": ["All Users", "CWMS Users", "TS ID Creator", "CWMS User Admins"],
139
- "readonly": ["All Users", "CWMS Users"],
140
- "readwrite": ["All Users", "CWMS Users", "TS ID Creator"],
141
- }
142
-
143
172
  expanded_roles: list[str] = []
144
173
  for role in roles:
145
174
  key = role.strip().casefold()
146
175
  if key == "all":
147
176
  expanded_roles.append(role) # Keep "all" as is, handled elsewhere
148
- elif key in emap:
149
- expanded_roles.extend(emap[key])
177
+ elif key in ROLE_SHORTCUTS:
178
+ expanded_roles.extend(ROLE_SHORTCUTS[key])
150
179
  else:
151
180
  expanded_roles.append(role)
152
181
  return expanded_roles
@@ -325,6 +354,12 @@ def add_roles(
325
354
  f"{_cmd('cwms-cli users roles add')} interactively with no add-specific args."
326
355
  )
327
356
 
357
+ if not provided_user_name and not provided_roles and is_non_interactive():
358
+ raise click.ClickException(
359
+ "User role changes cannot prompt in non-interactive mode. Specify "
360
+ "both --user-name and --roles."
361
+ )
362
+
328
363
  cwms = _init_cwms(api_root, api_key, api_key_loc)
329
364
  users = _fetch_users(cwms)
330
365
  available_roles = _fetch_roles(cwms)
@@ -373,6 +408,12 @@ def delete_roles(
373
408
  f"{_cmd('cwms-cli users roles delete')} interactively with no delete-specific args."
374
409
  )
375
410
 
411
+ if not provided_user_name and not provided_roles and is_non_interactive():
412
+ raise click.ClickException(
413
+ "User role changes cannot prompt in non-interactive mode. Specify "
414
+ "both --user-name and --roles."
415
+ )
416
+
376
417
  cwms = _init_cwms(api_root, api_key, api_key_loc)
377
418
  users = _fetch_users(cwms, office)
378
419
  available_roles = _fetch_roles(cwms)
@@ -7,6 +7,7 @@ from typing import Callable, Iterable, Optional, Protocol
7
7
 
8
8
  from cwmscli.dss.naming import ExportRule, ImportRule
9
9
  from cwmscli.utils import colors, init_cwms_session
10
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
@@ -71,7 +72,9 @@ def transfer_all(
71
72
  colors.c(str(timeseries.name), "cyan", bright=True),
72
73
  )
73
74
  summary.transferred += 1
74
- except Exception:
75
+ except Exception as error:
76
+ if is_fatal_service_error(error):
77
+ raise
75
78
  summary.failed += 1
76
79
  logger.exception("Failed to transfer %s", colors.err(str(identifier)))
77
80
  return summary
@@ -8,6 +8,7 @@ import cwms
8
8
  import pandas as pd
9
9
 
10
10
  from cwmscli.utils import init_cwms_session
11
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
11
12
  from cwmscli.utils.links import CDA_REGEXP_GUIDE_URL
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -89,6 +90,8 @@ def load_locations(
89
90
  if verbose:
90
91
  logger.info("%s", result)
91
92
  except Exception as e:
93
+ if is_fatal_service_error(e):
94
+ raise
92
95
  errors += 1
93
96
  click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
94
97
 
@@ -8,6 +8,7 @@ import cwms
8
8
  import pandas as pd
9
9
 
10
10
  from cwmscli.utils import init_cwms_session
11
+ from cwmscli.utils.friendly_errors import is_fatal_service_error
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
@@ -129,6 +130,8 @@ def copy_from_group(
129
130
  if verbose:
130
131
  logger.info("\tStored successfully.")
131
132
  except Exception as e:
133
+ if is_fatal_service_error(e):
134
+ raise
132
135
  errors += 1
133
136
  click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
134
137