cwms-cli 0.6.0__tar.gz → 0.7.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 (77) hide show
  1. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/PKG-INFO +1 -1
  2. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/blob.py +20 -9
  3. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/clob.py +13 -10
  4. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/location/location.py +19 -6
  5. cwms_cli-0.7.1/cwmscli/load/location/location_ids.py +147 -0
  6. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/location/location_ids_bygroup.py +10 -2
  7. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/root.py +72 -6
  8. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/__init__.py +61 -0
  9. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/pyproject.toml +1 -1
  10. cwms_cli-0.6.0/cwmscli/load/location/location_ids.py +0 -105
  11. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/LICENSE +0 -0
  12. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/README.md +0 -0
  13. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/__init__.py +0 -0
  14. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/__main__.py +0 -0
  15. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/_generated/__init__.py +0 -0
  16. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/_generated/ownership_data.py +0 -0
  17. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/callbacks/__init__.py +0 -0
  18. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/commands_cwms.py +0 -0
  19. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  20. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/README.md +0 -0
  21. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  22. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  23. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/config.py +0 -0
  24. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  25. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  26. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/parser.py +0 -0
  27. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  28. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  29. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  30. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  31. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  32. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  33. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  34. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  35. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  36. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  37. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/transform.py +0 -0
  38. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  39. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  40. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  41. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  42. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  43. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/csv2cwms/writer.py +0 -0
  44. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/shef/__init__.py +0 -0
  45. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/shef/import_critfile.py +0 -0
  46. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/shef/import_infile.py +0 -0
  47. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/shef/shef_parameters.csv +0 -0
  48. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/commands/users.py +0 -0
  49. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/README.md +0 -0
  50. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/__init__.py +0 -0
  51. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/__main__.py +0 -0
  52. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/timeseries/timeseries.py +0 -0
  53. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/timeseries/timeseries_data.py +0 -0
  54. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  55. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/ownership.py +0 -0
  56. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/requirements.py +0 -0
  57. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/__init__.py +0 -0
  58. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/__main__.py +0 -0
  59. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  60. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/getusgs_cda.py +0 -0
  61. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  62. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  63. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/auth.py +0 -0
  64. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/callback_success.html +0 -0
  65. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/click_help.py +0 -0
  66. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/colors.py +0 -0
  67. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/deps.py +0 -0
  68. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/friendly_errors.py +0 -0
  69. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/intervals.py +0 -0
  70. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/io.py +0 -0
  71. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/links.py +0 -0
  72. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/logging/__init__.py +0 -0
  73. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/logging/formatters.py +0 -0
  74. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/ssl_errors.py +0 -0
  75. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/update.py +0 -0
  76. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/version.py +0 -0
  77. {cwms_cli-0.6.0 → cwms_cli-0.7.1}/cwmscli/utils/version_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -11,10 +11,12 @@ from typing import Optional, Sequence, Tuple, Union
11
11
 
12
12
  from cwmscli.utils import (
13
13
  colors,
14
+ format_local_download_error,
14
15
  get_api_key,
15
16
  has_invalid_chars,
16
17
  init_cwms_session,
17
18
  log_scoped_read_hint,
19
+ validate_default_download_dest,
18
20
  )
19
21
  from cwmscli.utils.click_help import DOCS_BASE_URL
20
22
  from cwmscli.utils.deps import requires
@@ -109,6 +111,14 @@ def _save_blob_content(
109
111
  return dest
110
112
 
111
113
 
114
+ def _default_download_dest(blob_id: str) -> str:
115
+ return validate_default_download_dest(
116
+ blob_id,
117
+ resource_name="Blob",
118
+ docs_url=BLOB_DOCS_URL,
119
+ )
120
+
121
+
112
122
  def _blob_media_type(cwms_module, office: str, blob_id: str) -> Optional[str]:
113
123
  try:
114
124
  result = cwms_module.get_blobs(office_id=office, blob_id_like=blob_id)
@@ -602,7 +612,7 @@ def download_cmd(
602
612
 
603
613
  try:
604
614
  blob_content = cwms.get_blob(office_id=office, blob_id=bid)
605
- target = dest or bid
615
+ target = dest or _default_download_dest(bid)
606
616
  _save_blob_content(
607
617
  blob_content,
608
618
  dest=target,
@@ -621,14 +631,15 @@ def download_cmd(
621
631
  )
622
632
  sys.exit(1)
623
633
  except Exception as e:
624
- logging.error(f"Failed to download: {e}")
625
- log_scoped_read_hint(
626
- credential_kind=credential_kind,
627
- anonymous=anonymous,
628
- office=office,
629
- action="download",
630
- resource="blob content",
631
- )
634
+ logging.error(format_local_download_error(e, BLOB_DOCS_URL))
635
+ if not isinstance(e, (OSError, ValueError)):
636
+ log_scoped_read_hint(
637
+ credential_kind=credential_kind,
638
+ anonymous=anonymous,
639
+ office=office,
640
+ action="download",
641
+ resource="blob content",
642
+ )
632
643
  sys.exit(1)
633
644
 
634
645
 
@@ -9,7 +9,13 @@ import pandas as pd
9
9
  import requests
10
10
  from cwms import api as cwms_api
11
11
 
12
- from cwmscli.utils import get_api_key, has_invalid_chars, log_scoped_read_hint
12
+ from cwmscli.utils import (
13
+ format_local_download_error,
14
+ get_api_key,
15
+ has_invalid_chars,
16
+ log_scoped_read_hint,
17
+ validate_default_download_dest,
18
+ )
13
19
 
14
20
 
15
21
  def _join_api_url(api_root: str, path: str) -> str:
@@ -29,6 +35,10 @@ def _write_clob_content(content: str, dest: str) -> str:
29
35
  return dest
30
36
 
31
37
 
38
+ def _default_download_dest(clob_id: str) -> str:
39
+ return validate_default_download_dest(clob_id, resource_name="Clob")
40
+
41
+
32
42
  def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
33
43
  normalized = clob_id.upper()
34
44
  if has_invalid_chars(normalized):
@@ -198,7 +208,7 @@ def download_cmd(
198
208
  content = str(payload)
199
209
  else:
200
210
  content = _get_special_clob_text(office=office, clob_id=query_id)
201
- target = dest or bid
211
+ target = dest or _default_download_dest(bid)
202
212
  _write_clob_content(content, target)
203
213
  logging.info(f"Downloaded clob to: {target}")
204
214
  except requests.HTTPError as e:
@@ -213,14 +223,7 @@ def download_cmd(
213
223
  )
214
224
  sys.exit(1)
215
225
  except Exception as e:
216
- logging.error(f"Failed to download: {e}")
217
- log_scoped_read_hint(
218
- api_key=resolved_api_key,
219
- anonymous=anonymous,
220
- office=office,
221
- action="download",
222
- resource="clob content",
223
- )
226
+ logging.error(format_local_download_error(e, ""))
224
227
  sys.exit(1)
225
228
 
226
229
 
@@ -5,6 +5,7 @@ import click
5
5
 
6
6
  from cwmscli import requirements as reqs
7
7
  from cwmscli.load.root import (
8
+ csv_source_target_options,
8
9
  load_group,
9
10
  shared_source_target_options,
10
11
  validate_cda_targets,
@@ -24,12 +25,14 @@ def location(ctx):
24
25
  @location.command(
25
26
  "ids-all",
26
27
  help=(
27
- "Copy locations from a source CDA catalog to a target CDA. "
28
+ "Copy locations from a source CDA catalog to a target CDA, "
29
+ "or to/from a CSV file via --source-csv / --target-csv. "
28
30
  "The --like and --location-kind-like filters use CDA regex semantics. "
29
31
  f"Regex guide: {CDA_REGEXP_GUIDE_URL}"
30
32
  ),
31
33
  )
32
34
  @shared_source_target_options
35
+ @csv_source_target_options(allow_source_csv=True, allow_target_csv=True)
33
36
  @click.option(
34
37
  "--like",
35
38
  default=None,
@@ -57,14 +60,16 @@ def location(ctx):
57
60
  @requires(reqs.cwms)
58
61
  @validate_cda_targets
59
62
  def load_locations(
60
- source_cda: str,
61
- source_office: str,
62
- target_cda: str,
63
+ source_cda: Optional[str],
64
+ source_office: Optional[str],
65
+ target_cda: Optional[str],
63
66
  target_api_key: Optional[str],
64
67
  verbose: int,
65
68
  dry_run: bool,
66
69
  like: Optional[str],
67
70
  location_kind_like: Optional[Iterable[str]] = None,
71
+ source_csv: Optional[str] = None,
72
+ target_csv: Optional[str] = None,
68
73
  ):
69
74
  from cwmscli.load.location.location_ids import load_locations as _load_locations
70
75
 
@@ -77,14 +82,20 @@ def load_locations(
77
82
  dry_run=dry_run,
78
83
  like=like,
79
84
  location_kind_like=location_kind_like,
85
+ source_csv=source_csv,
86
+ target_csv=target_csv,
80
87
  )
81
88
 
82
89
 
83
90
  @location.command(
84
91
  "ids-bygroup",
85
- help="Copy locations from a CWMS Location Group (source CDA) to a target CDA.",
92
+ help=(
93
+ "Copy locations from a CWMS Location Group (source CDA) to a target CDA, "
94
+ "or export the resolved members to a CSV file via --target-csv."
95
+ ),
86
96
  )
87
97
  @shared_source_target_options
98
+ @csv_source_target_options(allow_source_csv=False, allow_target_csv=True)
88
99
  @click.option(
89
100
  "--group-id", required=True, help="Location Group ID (e.g., 'Ark Basin')."
90
101
  )
@@ -112,7 +123,7 @@ def load_locations(
112
123
  def load_locations_from_group(
113
124
  source_cda: str,
114
125
  source_office: str,
115
- target_cda: str,
126
+ target_cda: Optional[str],
116
127
  target_api_key: Optional[str],
117
128
  verbose: int,
118
129
  group_id: str,
@@ -121,6 +132,7 @@ def load_locations_from_group(
121
132
  category_office_id: Optional[str],
122
133
  filter_office: bool,
123
134
  dry_run: bool,
135
+ target_csv: Optional[str] = None,
124
136
  ):
125
137
  from cwmscli.load.location.location_ids_bygroup import copy_from_group
126
138
 
@@ -136,4 +148,5 @@ def load_locations_from_group(
136
148
  category_office_id=category_office_id,
137
149
  filter_office=filter_office,
138
150
  dry_run=dry_run,
151
+ target_csv=target_csv,
139
152
  )
@@ -0,0 +1,147 @@
1
+ import logging
2
+ import math
3
+ import re
4
+ from typing import Iterable, Optional
5
+
6
+ import click
7
+ import cwms
8
+ import pandas as pd
9
+
10
+ from cwmscli.utils import init_cwms_session
11
+ from cwmscli.utils.links import CDA_REGEXP_GUIDE_URL
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def load_locations(
17
+ source_cda: Optional[str],
18
+ source_office: Optional[str],
19
+ target_cda: Optional[str],
20
+ target_api_key: Optional[str],
21
+ verbose: int,
22
+ dry_run: bool,
23
+ like: Optional[str],
24
+ location_kind_like: Optional[Iterable[str]] = "ALL",
25
+ source_csv: Optional[str] = None,
26
+ target_csv: Optional[str] = None,
27
+ ):
28
+ src_label = source_csv or source_cda or "-"
29
+ tgt_label = target_csv or target_cda or "-"
30
+
31
+ if verbose:
32
+ logger.info(
33
+ f"[load locations] source={src_label} ({source_office or '-'}) -> target={tgt_label}"
34
+ )
35
+ logger.info(
36
+ f" like={like or '-'} kinds={list(location_kind_like) or '-'} dry_run={dry_run}"
37
+ )
38
+ if like or (
39
+ location_kind_like
40
+ and list(location_kind_like) != ["ALL"]
41
+ and list(location_kind_like) != []
42
+ ):
43
+ logger.info(" CDA regex guide: %s", CDA_REGEXP_GUIDE_URL)
44
+
45
+ if source_csv:
46
+ df = pd.read_csv(source_csv)
47
+ locations = df.to_dict(orient="records")
48
+ else:
49
+ init_cwms_session(cwms, api_root=source_cda)
50
+ locations = _fetch_locations_from_cda(
51
+ source_office=source_office,
52
+ like=like,
53
+ location_kind_like=location_kind_like,
54
+ verbose=verbose,
55
+ )
56
+
57
+ if verbose:
58
+ logger.info("Got %s locations from source", len(locations))
59
+
60
+ if dry_run:
61
+ for loc in locations:
62
+ logger.info(
63
+ f"[dry-run] would store Location(name={loc['name']}) to {tgt_label} "
64
+ f"({source_office or loc.get('office-id') or '-'})"
65
+ )
66
+ return
67
+
68
+ if target_csv:
69
+ pd.DataFrame(locations).to_csv(target_csv, index=False)
70
+ click.echo(f"Wrote {len(locations)} locations to {target_csv}")
71
+ return
72
+
73
+ init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key)
74
+
75
+ errors = 0
76
+ for loc in locations:
77
+ loc = _clean_row(loc)
78
+ try:
79
+ if loc.get("active") is True:
80
+ result = cwms.store_location(data=loc, fail_if_exists=False)
81
+ if verbose:
82
+ logger.info("%s", result)
83
+ except Exception as e:
84
+ errors += 1
85
+ click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
86
+
87
+ if errors:
88
+ raise click.ClickException(f"Completed with {errors} error(s).")
89
+
90
+ click.echo("Done.")
91
+
92
+
93
+ def _fetch_locations_from_cda(
94
+ source_office: str,
95
+ like: Optional[str],
96
+ location_kind_like: Optional[Iterable[str]],
97
+ verbose: int,
98
+ ) -> list:
99
+ cat_kwargs = {"office_id": source_office}
100
+ if like:
101
+ cat_kwargs["like"] = like
102
+ kinds = list(location_kind_like) if location_kind_like else ["ALL"]
103
+ if "ALL" in kinds:
104
+ kinds = ["ALL"]
105
+
106
+ if kinds == ["ALL"] and not like:
107
+ return cwms.get_locations(office_id=source_office).json
108
+
109
+ locations = []
110
+ seen_location_ids = set()
111
+ for kind in kinds:
112
+ cat_kwargs_k = dict(cat_kwargs)
113
+ if kind != "ALL":
114
+ cat_kwargs_k["location_kind_like"] = kind
115
+
116
+ if verbose >= 2:
117
+ logger.debug(" > catalog query: %s", cat_kwargs_k)
118
+
119
+ resp = cwms.get_locations_catalog(**cat_kwargs_k)
120
+ if resp.df.empty:
121
+ continue
122
+
123
+ for location_id in resp.df["name"].tolist():
124
+ if location_id in seen_location_ids:
125
+ continue
126
+ seen_location_ids.add(location_id)
127
+ if verbose >= 2:
128
+ logger.debug(" > location fetch: %s", location_id)
129
+ detail_resp = cwms.get_locations(
130
+ office_id=source_office,
131
+ location_ids=rf"^{re.escape(location_id)}$",
132
+ )
133
+ if detail_resp and detail_resp.json:
134
+ locations.extend(detail_resp.json)
135
+ return locations
136
+
137
+
138
+ def _clean_row(loc: dict) -> dict:
139
+ cleaned = {}
140
+ for k, v in loc.items():
141
+ if isinstance(v, float) and math.isnan(v):
142
+ cleaned[k] = None
143
+ elif isinstance(v, str) and v in ("True", "False"):
144
+ cleaned[k] = v == "True"
145
+ else:
146
+ cleaned[k] = v
147
+ return cleaned
@@ -5,6 +5,7 @@ from typing import Optional
5
5
 
6
6
  import click
7
7
  import cwms
8
+ import pandas as pd
8
9
 
9
10
  from cwmscli.utils import init_cwms_session
10
11
 
@@ -22,7 +23,7 @@ def exact_or_regex(ids: list[str]) -> str:
22
23
  def copy_from_group(
23
24
  source_cda: str,
24
25
  source_office: str,
25
- target_cda: str,
26
+ target_cda: Optional[str],
26
27
  target_api_key: Optional[str],
27
28
  verbose: int,
28
29
  group_id: str,
@@ -31,6 +32,7 @@ def copy_from_group(
31
32
  category_office_id: Optional[str],
32
33
  filter_office: bool,
33
34
  dry_run: bool,
35
+ target_csv: Optional[str] = None,
34
36
  ):
35
37
  group_office_id = group_office_id or source_office
36
38
  category_office_id = category_office_id or source_office
@@ -103,10 +105,16 @@ def copy_from_group(
103
105
  if dry_run:
104
106
  for loc in locations:
105
107
  logger.info(
106
- f"[dry-run] would store Location(name={loc['name']}) to {target_cda} ({source_office})"
108
+ f"[dry-run] would store Location(name={loc['name']}) to "
109
+ f"{target_csv or target_cda} ({source_office})"
107
110
  )
108
111
  return
109
112
 
113
+ if target_csv:
114
+ pd.DataFrame(locations).to_csv(target_csv, index=False)
115
+ click.echo(f"Wrote {len(locations)} locations to {target_csv}")
116
+ return
117
+
110
118
  try:
111
119
  init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key)
112
120
  except Exception as e:
@@ -43,11 +43,39 @@ def _norm_office(o: Optional[str]) -> str:
43
43
  def validate_cda_targets(func):
44
44
  @functools.wraps(func)
45
45
  def wrapper(*args, **kwargs):
46
+ source_csv = kwargs.get("source_csv")
47
+ target_csv = kwargs.get("target_csv")
48
+
49
+ if source_csv and target_csv:
50
+ raise click.ClickException(
51
+ "--source-csv and --target-csv are both set, but no CDA is involved. "
52
+ "Use a plain file copy instead."
53
+ )
54
+
55
+ if source_csv:
56
+ if kwargs.get("source_cda") and _param_was_explicit("source_cda"):
57
+ raise click.ClickException(
58
+ "--source-csv and --source-cda are mutually exclusive."
59
+ )
60
+ kwargs["source_cda"] = None
61
+
62
+ if target_csv:
63
+ if kwargs.get("target_cda") and _param_was_explicit("target_cda"):
64
+ raise click.ClickException(
65
+ "--target-csv and --target-cda are mutually exclusive."
66
+ )
67
+ kwargs["target_cda"] = None
68
+
46
69
  source_cda = _normalize_url(kwargs.get("source_cda"))
47
70
  target_cda = _normalize_url(kwargs.get("target_cda"))
48
71
  source_office = _norm_office(kwargs.get("source_office"))
49
72
  target_office = _norm_office(kwargs.get("target_office"))
50
73
 
74
+ if source_cda and not source_office:
75
+ raise click.ClickException(
76
+ "--source-office is required when reading from a source CDA."
77
+ )
78
+
51
79
  same_root = source_cda == target_cda and bool(source_cda)
52
80
  same_office = source_office == target_office and bool(source_office)
53
81
 
@@ -64,33 +92,40 @@ def validate_cda_targets(func):
64
92
  "This is allowed, but double-check intent.",
65
93
  )
66
94
 
95
+ src_label = source_csv or source_cda or "-"
96
+ tgt_label = target_csv or target_cda or "-"
67
97
  logger.info(
68
- f"Source: {source_cda} (office={source_office or '-'})\n"
69
- f"Target: {target_cda} (office={source_office or '-'})",
98
+ f"Source: {src_label} (office={source_office or '-'})\n"
99
+ f"Target: {tgt_label} (office={target_office or source_office or '-'})",
70
100
  )
71
101
  return func(*args, **kwargs)
72
102
 
73
103
  return wrapper
74
104
 
75
105
 
106
+ def _param_was_explicit(name: str) -> bool:
107
+ ctx = click.get_current_context(silent=True)
108
+ if ctx is None:
109
+ return False
110
+ src = ctx.get_parameter_source(name)
111
+ return src is not None and src.name != "DEFAULT"
112
+
113
+
76
114
  def shared_source_target_options(f):
77
115
  f = click.option(
78
116
  "--source-cda",
79
117
  envvar="CDA_SOURCE_URL",
80
- required=True,
81
118
  default="https://cwms-data.usace.army.mil/cwms-data/",
82
119
  help="Source CWMS Data API root. Default: https://cwms-data.usace.army.mil/cwms-data/",
83
120
  )(f)
84
121
  f = click.option(
85
122
  "--source-office",
86
123
  envvar="CDA_SOURCE_OFFICE",
87
- required=True,
88
- help="Source office ID (e.g. SWT, SWL).",
124
+ help="Source office ID (e.g. SWT, SWL). Required when reading from a CDA.",
89
125
  )(f)
90
126
  f = click.option(
91
127
  "--target-cda",
92
128
  envvar="CDA_TARGET_URL",
93
- required=True,
94
129
  default="http://localhost:8081/cwms-data/",
95
130
  help="Target CWMS Data API root. Default: http://localhost:8081/cwms-data/",
96
131
  )(f)
@@ -115,6 +150,37 @@ def shared_source_target_options(f):
115
150
  return f
116
151
 
117
152
 
153
+ def csv_source_target_options(*, allow_source_csv: bool, allow_target_csv: bool):
154
+ """Add --source-csv and/or --target-csv to a command, depending on flags."""
155
+
156
+ def decorator(f):
157
+ if allow_target_csv:
158
+ f = click.option(
159
+ "--target-csv",
160
+ "target_csv",
161
+ type=click.Path(dir_okay=False, writable=True),
162
+ default=None,
163
+ help=(
164
+ "Write fetched locations to this CSV file instead of POSTing "
165
+ "to a target CDA. Mutually exclusive with --target-cda."
166
+ ),
167
+ )(f)
168
+ if allow_source_csv:
169
+ f = click.option(
170
+ "--source-csv",
171
+ "source_csv",
172
+ type=click.Path(exists=True, dir_okay=False, readable=True),
173
+ default=None,
174
+ help=(
175
+ "Read locations from this CSV file instead of fetching from "
176
+ "a source CDA. Mutually exclusive with --source-cda."
177
+ ),
178
+ )(f)
179
+ return f
180
+
181
+ return decorator
182
+
183
+
118
184
  @click.group(
119
185
  name="load",
120
186
  help="Load data from one CWMS Data API instance to another.",
@@ -1,4 +1,5 @@
1
1
  import logging as py_logging
2
+ import re
2
3
  import time
3
4
  from pathlib import Path
4
5
  from typing import Optional, Union
@@ -235,6 +236,66 @@ def log_scoped_read_hint(
235
236
  )
236
237
 
237
238
 
239
+ def format_local_download_error(error: Exception, docs_url: str) -> str:
240
+ if isinstance(error, (OSError, ValueError)):
241
+ message = (
242
+ f"{colors.c('Failed to download:', 'red', bright=True)} {error}. "
243
+ f"If this is a local destination/path issue, pass "
244
+ f"{colors.c('--dest', 'cyan', bright=True)} explicitly."
245
+ )
246
+ if docs_url:
247
+ message = (
248
+ f"{message} {colors.c('Docs:', 'blue', bright=True)} "
249
+ f"{colors.c(docs_url, 'blue', bright=True)}"
250
+ )
251
+ return message
252
+ return f"{colors.c('Failed to download:', 'red', bright=True)} {error}"
253
+
254
+
255
+ def validate_default_download_dest(
256
+ raw_id: str,
257
+ *,
258
+ resource_name: str,
259
+ docs_url: str = "",
260
+ ) -> str:
261
+ if raw_id is None:
262
+ raise ValueError(
263
+ f"{resource_name} ID must include a non-root destination name. "
264
+ f"Pass --dest explicitly if needed."
265
+ )
266
+
267
+ if raw_id.startswith("//") or raw_id.startswith("\\\\"):
268
+ raise ValueError(
269
+ f"{resource_name} ID must resolve to a relative local path. "
270
+ f"Pass --dest explicitly if needed."
271
+ )
272
+
273
+ target = raw_id.lstrip("/\\")
274
+ if not target:
275
+ message = (
276
+ f"{resource_name} ID must include a non-root destination name. "
277
+ f"Pass --dest explicitly if needed."
278
+ )
279
+ if docs_url:
280
+ message = f"{message} Docs: {docs_url}"
281
+ raise ValueError(message)
282
+
283
+ if re.match(r"^[A-Za-z]:", target):
284
+ raise ValueError(
285
+ f"{resource_name} ID must resolve to a relative local path. "
286
+ f"Pass --dest explicitly if needed."
287
+ )
288
+
289
+ parts = re.split(r"[\\/]", target)
290
+ if any(part in {"", ".", ".."} for part in parts):
291
+ raise ValueError(
292
+ f"{resource_name} ID must resolve to a relative local path. "
293
+ f"Pass --dest explicitly if needed."
294
+ )
295
+
296
+ return target
297
+
298
+
238
299
  def common_api_options(f):
239
300
  f = log_level_option(f)
240
301
  f = office_option(f)
@@ -2,7 +2,7 @@
2
2
  name = "cwms-cli"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-cli"
4
4
 
5
- version = "0.6.0"
5
+ version = "0.7.1"
6
6
 
7
7
 
8
8
  packages = [
@@ -1,105 +0,0 @@
1
- import logging
2
- import re
3
- from typing import Iterable, Optional
4
-
5
- import click
6
- import cwms
7
-
8
- from cwmscli.utils import init_cwms_session
9
- from cwmscli.utils.links import CDA_REGEXP_GUIDE_URL
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def load_locations(
15
- source_cda: str,
16
- source_office: str,
17
- target_cda: str,
18
- target_api_key: Optional[str],
19
- verbose: int,
20
- dry_run: bool,
21
- like: Optional[str],
22
- location_kind_like: Optional[Iterable[str]] = "ALL",
23
- ):
24
- if verbose:
25
- logger.info(
26
- f"[load locations] source={source_cda} ({source_office}) -> target={target_cda}"
27
- )
28
- logger.info(
29
- f" like={like or '-'} kinds={list(location_kind_like) or '-'} dry_run={dry_run}"
30
- )
31
- if like or (
32
- location_kind_like
33
- and list(location_kind_like) != ["ALL"]
34
- and list(location_kind_like) != []
35
- ):
36
- logger.info(" CDA regex guide: %s", CDA_REGEXP_GUIDE_URL)
37
-
38
- init_cwms_session(cwms, api_root=source_cda)
39
-
40
- cat_kwargs = {"office_id": source_office}
41
- if like:
42
- cat_kwargs["like"] = like
43
- kinds = list(location_kind_like) if location_kind_like else ["ALL"]
44
- if "ALL" in kinds:
45
- kinds = ["ALL"]
46
-
47
- locations = []
48
-
49
- if kinds == ["ALL"] and not like:
50
- locations = cwms.get_locations(office_id=source_office).json
51
- else:
52
- seen_location_ids = set()
53
- for kind in kinds:
54
- cat_kwargs_k = dict(cat_kwargs)
55
- if kind != "ALL":
56
- cat_kwargs_k["location_kind_like"] = kind
57
-
58
- if verbose >= 2:
59
- logger.debug(" > catalog query: %s", cat_kwargs_k)
60
-
61
- resp = cwms.get_locations_catalog(**cat_kwargs_k)
62
- if resp.df.empty:
63
- continue
64
-
65
- for location_id in resp.df["name"].tolist():
66
- if location_id in seen_location_ids:
67
- continue
68
- seen_location_ids.add(location_id)
69
- if verbose >= 2:
70
- logger.debug(" > location fetch: %s", location_id)
71
- detail_resp = cwms.get_locations(
72
- office_id=source_office,
73
- location_ids=rf"^{re.escape(location_id)}$",
74
- )
75
- if detail_resp and detail_resp.json:
76
- locations.extend(detail_resp.json)
77
-
78
- if verbose:
79
- logger.info("Fetched %s locations from source", len(locations))
80
-
81
- if dry_run:
82
- for loc in locations:
83
- logger.info(
84
- f"[dry-run] would store Location(name={loc['name']}) to {target_cda} ({source_office})"
85
- )
86
- return
87
-
88
- # init target once
89
- init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key)
90
-
91
- errors = 0
92
- for loc in locations:
93
- try:
94
- if loc["active"] is True:
95
- result = cwms.store_location(data=loc, fail_if_exists=False)
96
- if verbose:
97
- logger.info("%s", result)
98
- except Exception as e:
99
- errors += 1
100
- click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
101
-
102
- if errors:
103
- raise click.ClickException(f"Completed with {errors} error(s).")
104
-
105
- click.echo("Done.")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes