cwms-cli 0.3.5__tar.gz → 0.3.7__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 (66) hide show
  1. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/PKG-INFO +6 -1
  2. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/README.md +5 -0
  3. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/__main__.py +15 -4
  4. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/blob.py +17 -1
  5. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/commands_cwms.py +69 -9
  6. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/test_main.py +35 -0
  7. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/transform.py +3 -2
  8. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/__init__.py +7 -3
  9. cwms_cli-0.3.7/cwmscli/utils/friendly_errors.py +147 -0
  10. cwms_cli-0.3.7/cwmscli/utils/update.py +61 -0
  11. cwms_cli-0.3.7/cwmscli/utils/version.py +68 -0
  12. cwms_cli-0.3.7/cwmscli/utils/version_cli.py +47 -0
  13. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/pyproject.toml +1 -1
  14. cwms_cli-0.3.5/cwmscli/utils/version.py +0 -35
  15. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/LICENSE +0 -0
  16. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/__init__.py +0 -0
  17. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/callbacks/__init__.py +0 -0
  18. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  19. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/README.md +0 -0
  20. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  21. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  22. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/config.py +0 -0
  23. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  24. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  25. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/parser.py +0 -0
  26. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  27. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  28. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  29. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  30. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  31. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  32. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  33. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  34. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  35. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  36. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  37. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  38. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  39. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  40. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/csv2cwms/writer.py +0 -0
  41. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/commands/shef_critfile_import.py +0 -0
  42. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/README.md +0 -0
  43. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/__init__.py +0 -0
  44. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/__main__.py +0 -0
  45. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/location/location.py +0 -0
  46. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/location/location_ids.py +0 -0
  47. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  48. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/root.py +0 -0
  49. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/timeseries/timeseries.py +0 -0
  50. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  51. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/requirements.py +0 -0
  52. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/__main__.py +0 -0
  53. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  54. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/getusgs_cda.py +0 -0
  55. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  56. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  57. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/__init__.py +0 -0
  58. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/click_help.py +0 -0
  59. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/colors.py +0 -0
  60. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/deps.py +0 -0
  61. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/intervals.py +0 -0
  62. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/io.py +0 -0
  63. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/links.py +0 -0
  64. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/logging/__init__.py +0 -0
  65. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/logging/formatters.py +0 -0
  66. {cwms_cli-0.3.5 → cwms_cli-0.3.7}/cwmscli/utils/ssl_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
@@ -45,6 +45,11 @@ Or as of version `0.3.0+`
45
45
  cwms-cli update
46
46
  ```
47
47
 
48
+ To install a specific version:
49
+ ```sh
50
+ cwms-cli update --target-version 0.7.1 --yes
51
+ ```
52
+
48
53
  ## Command line implementation
49
54
 
50
55
  View the help in terminal:
@@ -22,6 +22,11 @@ Or as of version `0.3.0+`
22
22
  cwms-cli update
23
23
  ```
24
24
 
25
+ To install a specific version:
26
+ ```sh
27
+ cwms-cli update --target-version 0.7.1 --yes
28
+ ```
29
+
25
30
  ## Command line implementation
26
31
 
27
32
  View the help in terminal:
@@ -10,6 +10,7 @@ from cwmscli.commands import commands_cwms
10
10
  from cwmscli.load import __main__ as load
11
11
  from cwmscli.usgs import usgs_group
12
12
  from cwmscli.utils.click_help import add_version_to_help_tree
13
+ from cwmscli.utils.friendly_errors import to_user_facing_error
13
14
  from cwmscli.utils.logging import (
14
15
  LoggingConfig,
15
16
  apply_logging_policies,
@@ -17,15 +18,18 @@ from cwmscli.utils.logging import (
17
18
  setup_logging,
18
19
  )
19
20
  from cwmscli.utils.ssl_errors import is_cert_verify_error, ssl_help_text
20
- from cwmscli.utils.version import get_cwms_cli_version
21
+ from cwmscli.utils.version_cli import show_version_and_exit
21
22
 
22
23
 
23
24
  @click.group(context_settings=dict(help_option_names=["-h", "--help"]))
24
- @click.version_option(
25
- get_cwms_cli_version(),
25
+ @click.option(
26
26
  "--version",
27
27
  "-V",
28
- message="cwms-cli version %(version)s",
28
+ is_flag=True,
29
+ is_eager=True,
30
+ expose_value=False,
31
+ callback=show_version_and_exit,
32
+ help="Show the cwms-cli version and exit.",
29
33
  )
30
34
  @click.option(
31
35
  "--log-file",
@@ -115,6 +119,13 @@ def main() -> None:
115
119
  click.echo(ssl_help_text(), err=True)
116
120
  raise SystemExit(2)
117
121
 
122
+ if not debug:
123
+ friendly_error = to_user_facing_error(e)
124
+ if friendly_error is not None:
125
+ logging.debug("Suppressed traceback for CLI exception", exc_info=e)
126
+ friendly_error.show()
127
+ raise SystemExit(friendly_error.exit_code)
128
+
118
129
  # If debug is enabled (or it's not a cert verify error), keep the normal failure behavior.
119
130
  raise
120
131
 
@@ -133,6 +133,11 @@ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Option
133
133
  return get_api_key(api_key, None)
134
134
 
135
135
 
136
+ def _response_status_code(exc: BaseException) -> Optional[int]:
137
+ response = getattr(exc, "response", None)
138
+ return getattr(response, "status_code", None)
139
+
140
+
136
141
  def store_blob(**kwargs):
137
142
  import cwms
138
143
  import requests
@@ -597,6 +602,7 @@ def download_cmd(
597
602
 
598
603
  def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run: bool):
599
604
  import cwms
605
+ import requests
600
606
 
601
607
  if dry_run:
602
608
  logging.info(
@@ -604,7 +610,17 @@ def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run:
604
610
  )
605
611
  return
606
612
  cwms.init_session(api_root=api_root, api_key=api_key)
607
- cwms.delete_blob(office_id=office, blob_id=blob_id)
613
+ try:
614
+ cwms.delete_blob(office_id=office, blob_id=blob_id)
615
+ except requests.HTTPError as e:
616
+ if _response_status_code(e) == 404:
617
+ logging.info(
618
+ "Blob %s was already absent in office %s. Nothing to delete.",
619
+ blob_id,
620
+ office,
621
+ )
622
+ return
623
+ raise
608
624
  logging.info(f"Deleted blob: {blob_id} for office: {office}")
609
625
 
610
626
 
@@ -1,14 +1,21 @@
1
+ import os
1
2
  import subprocess
2
3
  import sys
3
4
  import textwrap
5
+ from typing import Optional
4
6
 
5
7
  import click
6
8
 
7
9
  from cwmscli import requirements as reqs
8
10
  from cwmscli.callbacks import csv_to_list
9
11
  from cwmscli.commands import csv2cwms
10
- from cwmscli.utils import api_key_loc_option, common_api_options, to_uppercase
12
+ from cwmscli.utils import api_key_loc_option, colors, common_api_options, to_uppercase
11
13
  from cwmscli.utils.deps import requires
14
+ from cwmscli.utils.update import (
15
+ build_update_package_spec,
16
+ launch_windows_update,
17
+ looks_like_missing_version,
18
+ )
12
19
  from cwmscli.utils.version import get_cwms_cli_version
13
20
 
14
21
 
@@ -86,7 +93,16 @@ def csv2cwms_cmd(**kwargs):
86
93
  csv2_main(**kwargs)
87
94
 
88
95
 
89
- @click.command("update", help="Update cwms-cli to the latest version using pip.")
96
+ @click.command(
97
+ "update",
98
+ help="Update cwms-cli with pip, optionally targeting a specific version.",
99
+ )
100
+ @click.option(
101
+ "--target-version",
102
+ "target_version",
103
+ metavar="VERSION",
104
+ help="Install a specific cwms-cli version instead of the latest release.",
105
+ )
90
106
  @click.option(
91
107
  "--pre",
92
108
  is_flag=True,
@@ -100,32 +116,76 @@ def csv2cwms_cmd(**kwargs):
100
116
  default=False,
101
117
  help="Skip confirmation prompt and run update immediately.",
102
118
  )
103
- def update_cli_cmd(pre: bool, yes: bool) -> None:
119
+ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
104
120
  current_version = get_cwms_cli_version()
105
- click.echo(f"Current cwms-cli version: {current_version}")
121
+ package_spec = build_update_package_spec(target_version)
106
122
 
107
- cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "cwms-cli"]
123
+ click.echo(
124
+ "Current cwms-cli version: " f"{colors.c(current_version, 'cyan', bright=True)}"
125
+ )
126
+ if target_version:
127
+ click.echo(
128
+ "Requested cwms-cli version: "
129
+ f"{colors.c(target_version, 'cyan', bright=True)}"
130
+ )
131
+ else:
132
+ click.echo("Requested cwms-cli version: latest available release")
133
+
134
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_spec]
108
135
  if pre:
109
136
  cmd.append("--pre")
110
137
 
111
138
  if not yes:
112
139
  proceed = click.confirm("Proceed with updating cwms-cli via pip?", default=True)
113
140
  if not proceed:
114
- click.echo("Update canceled.")
141
+ click.echo(colors.warn("Update canceled."))
115
142
  return
116
143
 
117
144
  click.echo(f"Running: {' '.join(cmd)}")
145
+ if os.name == "nt":
146
+ try:
147
+ script_path = launch_windows_update(cmd)
148
+ except OSError as e:
149
+ raise click.ClickException(
150
+ f"Unable to launch Windows update process: {e}"
151
+ ) from e
152
+ click.echo(
153
+ colors.ok(
154
+ "Opened a separate command window to complete the update after "
155
+ "cwms-cli exits."
156
+ )
157
+ )
158
+ click.echo(f"Update helper script: {script_path}")
159
+ return
160
+
118
161
  try:
119
- result = subprocess.run(cmd, check=False)
162
+ result = subprocess.run(
163
+ cmd,
164
+ check=False,
165
+ capture_output=True,
166
+ text=True,
167
+ )
120
168
  except OSError as e:
121
169
  raise click.ClickException(f"Unable to run pip update command: {e}") from e
122
170
 
171
+ if result.stdout:
172
+ click.echo(result.stdout, nl=False)
173
+ if result.stderr:
174
+ click.echo(result.stderr, err=True, nl=False)
175
+
123
176
  if result.returncode != 0:
177
+ pip_output = "\n".join(part for part in [result.stdout, result.stderr] if part)
178
+ if target_version and looks_like_missing_version(pip_output, package_spec):
179
+ raise click.ClickException(
180
+ colors.err(
181
+ f"Requested cwms-cli version '{target_version}' was not found."
182
+ )
183
+ )
124
184
  raise click.ClickException(
125
- "cwms-cli update failed. Please review pip output above."
185
+ colors.err("cwms-cli update failed. Please review pip output above.")
126
186
  )
127
187
 
128
- click.echo("Update complete. Run `cwms-cli --version` to verify.")
188
+ click.echo(colors.ok("Update complete. Run `cwms-cli --version` to verify."))
129
189
 
130
190
 
131
191
  # region Blob
@@ -133,6 +133,41 @@ def test_load_timeseries_uses_raw_timestamps_when_rounding_disabled(monkeypatch)
133
133
  ]
134
134
 
135
135
 
136
+ def test_load_timeseries_preserves_raw_value_when_precision_omitted(monkeypatch):
137
+ monkeypatch.setenv("CDA_API_KEY", "test-key")
138
+ monkeypatch.setenv("CDA_OFFICE", "SWT")
139
+ monkeypatch.setenv("CDA_HOST", "https://example.test")
140
+
141
+ csv2_main = importlib.import_module("cwmscli.commands.csv2cwms.__main__")
142
+ tz = csv2_main.safe_zoneinfo("UTC")
143
+ epoch = int(datetime(2025, 3, 25, 12, 7, tzinfo=tz).timestamp())
144
+
145
+ file_data = {
146
+ "header": ["Time", "Headwater"],
147
+ "data": {
148
+ epoch: ["2025-03-25 12:07", "10.12345"],
149
+ },
150
+ "source_timezone": tz,
151
+ }
152
+ config = {
153
+ "interval": 900,
154
+ "input_files": {
155
+ "BROK": {
156
+ "timeseries": {
157
+ "BROK.Elev.Inst.15Minutes.0.Rev-SCADA-cda": {
158
+ "columns": "Headwater",
159
+ "units": "ft",
160
+ }
161
+ }
162
+ }
163
+ },
164
+ }
165
+
166
+ result = csv2_main.load_timeseries(file_data, "BROK", config)
167
+
168
+ assert result[0]["values"] == [[epoch * 1000, 10.12345, 3]]
169
+
170
+
136
171
  def test_load_timeseries_rounds_to_configured_interval_when_present(monkeypatch):
137
172
  monkeypatch.setenv("CDA_API_KEY", "test-key")
138
173
  monkeypatch.setenv("CDA_OFFICE", "SWT")
@@ -72,7 +72,8 @@ def select_value(
72
72
  else:
73
73
  value = raw_values[-1]
74
74
 
75
- value = round(value, precision) if value is not None else None
75
+ if value is not None and precision is not None:
76
+ value = round(value, precision)
76
77
  quality = 3 if value is not None else 5
77
78
  return value, quality
78
79
 
@@ -154,7 +155,7 @@ def load_timeseries(file_data, file_key, config, logger):
154
155
  for name, meta in ts_config.items():
155
156
  expr = meta["columns"]
156
157
  units = meta.get("units", "")
157
- precision = meta.get("precision", 2)
158
+ precision = meta.get("precision")
158
159
  ts_data = data
159
160
  ts_interval = interval
160
161
 
@@ -46,7 +46,7 @@ def getusgs_timeseries(office, days_back, api_root, api_key, api_key_loc, backfi
46
46
  from cwmscli.usgs.getusgs_cda import getusgs_cda
47
47
 
48
48
  if backfill is not None:
49
- backfill_list = backfill.replace(" ", "").split(",")
49
+ backfill_list = [item.strip() for item in backfill.split(",") if item.strip()]
50
50
  else:
51
51
  backfill_list = None
52
52
 
@@ -78,7 +78,9 @@ def getusgs_ratings(office, days_back, api_root, api_key, api_key_loc, rating_su
78
78
  from cwmscli.usgs.getUSGS_ratings_cda import getusgs_rating_cda
79
79
 
80
80
  if rating_subset is not None:
81
- rating_list = rating_subset.replace(" ", "").split(",")
81
+ rating_list = [
82
+ item.strip() for item in rating_subset.split(",") if item.strip()
83
+ ]
82
84
  else:
83
85
  rating_list = None
84
86
 
@@ -158,7 +160,9 @@ def getusgs_measurements(
158
160
  if "group" in backfill:
159
161
  backfill_group = True
160
162
  elif type(backfill) == str:
161
- backfill_list = backfill.replace(" ", "").split(",")
163
+ backfill_list = [
164
+ item.strip() for item in backfill.split(",") if item.strip()
165
+ ]
162
166
  api_key = get_api_key(api_key, api_key_loc)
163
167
  getusgs_measurement_cda(
164
168
  api_root=api_root,
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Iterable, Optional, Set
5
+
6
+ import click
7
+
8
+
9
+ class UserFacingError(click.ClickException):
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ hint: Optional[str] = None,
14
+ *,
15
+ exit_code: int = 1,
16
+ ) -> None:
17
+ self.hint = hint
18
+ self.exit_code = exit_code
19
+ full_message = message
20
+ if hint:
21
+ full_message = f"{full_message}\nHint: {hint}"
22
+ super().__init__(full_message)
23
+
24
+
25
+ def _walk_exception_chain(exc: BaseException) -> Iterable[BaseException]:
26
+ seen: Set[int] = set()
27
+ cur: Optional[BaseException] = exc
28
+ while cur is not None and id(cur) not in seen:
29
+ seen.add(id(cur))
30
+ yield cur
31
+ cur = cur.__cause__ or cur.__context__
32
+
33
+
34
+ def _response_text(response) -> str:
35
+ text = getattr(response, "text", None)
36
+ if text:
37
+ return str(text).strip()
38
+
39
+ content = getattr(response, "content", None)
40
+ if isinstance(content, bytes):
41
+ return content.decode("utf-8", errors="replace").strip()
42
+ if content:
43
+ return str(content).strip()
44
+ return ""
45
+
46
+
47
+ def _response_json_field(response, field: str) -> Optional[str]:
48
+ text = _response_text(response)
49
+ if not text:
50
+ return None
51
+ try:
52
+ payload = json.loads(text)
53
+ except Exception:
54
+ return None
55
+ value = payload.get(field)
56
+ if value in (None, ""):
57
+ return None
58
+ return str(value)
59
+
60
+
61
+ def _trim_message(message: str) -> str:
62
+ message = message.strip()
63
+ if not message:
64
+ return message
65
+ return message if message.endswith(".") else f"{message}."
66
+
67
+
68
+ def _friendly_http_error(response) -> Optional[UserFacingError]:
69
+ status = getattr(response, "status_code", None)
70
+ if status is None:
71
+ return None
72
+
73
+ api_message = _response_json_field(response, "message")
74
+ incident_id = _response_json_field(response, "incidentIdentifier")
75
+
76
+ if status == 400:
77
+ return UserFacingError(
78
+ _trim_message(api_message or "CWMS rejected the request"),
79
+ "Check the command arguments and input values, then try again.",
80
+ exit_code=2,
81
+ )
82
+ if status in (401, 403):
83
+ return UserFacingError(
84
+ _trim_message(api_message or "Authentication failed while calling CWMS"),
85
+ "Check CDA_API_KEY, --api-key, and whether the account can access the requested office.",
86
+ exit_code=2,
87
+ )
88
+ if status == 404:
89
+ return UserFacingError(
90
+ _trim_message(api_message or "Requested CWMS resource was not found"),
91
+ "Verify the identifier, office, and any category or group arguments.",
92
+ )
93
+ if status == 409:
94
+ return UserFacingError(
95
+ _trim_message(api_message or "CWMS reported a conflict for this request"),
96
+ "Review overwrite or replace options and confirm the resource state before retrying.",
97
+ )
98
+ if status == 429:
99
+ return UserFacingError(
100
+ _trim_message(api_message or "CWMS rate limited the request"),
101
+ "Wait briefly and retry. If this persists, reduce request frequency.",
102
+ )
103
+ if isinstance(status, int) and status >= 500:
104
+ hint = "Retry later or contact the service owner."
105
+ if incident_id:
106
+ hint += f" Include incidentIdentifier {incident_id}."
107
+ return UserFacingError(
108
+ _trim_message(api_message or "CWMS returned a server error"),
109
+ hint,
110
+ )
111
+
112
+ return UserFacingError(
113
+ _trim_message(api_message or f"CWMS request failed with HTTP {status}"),
114
+ "Re-run with `CWMS_CLI_DEBUG=1` or `cwms-cli --log-level DEBUG ...etc` for a traceback if you need the raw exception details.",
115
+ )
116
+
117
+
118
+ def _is_requests_exception(exc: BaseException, name: str) -> bool:
119
+ try:
120
+ import requests
121
+ except Exception:
122
+ return False
123
+
124
+ return isinstance(exc, getattr(requests.exceptions, name, ()))
125
+
126
+
127
+ def to_user_facing_error(exc: BaseException) -> Optional[UserFacingError]:
128
+ for candidate in _walk_exception_chain(exc):
129
+ response = getattr(candidate, "response", None)
130
+ if response is not None:
131
+ friendly = _friendly_http_error(response)
132
+ if friendly is not None:
133
+ return friendly
134
+
135
+ if _is_requests_exception(candidate, "Timeout"):
136
+ return UserFacingError(
137
+ "Timed out while waiting for CWMS to respond.",
138
+ "Confirm the service is reachable and try again.",
139
+ )
140
+
141
+ if _is_requests_exception(candidate, "ConnectionError"):
142
+ return UserFacingError(
143
+ "Could not reach the CWMS API endpoint.",
144
+ "Check --api-root, network connectivity, and whether the server is running.",
145
+ )
146
+
147
+ return None
@@ -0,0 +1,61 @@
1
+ import subprocess
2
+ import tempfile
3
+ from typing import List, Optional
4
+
5
+
6
+ def build_update_package_spec(target_version: Optional[str]) -> str:
7
+ if target_version:
8
+ return f"cwms-cli=={target_version}"
9
+ return "cwms-cli"
10
+
11
+
12
+ def looks_like_missing_version(pip_output: str, package_spec: str) -> bool:
13
+ return (
14
+ "No matching distribution found for" in pip_output
15
+ or "Could not find a version that satisfies the requirement" in pip_output
16
+ ) and package_spec in pip_output
17
+
18
+
19
+ def write_windows_update_script(cmd: List[str]) -> str:
20
+ quoted_cmd = subprocess.list2cmdline(cmd)
21
+ script = "\r\n".join(
22
+ [
23
+ "@echo off",
24
+ "setlocal",
25
+ "echo Waiting for cwms-cli to exit before updating...",
26
+ "timeout /t 1 /nobreak >nul",
27
+ quoted_cmd,
28
+ 'set "EXIT_CODE=%ERRORLEVEL%"',
29
+ 'if "%EXIT_CODE%"=="0" (',
30
+ " echo Update complete. Run cwms-cli --version to verify.",
31
+ ") else (",
32
+ " echo.",
33
+ " echo cwms-cli update failed. Review pip output above.",
34
+ ")",
35
+ "echo.",
36
+ "echo Press any key to close this window.",
37
+ "pause >nul",
38
+ '(goto) 2>nul & del "%~f0"',
39
+ "exit /b %EXIT_CODE%",
40
+ "",
41
+ ]
42
+ )
43
+ with tempfile.NamedTemporaryFile(
44
+ mode="w",
45
+ suffix=".cmd",
46
+ delete=False,
47
+ encoding="utf-8",
48
+ newline="",
49
+ ) as fh:
50
+ fh.write(script)
51
+ return fh.name
52
+
53
+
54
+ def launch_windows_update(cmd: List[str]) -> str:
55
+ script_path = write_windows_update_script(cmd)
56
+ creationflags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
57
+ subprocess.Popen(
58
+ ["cmd.exe", "/c", script_path],
59
+ creationflags=creationflags,
60
+ )
61
+ return script_path
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from importlib import metadata
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from urllib import error, request
10
+
11
+ PYPI_JSON_URL = "https://pypi.org/pypi/cwms-cli/json"
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def get_cwms_cli_version() -> str:
16
+ """Return installed cwms-cli version, with pyproject fallback for source runs."""
17
+ try:
18
+ return metadata.version("cwms-cli")
19
+ except metadata.PackageNotFoundError:
20
+ pass
21
+
22
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
23
+ if not pyproject.exists():
24
+ return "unknown"
25
+
26
+ text = pyproject.read_text(encoding="utf-8")
27
+
28
+ # Prefer the [tool.poetry] version declaration.
29
+ in_poetry_section = False
30
+ for line in text.splitlines():
31
+ stripped = line.strip()
32
+ if stripped.startswith("[") and stripped.endswith("]"):
33
+ in_poetry_section = stripped == "[tool.poetry]"
34
+ continue
35
+ if in_poetry_section:
36
+ m = re.match(r'^version\s*=\s*"([^"]+)"\s*$', stripped)
37
+ if m:
38
+ return m.group(1)
39
+
40
+ return "unknown"
41
+
42
+
43
+ def _version_key(version: str) -> Optional[tuple[int, ...]]:
44
+ match = re.fullmatch(r"\d+(?:\.\d+)*", version.strip())
45
+ if not match:
46
+ return None
47
+ return tuple(int(part) for part in version.split("."))
48
+
49
+
50
+ def is_newer_version_available(current_version: str, latest_version: str) -> bool:
51
+ current_key = _version_key(current_version)
52
+ latest_key = _version_key(latest_version)
53
+ if current_key is None or latest_key is None:
54
+ return False
55
+ return latest_key > current_key
56
+
57
+
58
+ def get_latest_cwms_cli_version(timeout: float = 1.0) -> Optional[str]:
59
+ try:
60
+ with request.urlopen(PYPI_JSON_URL, timeout=timeout) as response:
61
+ payload = json.load(response)
62
+ except (OSError, ValueError, error.URLError):
63
+ return None
64
+
65
+ version = payload.get("info", {}).get("version")
66
+ if not isinstance(version, str) or not version.strip():
67
+ return None
68
+ return version.strip()
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from cwmscli.utils import colors
9
+ from cwmscli.utils.version import (
10
+ get_cwms_cli_version,
11
+ get_latest_cwms_cli_version,
12
+ is_newer_version_available,
13
+ )
14
+
15
+
16
+ def version_output_allows_color(
17
+ no_color: bool,
18
+ log_file: Optional[str],
19
+ ) -> bool:
20
+ return sys.stdout.isatty() and (not no_color) and (not log_file)
21
+
22
+
23
+ def show_version_and_exit(
24
+ ctx: click.Context, _param: click.Parameter, value: bool
25
+ ) -> None:
26
+ if not value or ctx.resilient_parsing:
27
+ return
28
+
29
+ current_version = get_cwms_cli_version()
30
+ colors.set_enabled(
31
+ version_output_allows_color(
32
+ no_color=bool(ctx.params.get("no_color")),
33
+ log_file=ctx.params.get("log_file"),
34
+ )
35
+ )
36
+
37
+ click.echo(f"cwms-cli version {colors.c(current_version, 'cyan', bright=True)}")
38
+
39
+ latest_version = get_latest_cwms_cli_version()
40
+ if latest_version and is_newer_version_available(current_version, latest_version):
41
+ click.echo(
42
+ colors.warn("Newer version available: ")
43
+ + colors.c(latest_version, "yellow", bright=True)
44
+ )
45
+ click.echo(f"Run: {colors.c('cwms-cli update', 'cyan', bright=True)}")
46
+
47
+ ctx.exit()
@@ -2,7 +2,7 @@
2
2
  name = "cwms-cli"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-cli"
4
4
 
5
- version = "0.3.5"
5
+ version = "0.3.7"
6
6
 
7
7
 
8
8
  packages = [
@@ -1,35 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from functools import lru_cache
5
- from importlib import metadata
6
- from pathlib import Path
7
-
8
-
9
- @lru_cache(maxsize=1)
10
- def get_cwms_cli_version() -> str:
11
- """Return installed cwms-cli version, with pyproject fallback for source runs."""
12
- try:
13
- return metadata.version("cwms-cli")
14
- except metadata.PackageNotFoundError:
15
- pass
16
-
17
- pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
18
- if not pyproject.exists():
19
- return "unknown"
20
-
21
- text = pyproject.read_text(encoding="utf-8")
22
-
23
- # Prefer the [tool.poetry] version declaration.
24
- in_poetry_section = False
25
- for line in text.splitlines():
26
- stripped = line.strip()
27
- if stripped.startswith("[") and stripped.endswith("]"):
28
- in_poetry_section = stripped == "[tool.poetry]"
29
- continue
30
- if in_poetry_section:
31
- m = re.match(r'^version\s*=\s*"([^"]+)"\s*$', stripped)
32
- if m:
33
- return m.group(1)
34
-
35
- return "unknown"
File without changes
File without changes
File without changes
File without changes
File without changes