cwms-cli 0.1.5__tar.gz → 0.2.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 (50) hide show
  1. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/PKG-INFO +1 -1
  2. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/__init__.py +2 -0
  3. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/__main__.py +2 -0
  4. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/blob.py +29 -9
  5. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/commands_cwms.py +6 -2
  6. cwms_cli-0.2.1/cwmscli/load/README.md +7 -0
  7. cwms_cli-0.2.1/cwmscli/load/__init__.py +0 -0
  8. cwms_cli-0.2.1/cwmscli/load/__main__.py +5 -0
  9. cwms_cli-0.2.1/cwmscli/load/location/location.py +131 -0
  10. cwms_cli-0.2.1/cwmscli/load/location/location_ids.py +83 -0
  11. cwms_cli-0.2.1/cwmscli/load/location/location_ids_bygroup.py +127 -0
  12. cwms_cli-0.2.1/cwmscli/load/root.py +123 -0
  13. cwms_cli-0.2.1/cwmscli/load/timeseries/timeseries.py +55 -0
  14. cwms_cli-0.2.1/cwmscli/load/timeseries/timeseries_ids.py +71 -0
  15. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/usgs/__init__.py +7 -12
  16. cwms_cli-0.2.1/cwmscli/usgs/__main__.py +7 -0
  17. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -2
  18. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/usgs/getusgs_cda.py +0 -2
  19. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/usgs/getusgs_measurements_cda.py +5 -5
  20. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/usgs/rating_ini_file_import.py +0 -1
  21. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/utils/deps.py +13 -1
  22. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/pyproject.toml +3 -3
  23. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/LICENSE +0 -0
  24. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/README.md +0 -0
  25. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/callbacks/__init__.py +0 -0
  26. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  27. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/README.md +0 -0
  28. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  29. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  30. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  31. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/examples/hourly.json +0 -0
  32. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/examples/minutes.json +0 -0
  33. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  34. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  35. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  36. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  37. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  38. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  39. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  40. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  41. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  42. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  43. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  44. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  45. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  46. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  47. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/csv2cwms/utils/terminal.py +0 -0
  48. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/commands/shef_critfile_import.py +0 -0
  49. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/requirements.py +0 -0
  50. {cwms_cli-0.1.5 → cwms_cli-0.2.1}/cwmscli/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.1.5
3
+ Version: 0.2.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
@@ -1,5 +1,7 @@
1
1
  import logging as lg
2
2
 
3
+ from cwmscli import load
4
+
3
5
  # create logging for logging
4
6
  logging = lg.getLogger()
5
7
  if logging.hasHandlers():
@@ -1,6 +1,7 @@
1
1
  import click
2
2
 
3
3
  from cwmscli.commands import commands_cwms
4
+ from cwmscli.load import __main__ as load
4
5
  from cwmscli.usgs import usgs_group
5
6
 
6
7
 
@@ -13,3 +14,4 @@ cli.add_command(usgs_group, name="usgs")
13
14
  cli.add_command(commands_cwms.shefcritimport)
14
15
  cli.add_command(commands_cwms.csv2cwms_cmd)
15
16
  cli.add_command(commands_cwms.blob_group)
17
+ cli.add_command(load.load_group)
@@ -7,10 +7,6 @@ import re
7
7
  import sys
8
8
  from typing import Optional, Sequence
9
9
 
10
- import cwms
11
- import pandas as pd
12
- import requests
13
-
14
10
  from cwmscli.utils import get_api_key
15
11
  from cwmscli.utils.deps import requires
16
12
 
@@ -90,6 +86,9 @@ def _save_base64(
90
86
 
91
87
 
92
88
  def store_blob(**kwargs):
89
+ import cwms
90
+ import requests
91
+
93
92
  file_data = kwargs.get("file_data")
94
93
  blob_id = kwargs.get("blob_id", "").upper()
95
94
  # Attempt to determine what media type should be used for the mime-type if one is not presented based on the file extension
@@ -145,6 +144,9 @@ def store_blob(**kwargs):
145
144
 
146
145
 
147
146
  def retrieve_blob(**kwargs):
147
+ import cwms
148
+ import requests
149
+
148
150
  blob_id = kwargs.get("blob_id", "").upper()
149
151
  if not blob_id:
150
152
  logging.warning(
@@ -172,14 +174,17 @@ def retrieve_blob(**kwargs):
172
174
 
173
175
 
174
176
  def delete_blob(**kwargs):
177
+ import cwms
178
+ import requests
179
+
175
180
  blob_id = kwargs.get("blob_id").upper()
176
181
  logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}")
177
182
 
178
183
  try:
179
- # cwms.delete_blob(
180
- # office_id=kwargs.get("office"),
181
- # blob_id=kwargs.get("blob_id").upper(),
182
- # )
184
+ cwms.delete_blob(
185
+ office_id=kwargs.get("office"),
186
+ blob_id=kwargs.get("blob_id").upper(),
187
+ )
183
188
  logging.info(f"Successfully deleted blob with ID: {blob_id}")
184
189
  except requests.HTTPError as e:
185
190
  details = getattr(e.response, "text", "") or str(e)
@@ -197,8 +202,11 @@ def list_blobs(
197
202
  sort_by: Optional[Sequence[str]] = None,
198
203
  ascending: bool = True,
199
204
  limit: Optional[int] = None,
200
- ) -> pd.DataFrame:
205
+ ):
201
206
  logging.info(f"Listing blobs for office: {office!r}...")
207
+ import cwms
208
+ import pandas as pd
209
+
202
210
  result = cwms.get_blobs(office_id=office, blob_id_like=blob_id_like)
203
211
 
204
212
  # Accept either a DataFrame or a JSON/dict-like response
@@ -250,6 +258,9 @@ def upload_cmd(
250
258
  api_root: str,
251
259
  api_key: str,
252
260
  ):
261
+ import cwms
262
+ import requests
263
+
253
264
  cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, ""))
254
265
  try:
255
266
  file_size = os.path.getsize(input_file)
@@ -307,6 +318,9 @@ def upload_cmd(
307
318
  def download_cmd(
308
319
  blob_id: str, dest: str, office: str, api_root: str, api_key: str, dry_run: bool
309
320
  ):
321
+ import cwms
322
+ import requests
323
+
310
324
  if dry_run:
311
325
  logging.info(
312
326
  f"DRY RUN: would GET {api_root} blob with blob-id={blob_id} office={office}."
@@ -331,6 +345,7 @@ def download_cmd(
331
345
 
332
346
 
333
347
  def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run: bool):
348
+ import cwms
334
349
 
335
350
  if dry_run:
336
351
  logging.info(
@@ -353,6 +368,8 @@ def update_cmd(
353
368
  api_root: str,
354
369
  api_key: str,
355
370
  ):
371
+ import cwms
372
+
356
373
  if dry_run:
357
374
  logging.info(
358
375
  f"DRY RUN: would PATCH {api_root} blob with blob-id={blob_id} office={office}"
@@ -399,6 +416,9 @@ def list_cmd(
399
416
  api_root: str,
400
417
  api_key: str,
401
418
  ):
419
+ import cwms
420
+ import pandas as pd
421
+
402
422
  cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
403
423
  df = list_blobs(
404
424
  office=office,
@@ -107,7 +107,6 @@ def csv2cwms_cmd(**kwargs):
107
107
  """
108
108
  ),
109
109
  )
110
- @requires(reqs.cwms)
111
110
  def blob_group():
112
111
  pass
113
112
 
@@ -130,13 +129,14 @@ def blob_group():
130
129
  help="Override media type (guessed from file if omitted).",
131
130
  )
132
131
  @click.option(
133
- "--overwrite/--no-overwrite",
132
+ "--overwrite",
134
133
  default=False,
135
134
  show_default=True,
136
135
  help="If true, replace existing blob.",
137
136
  )
138
137
  @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
139
138
  @common_api_options
139
+ @requires(reqs.cwms)
140
140
  def blob_upload(**kwargs):
141
141
  from cwmscli.commands.blob import upload_cmd
142
142
 
@@ -156,6 +156,7 @@ def blob_upload(**kwargs):
156
156
  )
157
157
  @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
158
158
  @common_api_options
159
+ @requires(reqs.cwms)
159
160
  def blob_download(**kwargs):
160
161
  from cwmscli.commands.blob import download_cmd
161
162
 
@@ -169,6 +170,7 @@ def blob_download(**kwargs):
169
170
  @click.option("--blob-id", required=True, type=str, help="Blob ID to delete.")
170
171
  @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
171
172
  @common_api_options
173
+ @requires(reqs.cwms)
172
174
  def delete_cmd(**kwargs):
173
175
  from cwmscli.commands.blob import delete_cmd
174
176
 
@@ -204,6 +206,7 @@ def delete_cmd(**kwargs):
204
206
  help="If true, replace existing blob.",
205
207
  )
206
208
  @common_api_options
209
+ @requires(reqs.cwms)
207
210
  def update_cmd(**kwargs):
208
211
  from cwmscli.commands.blob import update_cmd
209
212
 
@@ -243,6 +246,7 @@ def update_cmd(**kwargs):
243
246
  help="If set, write results to this CSV file.",
244
247
  )
245
248
  @common_api_options
249
+ @requires(reqs.cwms)
246
250
  def list_cmd(**kwargs):
247
251
  from cwmscli.commands.blob import list_cmd
248
252
 
@@ -0,0 +1,7 @@
1
+ # Loader Scripts
2
+
3
+ This command supports loading data in some form or fashion.
4
+
5
+ Initially it was intended to load data across from one CDA instance (GET) to another (POST). But could be expanded to other forms of loading including backloading jobs that potentially wrap existing other commands.
6
+
7
+ It differs from the other scripts in that it is effectively a `CDA2CDA` script which is usually only needed for `loading` data in initial setups. (Dev/Docker Compose)
File without changes
@@ -0,0 +1,5 @@
1
+ # cwmscli/load/__main__.py
2
+ # Side-effect imports to register subcommands under `load_group`
3
+ from cwmscli.load.location import location as _locations
4
+ from cwmscli.load.root import load_group # export for callers
5
+ from cwmscli.load.timeseries import timeseries as _timeseries
@@ -0,0 +1,131 @@
1
+ # cwmscli/load/locations.py
2
+ from typing import Iterable, Optional
3
+
4
+ import click
5
+
6
+ from cwmscli import requirements as reqs
7
+ from cwmscli.load.root import (
8
+ load_group,
9
+ shared_source_target_options,
10
+ validate_cda_targets,
11
+ )
12
+ from cwmscli.utils.deps import requires
13
+
14
+
15
+ @load_group.group(
16
+ "location", help="Copy location data from a source CDA to a target CDA."
17
+ )
18
+ @click.pass_context
19
+ def location(ctx):
20
+ pass
21
+
22
+
23
+ @location.command(
24
+ "ids-all",
25
+ help="Copy ALL locations from a source CDA to a target CDA.",
26
+ )
27
+ @shared_source_target_options
28
+ @click.option(
29
+ "--like",
30
+ default=None,
31
+ type=str,
32
+ help="LIKE filter for location name (e.g. 'Turbine*').",
33
+ )
34
+ @click.option(
35
+ "--location-kind-like",
36
+ "location_kind_like",
37
+ default=["ALL"],
38
+ multiple=True,
39
+ help=(
40
+ "Filter by LOCATION_KIND using LIKE; may be passed multiple times.\n\n"
41
+ "Default is to pull all Location kinds.\n\n"
42
+ "Common kinds: SITE, EMBANKMENT, OVERFLOW, TURBINE, STREAM, PROJECT, "
43
+ "STREAMGAGE, BASIN, OUTLET, LOCK, GATE.\n\n"
44
+ "Examples:\n"
45
+ " --location-kind-like PROJECT --location-kind-like STREAM\n"
46
+ " --location-kind-like '(SITE|STREAM)' # Posix regex"
47
+ ),
48
+ )
49
+ @requires(reqs.cwms)
50
+ @validate_cda_targets
51
+ def load_locations(
52
+ source_cda: str,
53
+ source_office: str,
54
+ target_cda: str,
55
+ target_api_key: Optional[str],
56
+ verbose: int,
57
+ dry_run: bool,
58
+ like: Optional[str],
59
+ location_kind_like: Optional[Iterable[str]] = None,
60
+ ):
61
+ from cwmscli.load.location.location_ids import load_locations as _load_locations
62
+
63
+ _load_locations(
64
+ source_cda=source_cda,
65
+ source_office=source_office,
66
+ target_cda=target_cda,
67
+ target_api_key=target_api_key,
68
+ verbose=verbose,
69
+ dry_run=dry_run,
70
+ like=like,
71
+ location_kind_like=location_kind_like,
72
+ )
73
+
74
+
75
+ @location.command(
76
+ "ids-bygroup",
77
+ help="Copy locations from a CWMS Location Group (source CDA) to a target CDA.",
78
+ )
79
+ @shared_source_target_options
80
+ @click.option(
81
+ "--group-id", required=True, help="Location Group ID (e.g., 'Ark Basin')."
82
+ )
83
+ @click.option(
84
+ "--category-id", required=True, help="Location Category ID (e.g., 'Basin')."
85
+ )
86
+ @click.option(
87
+ "--group-office-id",
88
+ default=None,
89
+ help="Owning office of the Location Group (defaults to --source-office).",
90
+ )
91
+ @click.option(
92
+ "--category-office-id",
93
+ default=None,
94
+ help="Owning office of the Category (defaults to --source-office).",
95
+ )
96
+ @click.option(
97
+ "--filter-office/--no-filter-office",
98
+ default=True,
99
+ show_default=True,
100
+ help="If set, only copy members whose 'office-id' equals --source-office.",
101
+ )
102
+ @requires(reqs.cwms)
103
+ @validate_cda_targets
104
+ def load_locations_from_group(
105
+ source_cda: str,
106
+ source_office: str,
107
+ target_cda: str,
108
+ target_api_key: Optional[str],
109
+ verbose: int,
110
+ group_id: str,
111
+ category_id: str,
112
+ group_office_id: Optional[str],
113
+ category_office_id: Optional[str],
114
+ filter_office: bool,
115
+ dry_run: bool,
116
+ ):
117
+ from cwmscli.load.location.location_ids_bygroup import copy_from_group
118
+
119
+ copy_from_group(
120
+ source_cda=source_cda,
121
+ source_office=source_office,
122
+ target_cda=target_cda,
123
+ target_api_key=target_api_key,
124
+ verbose=verbose,
125
+ group_id=group_id,
126
+ category_id=category_id,
127
+ group_office_id=group_office_id,
128
+ category_office_id=category_office_id,
129
+ filter_office=filter_office,
130
+ dry_run=dry_run,
131
+ )
@@ -0,0 +1,83 @@
1
+ from typing import Iterable, Optional
2
+
3
+ import click
4
+ import cwms
5
+
6
+
7
+ def load_locations(
8
+ source_cda: str,
9
+ source_office: str,
10
+ target_cda: str,
11
+ target_api_key: Optional[str],
12
+ verbose: int,
13
+ dry_run: bool,
14
+ like: Optional[str],
15
+ location_kind_like: Optional[Iterable[str]] = "ALL",
16
+ ):
17
+ if verbose:
18
+ click.echo(
19
+ f"[load locations] source={source_cda} ({source_office}) -> target={target_cda}"
20
+ )
21
+ click.echo(
22
+ f" like={like or '-'} kinds={list(location_kind_like) or '-'} dry_run={dry_run}"
23
+ )
24
+
25
+ cwms.init_session(api_root=source_cda, api_key=None)
26
+
27
+ cat_kwargs = {"office_id": source_office}
28
+ if like:
29
+ cat_kwargs["like"] = like
30
+ kinds = list(location_kind_like) if location_kind_like else [None]
31
+
32
+ locations = []
33
+
34
+ if "ALL" in kinds:
35
+ locations = cwms.get_locations(office_id=source_office).json
36
+ else:
37
+ locations = []
38
+ for kind in kinds:
39
+ cat_kwargs_k = dict(cat_kwargs)
40
+ if kind != "ALL":
41
+ cat_kwargs_k["location_kind_like"] = kind
42
+
43
+ if verbose >= 2:
44
+ click.echo(f" > catalog query: {cat_kwargs_k}")
45
+
46
+ resp = cwms.get_locations_catalog(**cat_kwargs_k)
47
+ if resp.df.empty:
48
+ continue
49
+
50
+ loc_ids = resp.df["name"].tolist()
51
+ locations_resp = cwms.get_locations(
52
+ office_id=source_office, location_ids=loc_ids
53
+ )
54
+ locations.extend(locations_resp.json or [])
55
+
56
+ if verbose:
57
+ click.echo(f"Fetched {len(locations)} locations from source")
58
+
59
+ if dry_run:
60
+ for loc in locations:
61
+ click.echo(
62
+ f"[dry-run] would store Location(name={loc['name']}) to {target_cda} ({source_office})"
63
+ )
64
+ return
65
+
66
+ # init target once
67
+ cwms.init_session(api_root=target_cda, api_key=target_api_key)
68
+
69
+ errors = 0
70
+ for loc in locations:
71
+ try:
72
+ if loc["active"] is True:
73
+ result = cwms.store_location(data=loc, fail_if_exists=False)
74
+ if verbose:
75
+ click.echo(result)
76
+ except Exception as e:
77
+ errors += 1
78
+ click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
79
+
80
+ if errors:
81
+ raise click.ClickException(f"Completed with {errors} error(s).")
82
+ if verbose:
83
+ click.echo("Done.")
@@ -0,0 +1,127 @@
1
+ # cwmscli/load/location_group.py
2
+ import re
3
+ from typing import Optional
4
+
5
+ import click
6
+ import cwms
7
+
8
+
9
+ def exact_or_regex(ids: list[str]) -> str:
10
+ if not ids:
11
+ return r"^$"
12
+ if len(ids) == 1:
13
+ return rf"^{re.escape(ids[0])}$"
14
+ return r"^(?:" + "|".join(re.escape(x) for x in ids) + r")$"
15
+
16
+
17
+ def copy_from_group(
18
+ source_cda: str,
19
+ source_office: str,
20
+ target_cda: str,
21
+ target_api_key: Optional[str],
22
+ verbose: int,
23
+ group_id: str,
24
+ category_id: str,
25
+ group_office_id: Optional[str],
26
+ category_office_id: Optional[str],
27
+ filter_office: bool,
28
+ dry_run: bool,
29
+ ):
30
+ group_office_id = group_office_id or source_office
31
+ category_office_id = category_office_id or source_office
32
+
33
+ if verbose:
34
+ click.echo(
35
+ f"[load location group] source={source_cda} ({source_office}) -> target={target_cda})"
36
+ )
37
+ click.echo(
38
+ f" group={group_id} category={category_id} "
39
+ f"group_office={group_office_id} category_office={category_office_id} "
40
+ f"filter_office={filter_office} dry_run={dry_run}"
41
+ )
42
+
43
+ cwms.init_session(api_root=source_cda, api_key=None)
44
+
45
+ try:
46
+ grp = cwms.get_location_group(
47
+ loc_group_id=group_id,
48
+ category_id=category_id,
49
+ office_id=source_office,
50
+ group_office_id=group_office_id,
51
+ category_office_id=category_office_id,
52
+ )
53
+ if verbose:
54
+ click.echo(f"Fetched Location Group '{group_id}' from source:")
55
+ if hasattr(grp, "df"):
56
+ click.echo(grp.df)
57
+ else:
58
+ click.echo(grp.json)
59
+ except Exception as e:
60
+ raise click.ClickException(
61
+ f"Failed to read location group '{group_id}' in category '{category_id}': {e}"
62
+ )
63
+
64
+ df = getattr(grp, "df", None)
65
+ if df is None or df.empty:
66
+ click.echo("No members found in the specified location group.")
67
+ return
68
+
69
+ if filter_office and "office-id" in df.columns:
70
+ df = df[df["office-id"] == source_office].copy()
71
+
72
+ member_ids = sorted(df["location-id"].dropna().unique().tolist())
73
+ if verbose:
74
+ click.echo(f"Group members found: {len(member_ids)}")
75
+ if not member_ids:
76
+ click.echo("No valid location IDs to copy.")
77
+ return
78
+
79
+ try:
80
+ locations = []
81
+ BATCH = 200 # optional batching
82
+ for batch in (
83
+ member_ids[i : i + BATCH] for i in range(0, len(member_ids), BATCH)
84
+ ):
85
+ pattern = exact_or_regex(batch)
86
+ resp = cwms.get_locations(office_id=source_office, location_ids=pattern)
87
+ if verbose and getattr(resp, "df", None) is not None:
88
+ click.echo(f"Fetched {len(resp.df)} matching Locations in batch")
89
+ if resp and resp.json:
90
+ locations.extend(resp.json)
91
+
92
+ except Exception as e:
93
+ raise click.ClickException(f"Failed to fetch locations from source: {e}")
94
+
95
+ if verbose:
96
+ click.echo(f"Fetched {len(locations)} Location objects from source")
97
+
98
+ if dry_run:
99
+ for loc in locations:
100
+ click.echo(
101
+ f"[dry-run] would store Location(name={loc.name}) to {target_cda} ({source_office})"
102
+ )
103
+ return
104
+
105
+ try:
106
+ cwms.init_session(api_root=target_cda, api_key=target_api_key)
107
+ except Exception as e:
108
+ raise click.ClickException(f"Failed to init target session: {e}")
109
+
110
+ errors = 0
111
+ for loc in locations:
112
+ try:
113
+ if verbose:
114
+ click.echo(f"Store: {loc['name']}")
115
+ cwms.store_location(data=loc, fail_if_exists=False)
116
+ if verbose:
117
+ click.echo("\tStored successfully.")
118
+ except Exception as e:
119
+ errors += 1
120
+ click.echo(f"Error storing location {loc}: \n\t{e}", err=True)
121
+
122
+ click.echo(
123
+ f"Successfully stored {len(locations) - errors} / {len(locations)} locations."
124
+ )
125
+
126
+ if errors:
127
+ raise click.ClickException(f"Completed with {errors} error(s).")
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ from urllib.parse import urlparse
7
+
8
+ import click
9
+
10
+ from cwmscli import requirements as reqs
11
+ from cwmscli.utils.deps import requires
12
+
13
+ CONTEXT = dict(
14
+ help_option_names=["-h", "--help"],
15
+ max_content_width=160,
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class CdaEndpoints:
21
+ source_cda: str
22
+ source_office: str
23
+ target_cda: str
24
+ target_office: str
25
+ target_api_key: Optional[str] = None
26
+
27
+
28
+ def _normalize_url(u: str) -> str:
29
+ if not u:
30
+ return ""
31
+ p = urlparse(u)
32
+ path = (p.path or "").rstrip("/")
33
+ return f"{p.scheme.lower()}://{p.netloc.lower()}{path}"
34
+
35
+
36
+ def _norm_office(o: Optional[str]) -> str:
37
+ return (o or "").strip().upper()
38
+
39
+
40
+ def validate_cda_targets(func):
41
+ @functools.wraps(func)
42
+ def wrapper(*args, **kwargs):
43
+ source_cda = _normalize_url(kwargs.get("source_cda"))
44
+ target_cda = _normalize_url(kwargs.get("target_cda"))
45
+ source_office = _norm_office(kwargs.get("source_office"))
46
+ target_office = _norm_office(kwargs.get("target_office"))
47
+
48
+ same_root = source_cda == target_cda and bool(source_cda)
49
+ same_office = source_office == target_office and bool(source_office)
50
+
51
+ if same_root and same_office:
52
+ raise click.ClickException(
53
+ "Circular reference detected: source and target CDA endpoints "
54
+ "are identical (URL + office). This would read-from and write-to "
55
+ "the same system.\n\nChange the source or target CDA URL or office. "
56
+ "Type cwms-cli load --help for arg options."
57
+ )
58
+ elif same_root and not same_office:
59
+ click.secho(
60
+ "Warning: source and target use the same CDA root URL but different offices. "
61
+ "This is allowed, but double-check intent.",
62
+ fg="yellow",
63
+ )
64
+
65
+ click.secho(
66
+ f"Source: {source_cda} (office={source_office or '-'})\n"
67
+ f"Target: {target_cda} (office={source_office or '-'})",
68
+ fg="green" if not same_root else "yellow",
69
+ )
70
+ return func(*args, **kwargs)
71
+
72
+ return wrapper
73
+
74
+
75
+ def shared_source_target_options(f):
76
+ f = click.option(
77
+ "--source-cda",
78
+ envvar="CDA_SOURCE_URL",
79
+ required=True,
80
+ default="https://cwms-data.usace.army.mil/cwms-data/",
81
+ help="Source CWMS Data API root. Default: https://cwms-data.usace.army.mil/cwms-data/",
82
+ )(f)
83
+ f = click.option(
84
+ "--source-office",
85
+ envvar="CDA_SOURCE_OFFICE",
86
+ required=True,
87
+ help="Source office ID (e.g. SWT, SWL).",
88
+ )(f)
89
+ f = click.option(
90
+ "--target-cda",
91
+ envvar="CDA_TARGET_URL",
92
+ required=True,
93
+ default="http://localhost:8081/cwms-data/",
94
+ help="Target CWMS Data API root. Default: http://localhost:8081/cwms-data/",
95
+ )(f)
96
+ f = click.option(
97
+ "--target-api-key",
98
+ envvar="CDA_API_KEY",
99
+ help="Target API key (if required by the target CDA).",
100
+ )(f)
101
+ f = click.option(
102
+ "--dry-run/--no-dry-run",
103
+ is_flag=True,
104
+ default=False,
105
+ show_default=True,
106
+ help="Show what would be written without storing to target.",
107
+ )(f)
108
+ f = click.option(
109
+ "-v",
110
+ "--verbose",
111
+ count=True,
112
+ help="Increase verbosity (repeat for more detail).",
113
+ )(f)
114
+ return f
115
+
116
+
117
+ @click.group(
118
+ name="load",
119
+ help="Load data from one CWMS Data API instance to another.",
120
+ context_settings=CONTEXT,
121
+ )
122
+ def load_group():
123
+ pass
@@ -0,0 +1,55 @@
1
+ from typing import Optional
2
+
3
+ import click
4
+
5
+ from cwmscli import requirements as reqs
6
+ from cwmscli.load.root import (
7
+ load_group,
8
+ shared_source_target_options,
9
+ validate_cda_targets,
10
+ )
11
+ from cwmscli.utils.deps import requires
12
+
13
+
14
+ @load_group.group(
15
+ "timeseries", help="Copy timeseries IDs from a source CDA to a target CDA."
16
+ )
17
+ @click.pass_context
18
+ def timeseries(ctx):
19
+ pass
20
+
21
+
22
+ @timeseries.command(
23
+ "ids-all",
24
+ help="Copy ALL timeseries IDs for locations in a target CDA from a source CDA.",
25
+ )
26
+ @shared_source_target_options
27
+ @click.option(
28
+ "--timeseries-id-regex",
29
+ "timeseries_id_regex",
30
+ default=None,
31
+ type=str,
32
+ help="regex filter for timeseries ID (e.g. 'LocID.*').",
33
+ )
34
+ @requires(reqs.cwms)
35
+ @validate_cda_targets
36
+ def load_timeseries_ids_all(
37
+ source_cda: str,
38
+ source_office: str,
39
+ target_cda: str,
40
+ target_api_key: Optional[str],
41
+ verbose: int,
42
+ timeseries_id_regex: Optional[str],
43
+ dry_run: bool,
44
+ ):
45
+ from cwmscli.load.timeseries.timeseries_ids import load_timeseries_ids
46
+
47
+ load_timeseries_ids(
48
+ source_cda=source_cda,
49
+ source_office=source_office,
50
+ target_cda=target_cda,
51
+ target_api_key=target_api_key,
52
+ verbose=verbose,
53
+ timeseries_id_regex=timeseries_id_regex,
54
+ dry_run=dry_run,
55
+ )
@@ -0,0 +1,71 @@
1
+ # cwmscli/load/timeseries_ids.py
2
+ from turtle import pd
3
+ from typing import Optional
4
+
5
+ import click
6
+ import pandas as pd
7
+
8
+
9
+ def load_timeseries_ids(
10
+ source_cda: str,
11
+ source_office: str,
12
+ target_cda: str,
13
+ target_api_key: Optional[str],
14
+ verbose: int,
15
+ dry_run: bool,
16
+ timeseries_id_regex: Optional[str] = None,
17
+ ):
18
+ import cwms
19
+
20
+ if verbose:
21
+ click.echo(
22
+ f"Loading timeseries IDs from source CDA '{source_cda}' (office '{source_office}') "
23
+ f"to target CDA '{target_cda}'."
24
+ )
25
+
26
+ cwms.init_session(api_root=source_cda, api_key=None)
27
+ ts_ids = cwms.get_timeseries_identifiers(
28
+ office_id=source_office, timeseries_id_regex=timeseries_id_regex
29
+ ).df
30
+
31
+ cwms.init_session(api_root=target_cda, api_key=target_api_key)
32
+ # only grab time_ids for locations that are in the target database
33
+ locations = cwms.get_locations_catalog(office_id=source_office)
34
+ ts_ids[["location-id", "param", "type", "int", "dur", "ver"]] = ts_ids[
35
+ "time-series-id"
36
+ ].str.split(".", expand=True)
37
+ locs = locations.df.rename(columns={"name": "location-id", "office": "office-id"})
38
+ ts_lo_ids = pd.merge(ts_ids, locs, how="inner", on=["location-id", "office-id"])
39
+
40
+ if verbose:
41
+ click.echo(f"Found {len(ts_lo_ids)} timeseries IDs to copy.")
42
+
43
+ errors = 0
44
+ for i, row in ts_lo_ids.iterrows():
45
+ ts_id = row["time-series-id"]
46
+ if dry_run:
47
+ click.echo(
48
+ f"[dry-run] would store Timeseries ID(name={ts_id}) to {target_cda} ({source_office})"
49
+ )
50
+ continue
51
+ t_id_json = {
52
+ "office-id": row["office-id"],
53
+ "time-series-id": ts_id,
54
+ "timezone-name": row["timezone-name"],
55
+ "interval-offset-minutes": float(row["interval-offset-minutes"]),
56
+ "active": row["active_x"],
57
+ }
58
+ try:
59
+ result = cwms.store_timeseries_identifier(
60
+ data=t_id_json, fail_if_exists=False
61
+ )
62
+ if verbose:
63
+ click.echo(result)
64
+ except Exception as e:
65
+ errors += 1
66
+ click.echo(f"Error storing location {ts_id}: \n\t{e}", err=True)
67
+
68
+ if errors:
69
+ raise click.ClickException(f"Completed with {errors} error(s).")
70
+ if verbose:
71
+ click.echo("Timeseries ID copy operation completed.")
@@ -1,17 +1,5 @@
1
1
  import click
2
2
 
3
- from cwmscli import requirements as reqs
4
- from cwmscli.utils.deps import requires
5
-
6
-
7
- @click.group()
8
- def usgs_group():
9
- """USGS utilities"""
10
- pass
11
-
12
-
13
- import click
14
-
15
3
  from cwmscli import requirements as reqs
16
4
  from cwmscli.utils import (
17
5
  api_key_loc_option,
@@ -22,6 +10,13 @@ from cwmscli.utils import (
22
10
  )
23
11
  from cwmscli.utils.deps import requires
24
12
 
13
+
14
+ @click.group()
15
+ def usgs_group():
16
+ """USGS utilities"""
17
+ pass
18
+
19
+
25
20
  days_back_option = click.option(
26
21
  "-d",
27
22
  "--days-back",
@@ -0,0 +1,7 @@
1
+ from cwmscli import requirements as reqs
2
+ from cwmscli.utils.deps import requires
3
+
4
+
5
+ @requires(reqs.cwms)
6
+ def blob_group():
7
+ pass
@@ -17,7 +17,6 @@ def getusgs_rating_cda(
17
17
  days_back: float = 1,
18
18
  rating_subset: list = None,
19
19
  ):
20
-
21
20
  api_key = "apikey " + api_key
22
21
  cwms.api.init_session(api_root=api_root, api_key=api_key)
23
22
  logging.info(f"CDA connection: {api_root}")
@@ -180,7 +179,6 @@ def get_begin_with_date(data, str_starts):
180
179
 
181
180
 
182
181
  def get_usgs_effective_date(data, rating_type):
183
-
184
182
  date_string = None
185
183
  if rating_type == "EXSA":
186
184
  line = data[data[0].str.startswith("# //RATING SHIFTED=")].iloc[0, 0]
@@ -14,7 +14,6 @@ def getusgs_cda(
14
14
  api_key: str,
15
15
  backfill_tsids: list = None,
16
16
  ):
17
-
18
17
  api_key = "apikey " + api_key
19
18
  cwms.api.init_session(api_root=api_root, api_key=api_key)
20
19
  logging.info(f"CDA connection: {api_root}")
@@ -249,7 +248,6 @@ def CWMS_writeData(USGS_ts, USGS_data, USGS_data_method, days_back):
249
248
  USGS_data_row = USGS_data_method.loc[USGS_Id_param]
250
249
  if USGS_data_row is not None:
251
250
  try:
252
-
253
251
  # grab the time series values obtained from USGS API.
254
252
  values_df = pd.DataFrame(USGS_data_row["values"])
255
253
  if values_df.shape[0] > 1:
@@ -478,7 +478,6 @@ def check_and_drop_duplicates(df_store, df_existing):
478
478
  """
479
479
 
480
480
  if not df_existing.empty:
481
-
482
481
  # cast number columns as int, sometimes USGS won't resolve to int...drop those rows
483
482
  df_invalid = df_store[pd.to_numeric(df_store["number"], errors="coerce").isna()]
484
483
  if not df_invalid.empty:
@@ -603,7 +602,6 @@ def realtime_mode(DAYS_BACK_COLLECTED, DAYS_BACK_MODIFIED, measurement_site_df):
603
602
  f"Fetching USGS discharge measurements from {startDT.isoformat()} (modified in last {DAYS_BACK_MODIFIED} days)..."
604
603
  )
605
604
  try:
606
-
607
605
  df_meas_usgs, meta = nwis.get_discharge_measurements(
608
606
  # sites=["05058000", "05059500"],
609
607
  period=f"P{DAYS_BACK_COLLECTED}D",
@@ -829,9 +827,11 @@ def backfill_mode(BACKFILL_LIST, measurement_site_df):
829
827
  logging.info(
830
828
  "Overwrite flag is off. Filtering out any conflicting measurements"
831
829
  )
832
- df_store, df_rejected_number, df_rejected_instant = (
833
- check_and_drop_duplicates(df_meas_usgs, df_existing)
834
- )
830
+ (
831
+ df_store,
832
+ df_rejected_number,
833
+ df_rejected_instant,
834
+ ) = check_and_drop_duplicates(df_meas_usgs, df_existing)
835
835
 
836
836
  if not df_rejected_number.empty:
837
837
  logging.info(
@@ -10,7 +10,6 @@ rating_types = {
10
10
 
11
11
 
12
12
  def rating_ini_file_import(api_root, api_key, ini_filename):
13
-
14
13
  api_key = "apikey " + api_key
15
14
  cwms.api.init_session(api_root=api_root, api_key=api_key)
16
15
 
@@ -1,9 +1,19 @@
1
1
  import importlib
2
2
  import importlib.metadata
3
+ import os
3
4
 
4
5
  import click
5
6
 
6
7
 
8
+ def _pip_command():
9
+ # Check OS to determine pip vs pip3
10
+ if os.name == "nt":
11
+ return "pip"
12
+ # Avoid potential issues with multiple python (2/3) versions on Unix/Linux systems
13
+ else:
14
+ return "pip3"
15
+
16
+
7
17
  def requires(*requirements):
8
18
  """
9
19
  Decorator that ensures required Python modules are installed and meet optional minimum version constraints.
@@ -84,7 +94,9 @@ def requires(*requirements):
84
94
  error_lines.append("Missing module(s):")
85
95
  for msg, _ in missing:
86
96
  error_lines.append(msg)
87
- install_cmd = "pip install " + " ".join(pkg for _, pkg in missing)
97
+ install_cmd = f"{_pip_command()} install " + " ".join(
98
+ pkg for _, pkg in missing
99
+ )
88
100
  error_lines.append(
89
101
  f"\nInstall missing packages:\n {install_cmd}"
90
102
  )
@@ -2,7 +2,7 @@
2
2
  name = "cwms-cli"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-cli"
4
4
 
5
- version = "0.1.5"
5
+ version = "0.2.1"
6
6
 
7
7
 
8
8
  packages = [
@@ -25,7 +25,7 @@ black = "^24.2.0"
25
25
  isort = "^5.13.2"
26
26
  mypy = "^1.9.0"
27
27
  pre-commit = "^3.6.2"
28
- #pytest = "^8.1.1"
28
+ pytest = { version = "^9.0.2", python = ">=3.10" }
29
29
  #pytest-cov = "^4.1.0"
30
30
  #pandas-stubs = "^2.2.1.240316"
31
31
  yamlfix = "^1.16.0"
@@ -44,4 +44,4 @@ explicit_start = false
44
44
  preserve_quotes = true
45
45
 
46
46
  [tool.poetry.scripts]
47
- cwms-cli = "cwmscli.__main__:cli"
47
+ cwms-cli = "cwmscli.__main__:cli"
File without changes
File without changes