cwms-cli 0.4.0__tar.gz → 0.5.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 (72) hide show
  1. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/PKG-INFO +1 -1
  2. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/__main__.py +1 -0
  3. cwms_cli-0.5.0/cwmscli/_generated/__init__.py +1 -0
  4. cwms_cli-0.5.0/cwmscli/_generated/ownership_data.py +57 -0
  5. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/blob.py +22 -5
  6. cwms_cli-0.5.0/cwmscli/commands/clob.py +340 -0
  7. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/commands_cwms.py +174 -0
  8. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/utils/dateutils.py +3 -3
  9. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/location/location_ids.py +2 -2
  10. cwms_cli-0.5.0/cwmscli/load/timeseries/timeseries.py +150 -0
  11. cwms_cli-0.5.0/cwmscli/load/timeseries/timeseries_data.py +157 -0
  12. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/timeseries/timeseries_ids.py +1 -4
  13. cwms_cli-0.5.0/cwmscli/ownership.py +26 -0
  14. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/getusgs_cda.py +4 -1
  15. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/__init__.py +12 -0
  16. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/click_help.py +34 -0
  17. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/deps.py +1 -1
  18. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/pyproject.toml +1 -1
  19. cwms_cli-0.4.0/cwmscli/load/timeseries/timeseries.py +0 -59
  20. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/LICENSE +0 -0
  21. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/README.md +0 -0
  22. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/__init__.py +0 -0
  23. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/callbacks/__init__.py +0 -0
  24. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  25. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/README.md +0 -0
  26. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  27. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  28. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/config.py +0 -0
  29. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  30. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  31. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/parser.py +0 -0
  32. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  33. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  34. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  35. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  36. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  37. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  38. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  39. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  40. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  41. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  42. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/transform.py +0 -0
  43. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  44. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  45. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  46. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  47. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/csv2cwms/writer.py +0 -0
  48. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/shef_critfile_import.py +0 -0
  49. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/commands/users.py +0 -0
  50. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/README.md +0 -0
  51. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/__init__.py +0 -0
  52. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/__main__.py +0 -0
  53. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/location/location.py +0 -0
  54. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  55. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/load/root.py +0 -0
  56. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/requirements.py +0 -0
  57. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/__init__.py +0 -0
  58. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/__main__.py +0 -0
  59. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  60. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  61. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  62. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/colors.py +0 -0
  63. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/friendly_errors.py +0 -0
  64. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/intervals.py +0 -0
  65. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/io.py +0 -0
  66. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/links.py +0 -0
  67. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/logging/__init__.py +0 -0
  68. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/logging/formatters.py +0 -0
  69. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/ssl_errors.py +0 -0
  70. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/update.py +0 -0
  71. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/cwmscli/utils/version.py +0 -0
  72. {cwms_cli-0.4.0 → cwms_cli-0.5.0}/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.4.0
3
+ Version: 0.5.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
@@ -85,6 +85,7 @@ cli.add_command(commands_cwms.shefcritimport)
85
85
  cli.add_command(commands_cwms.csv2cwms_cmd)
86
86
  cli.add_command(commands_cwms.update_cli_cmd)
87
87
  cli.add_command(commands_cwms.blob_group)
88
+ cli.add_command(commands_cwms.clob_group)
88
89
  cli.add_command(commands_cwms.users_group)
89
90
  cli.add_command(load.load_group)
90
91
  add_version_to_help_tree(cli)
@@ -0,0 +1 @@
1
+ # Generated files used at runtime and in docs.
@@ -0,0 +1,57 @@
1
+ # DO NOT EDIT THIS FILE MANUALLY.
2
+ # Generated from maintainers.toml by scripts/sync_ownership.py.
3
+
4
+ OWNERSHIP_DATA = {
5
+ "commands": {
6
+ "cwms-cli blob": [
7
+ {
8
+ "email": "charles.r.graham@usace.army.mil",
9
+ "name": "Charles Graham"
10
+ }
11
+ ],
12
+ "cwms-cli csv2cwms": [
13
+ {
14
+ "email": "charles.r.graham@usace.army.mil",
15
+ "name": "Charles Graham"
16
+ }
17
+ ],
18
+ "cwms-cli load": [
19
+ {
20
+ "email": "charles.r.graham@usace.army.mil",
21
+ "name": "Charles Graham"
22
+ },
23
+ {
24
+ "email": "eric.v.novotny@usace.army.mil",
25
+ "name": "Eric Novotny"
26
+ }
27
+ ],
28
+ "cwms-cli shefcritimport": [
29
+ {
30
+ "email": "eric.v.novotny@usace.army.mil",
31
+ "name": "Eric Novotny"
32
+ }
33
+ ],
34
+ "cwms-cli update": [
35
+ {
36
+ "email": "charles.r.graham@usace.army.mil",
37
+ "name": "Charles Graham"
38
+ }
39
+ ],
40
+ "cwms-cli usgs": [
41
+ {
42
+ "email": "eric.v.novotny@usace.army.mil",
43
+ "name": "Eric Novotny"
44
+ }
45
+ ]
46
+ },
47
+ "default": [
48
+ {
49
+ "email": "charles.r.graham@usace.army.mil",
50
+ "name": "Charles Graham"
51
+ },
52
+ {
53
+ "email": "eric.v.novotny@usace.army.mil",
54
+ "name": "Eric Novotny"
55
+ }
56
+ ]
57
+ }
@@ -7,7 +7,7 @@ import os
7
7
  import re
8
8
  import sys
9
9
  from collections import defaultdict
10
- from typing import Optional, Sequence, Tuple
10
+ from typing import Optional, Sequence, Tuple, Union
11
11
 
12
12
  from cwmscli.utils import colors, get_api_key, log_scoped_read_hint
13
13
  from cwmscli.utils.click_help import DOCS_BASE_URL
@@ -64,12 +64,12 @@ def _looks_like_base64(raw: str) -> bool:
64
64
 
65
65
 
66
66
  def _save_blob_content(
67
- content: bytes | str,
67
+ content: Union[bytes, str],
68
68
  dest: str,
69
69
  media_type_hint: Optional[str] = None,
70
70
  ) -> str:
71
71
  media_type = media_type_hint
72
- data: bytes | str = content
72
+ data: Union[bytes, str] = content
73
73
 
74
74
  if isinstance(content, str):
75
75
  m = DATA_URL_RE.match(content.strip())
@@ -260,12 +260,19 @@ def list_blobs(
260
260
  sort_by: Optional[Sequence[str]] = None,
261
261
  ascending: bool = True,
262
262
  limit: Optional[int] = None,
263
+ page_size: Optional[int] = None,
263
264
  ):
264
265
  logging.info(f"Listing blobs for office: {office!r}...")
265
266
  import cwms
266
267
  import pandas as pd
267
268
 
268
- result = cwms.get_blobs(office_id=office, blob_id_like=blob_id_like)
269
+ # Use page size if it's provided per #184
270
+ fetch_page_size = page_size if page_size is not None else limit
271
+ result = cwms.get_blobs(
272
+ office_id=office,
273
+ blob_id_like=blob_id_like,
274
+ page_size=fetch_page_size,
275
+ )
269
276
 
270
277
  # Accept either a DataFrame or a JSON/dict-like response
271
278
  if isinstance(result, pd.DataFrame):
@@ -678,6 +685,7 @@ def list_cmd(
678
685
  sort_by: list[str],
679
686
  desc: bool,
680
687
  limit: int,
688
+ page_size: int,
681
689
  to_csv: str,
682
690
  office: str,
683
691
  api_root: str,
@@ -697,6 +705,7 @@ def list_cmd(
697
705
  sort_by=sort_by,
698
706
  ascending=not desc,
699
707
  limit=limit,
708
+ page_size=page_size,
700
709
  )
701
710
  except Exception:
702
711
  log_scoped_read_hint(
@@ -713,4 +722,12 @@ def list_cmd(
713
722
  else:
714
723
  # Friendly console preview
715
724
  with pd.option_context("display.max_rows", 500, "display.max_columns", None):
716
- logging.info(df.to_string(index=False))
725
+ # Left-align all columns
726
+ logging.info(
727
+ "\n"
728
+ + df.apply(
729
+ lambda s: (s := s.astype(str).str.strip()).str.ljust(
730
+ s.str.len().max()
731
+ )
732
+ ).to_string(index=False, justify="left")
733
+ )
@@ -0,0 +1,340 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import sys
5
+ from typing import Optional, Sequence
6
+
7
+ import cwms
8
+ import pandas as pd
9
+ import requests
10
+ from cwms import api as cwms_api
11
+
12
+ from cwmscli.utils import get_api_key, has_invalid_chars, log_scoped_read_hint
13
+
14
+
15
+ def _join_api_url(api_root: str, path: str) -> str:
16
+ return f"{api_root.rstrip('/')}/{path.lstrip('/')}"
17
+
18
+
19
+ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Optional[str]:
20
+ if anonymous or not api_key:
21
+ return None
22
+ return get_api_key(api_key, None)
23
+
24
+
25
+ def _write_clob_content(content: str, dest: str) -> str:
26
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
27
+ with open(dest, "w", encoding="utf-8", newline="") as f:
28
+ f.write(content)
29
+ return dest
30
+
31
+
32
+ def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
33
+ normalized = clob_id.upper()
34
+ if has_invalid_chars(normalized):
35
+ return "ignored", normalized
36
+ return normalized, None
37
+
38
+
39
+ def _get_special_clob_text(*, office: str, clob_id: str) -> str:
40
+ with cwms_api.SESSION.get(
41
+ "clobs/ignored",
42
+ params={"office": office, "clob-id": clob_id},
43
+ headers={"Accept": "text/plain"},
44
+ ) as response:
45
+ response.raise_for_status()
46
+ return response.text
47
+
48
+
49
+ def list_clobs(
50
+ office: Optional[str] = None,
51
+ clob_id_like: Optional[str] = None,
52
+ columns: Optional[Sequence[str]] = None,
53
+ sort_by: Optional[Sequence[str]] = None,
54
+ ascending: bool = True,
55
+ limit: Optional[int] = None,
56
+ page_size: Optional[int] = None,
57
+ ) -> pd.DataFrame:
58
+ logging.info(f"Listing clobs for office: {office!r}...")
59
+ fetch_page_size = page_size if page_size is not None else limit
60
+ result = cwms.get_clobs(
61
+ office_id=office,
62
+ clob_id_like=clob_id_like,
63
+ page_size=fetch_page_size,
64
+ )
65
+
66
+ # Accept either a DataFrame or a JSON/dict-like response
67
+ if isinstance(result, pd.DataFrame):
68
+ df = result.copy()
69
+ else:
70
+ # Expecting normal clob return structure
71
+ data = getattr(result, "json", None)
72
+ if callable(data):
73
+ data = result.json()
74
+ df = pd.DataFrame((data or {}).get("clobs", []))
75
+
76
+ # Allow column filtering
77
+ if columns:
78
+ keep = [c for c in columns if c in df.columns]
79
+ if keep:
80
+ df = df[keep]
81
+
82
+ # Sort by option
83
+ if sort_by:
84
+ by = [c for c in sort_by if c in df.columns]
85
+ if by:
86
+ df = df.sort_values(by=by, ascending=ascending, kind="stable")
87
+
88
+ # Optional limit
89
+ if limit is not None:
90
+ df = df.head(limit)
91
+
92
+ logging.info(f"Found {len(df):,} clob(s)")
93
+ # List the clobs in the logger
94
+ for _, row in df.iterrows():
95
+ logging.info(f"clob ID: {row['id']}, Description: {row.get('description')}")
96
+ return df
97
+
98
+
99
+ def upload_cmd(
100
+ input_file: str,
101
+ clob_id: str,
102
+ description: str,
103
+ overwrite: bool,
104
+ dry_run: bool,
105
+ office: str,
106
+ api_root: str,
107
+ api_key: str,
108
+ ):
109
+ cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
110
+ try:
111
+ file_size = os.path.getsize(input_file)
112
+ with open(input_file, "r", encoding="utf-8") as f:
113
+ file_data = f.read()
114
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
115
+ except Exception as e:
116
+ logging.error(f"Failed to read file: {e}")
117
+ sys.exit(1)
118
+
119
+ clob_id_up = clob_id.upper()
120
+ logging.debug(f"Office={office} clobID={clob_id_up}")
121
+
122
+ clob = {
123
+ "office-id": office,
124
+ "id": clob_id_up,
125
+ "description": (
126
+ json.dumps(description)
127
+ if isinstance(description, (dict, list))
128
+ else description
129
+ ),
130
+ "value": file_data,
131
+ }
132
+ params = {"fail-if-exists": not overwrite}
133
+ view_url = _join_api_url(api_root, f"clobs/{clob_id_up}?office={office}")
134
+
135
+ if dry_run:
136
+ logging.info(
137
+ f"DRY RUN: would POST {_join_api_url(api_root, 'clobs')} with params={params}"
138
+ )
139
+ logging.info(
140
+ json.dumps(
141
+ {
142
+ "url": _join_api_url(api_root, "clobs"),
143
+ "params": params,
144
+ "clob": {**clob, "value": f'<{len(clob["value"])} chars>'},
145
+ },
146
+ indent=2,
147
+ )
148
+ )
149
+ return
150
+
151
+ try:
152
+ cwms.store_clobs(clob, fail_if_exists=not overwrite)
153
+ logging.info(f"Uploaded clob: {clob_id_up}")
154
+ if has_invalid_chars(clob_id_up):
155
+ logging.info(
156
+ f"View: {_join_api_url(api_root, f'clobs/ignored?clob-id={clob_id_up}&office={office}')}"
157
+ )
158
+ else:
159
+ logging.info(f"View: {view_url}")
160
+ except requests.HTTPError as e:
161
+ detail = getattr(e.response, "text", "") or str(e)
162
+ logging.error(f"Failed to upload (HTTP): {detail}")
163
+ sys.exit(1)
164
+ except Exception as e:
165
+ logging.error(f"Failed to upload: {e}")
166
+ sys.exit(1)
167
+
168
+
169
+ def download_cmd(
170
+ clob_id: str,
171
+ dest: str,
172
+ office: str,
173
+ api_root: str,
174
+ api_key: str,
175
+ dry_run: bool,
176
+ anonymous: bool = False,
177
+ ):
178
+ if dry_run:
179
+ logging.info(
180
+ f"DRY RUN: would GET {api_root} clob with clob-id={clob_id} office={office}."
181
+ )
182
+ return
183
+ resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
184
+ cwms.init_session(api_root=api_root, api_key=resolved_api_key)
185
+ bid = clob_id.upper()
186
+ logging.debug(f"Office={office} clobID={bid}")
187
+
188
+ try:
189
+ path_id, query_id = _clob_endpoint_id(bid)
190
+ if query_id is None:
191
+ clob = cwms.get_clob(office_id=office, clob_id=path_id)
192
+ payload = getattr(clob, "json", clob)
193
+ if callable(payload):
194
+ payload = payload()
195
+ if isinstance(payload, dict):
196
+ content = payload.get("value", "")
197
+ else:
198
+ content = str(payload)
199
+ else:
200
+ content = _get_special_clob_text(office=office, clob_id=query_id)
201
+ target = dest or bid
202
+ _write_clob_content(content, target)
203
+ logging.info(f"Downloaded clob to: {target}")
204
+ except requests.HTTPError as e:
205
+ detail = getattr(e.response, "text", "") or str(e)
206
+ logging.error(f"Failed to download (HTTP): {detail}")
207
+ log_scoped_read_hint(
208
+ api_key=resolved_api_key,
209
+ anonymous=anonymous,
210
+ office=office,
211
+ action="download",
212
+ resource="clob content",
213
+ )
214
+ sys.exit(1)
215
+ 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
+ )
224
+ sys.exit(1)
225
+
226
+
227
+ def delete_cmd(clob_id: str, office: str, api_root: str, api_key: str, dry_run: bool):
228
+
229
+ if dry_run:
230
+ logging.info(
231
+ f"DRY RUN: would DELETE {api_root} clob with clob-id={clob_id} office={office}"
232
+ )
233
+ return
234
+ cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
235
+ cid = clob_id.upper()
236
+ path_id, query_id = _clob_endpoint_id(cid)
237
+ if query_id is None:
238
+ cwms.delete_clob(office_id=office, clob_id=cid)
239
+ else:
240
+ cwms_api.delete(
241
+ f"clobs/{path_id}", params={"office": office, "clob-id": query_id}
242
+ )
243
+ logging.info(f"Deleted clob: {clob_id} for office: {office}")
244
+
245
+
246
+ def update_cmd(
247
+ input_file: str,
248
+ clob_id: str,
249
+ description: str,
250
+ ignore_nulls: bool,
251
+ dry_run: bool,
252
+ office: str,
253
+ api_root: str,
254
+ api_key: str,
255
+ ):
256
+ if dry_run:
257
+ logging.info(
258
+ f"DRY RUN: would PATCH {api_root} clob with clob-id={clob_id} office={office}"
259
+ )
260
+ return
261
+ file_data = None
262
+ if input_file:
263
+ try:
264
+ file_size = os.path.getsize(input_file)
265
+ with open(input_file, "r", encoding="utf-8") as f:
266
+ file_data = f.read()
267
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
268
+ except Exception as e:
269
+ logging.error(f"Failed to read file: {e}")
270
+ sys.exit(1)
271
+ # Setup minimum required payload
272
+ clob = {"office-id": office, "id": clob_id.upper()}
273
+ if description:
274
+ clob["description"] = description
275
+
276
+ if file_data:
277
+ clob["value"] = file_data
278
+ cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
279
+ cid = clob_id.upper()
280
+ path_id, query_id = _clob_endpoint_id(cid)
281
+ if query_id is None:
282
+ cwms.update_clob(clob, cid, ignore_nulls=ignore_nulls)
283
+ else:
284
+ cwms_api.patch(
285
+ f"clobs/{path_id}",
286
+ data=clob,
287
+ params={"clob-id": query_id, "ignore-nulls": ignore_nulls},
288
+ )
289
+ logging.info(f"Updated clob: {clob_id} for office: {office}")
290
+
291
+
292
+ def list_cmd(
293
+ clob_id_like: str,
294
+ columns: list[str],
295
+ sort_by: list[str],
296
+ desc: bool,
297
+ limit: int,
298
+ page_size: int,
299
+ to_csv: str,
300
+ office: str,
301
+ api_root: str,
302
+ api_key: str,
303
+ anonymous: bool = False,
304
+ ):
305
+ resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
306
+ cwms.init_session(api_root=api_root, api_key=resolved_api_key)
307
+ try:
308
+ df = list_clobs(
309
+ office=office,
310
+ clob_id_like=clob_id_like,
311
+ columns=columns,
312
+ sort_by=sort_by,
313
+ ascending=not desc,
314
+ limit=limit,
315
+ page_size=page_size,
316
+ )
317
+ except Exception:
318
+ log_scoped_read_hint(
319
+ api_key=resolved_api_key,
320
+ anonymous=anonymous,
321
+ office=office,
322
+ action="list",
323
+ resource="clob content",
324
+ )
325
+ raise
326
+ if to_csv:
327
+ df.to_csv(to_csv, index=False)
328
+ logging.info(f"Wrote {len(df)} rows to {to_csv}")
329
+ else:
330
+ # Friendly console preview
331
+ with pd.option_context("display.max_rows", 500, "display.max_columns", None):
332
+ # Left-align all columns
333
+ logging.info(
334
+ "\n"
335
+ + df.apply(
336
+ lambda s: (s := s.astype(str).str.strip()).str.ljust(
337
+ s.str.len().max()
338
+ )
339
+ ).to_string(index=False, justify="left")
340
+ )
@@ -387,6 +387,12 @@ def update_cmd(**kwargs):
387
387
  help="Sort descending instead of ascending.",
388
388
  )
389
389
  @click.option("--limit", type=int, default=None, help="Max rows to show.")
390
+ @click.option(
391
+ "--page-size",
392
+ type=int,
393
+ default=None,
394
+ help="Max rows to request from the blob endpoint. Defaults to --limit if set, otherwise no pagination (all results in one page).",
395
+ )
390
396
  @click.option(
391
397
  "--to-csv",
392
398
  type=click.Path(dir_okay=False, writable=True, path_type=str),
@@ -405,6 +411,174 @@ def list_cmd(**kwargs):
405
411
  list_cmd(**kwargs)
406
412
 
407
413
 
414
+ # endregion
415
+
416
+
417
+ # region Clob
418
+ # ================================================================================
419
+ # CLOB
420
+ # ================================================================================
421
+ @click.group(
422
+ "clob",
423
+ help="Manage CWMS Clobs (upload, download, delete, update, list)",
424
+ epilog=textwrap.dedent(
425
+ """
426
+ Example Usage:\n
427
+ - Download a clob by id to your local filesystem\n
428
+ - Update a clob's name/description/mime-type\n
429
+ - Bulk list clobs for an office
430
+ """
431
+ ),
432
+ )
433
+ @requires(reqs.cwms)
434
+ def clob_group():
435
+ pass
436
+
437
+
438
+ # ================================================================================
439
+ # Upload
440
+ # ================================================================================
441
+ @clob_group.command("upload", help="Upload a file as a clob")
442
+ @click.option(
443
+ "--input-file",
444
+ required=True,
445
+ type=click.Path(exists=True, dir_okay=False, readable=True, path_type=str),
446
+ help="Path to the file to upload.",
447
+ )
448
+ @click.option("--clob-id", required=True, type=str, help="Clob ID to create.")
449
+ @click.option("--description", default=None, help="Optional description JSON or text.")
450
+ @click.option(
451
+ "--overwrite/--no-overwrite",
452
+ default=False,
453
+ show_default=True,
454
+ help="If true, replace existing clob.",
455
+ )
456
+ @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
457
+ @common_api_options
458
+ def clob_upload(**kwargs):
459
+ from cwmscli.commands.clob import upload_cmd
460
+
461
+ upload_cmd(**kwargs)
462
+
463
+
464
+ # ================================================================================
465
+ # Download
466
+ # ================================================================================
467
+ @clob_group.command("download", help="Download a clob by ID")
468
+ # TODO: test XML
469
+ @click.option("--clob-id", required=True, type=str, help="Clob ID to download.")
470
+ @click.option(
471
+ "--dest",
472
+ default=None,
473
+ help="Destination file path. Defaults to clob-id.",
474
+ )
475
+ @click.option(
476
+ "--anonymous",
477
+ is_flag=True,
478
+ help="Do not send credentials for this read request, even if they are configured.",
479
+ )
480
+ @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
481
+ @common_api_options
482
+ def clob_download(**kwargs):
483
+ from cwmscli.commands.clob import download_cmd
484
+
485
+ download_cmd(**kwargs)
486
+
487
+
488
+ # ================================================================================
489
+ # Delete
490
+ # ================================================================================
491
+ @clob_group.command("delete", help="Delete a clob by ID")
492
+ @click.option("--clob-id", required=True, type=str, help="Clob ID to delete.")
493
+ @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
494
+ @common_api_options
495
+ def delete_cmd(**kwargs):
496
+ from cwmscli.commands.clob import delete_cmd
497
+
498
+ delete_cmd(**kwargs)
499
+
500
+
501
+ # ================================================================================
502
+ # Update
503
+ # ================================================================================
504
+ @clob_group.command("update", help="Update/patch a clob by ID")
505
+ @click.option("--clob-id", required=True, type=str, help="Clob ID to update.")
506
+ @click.option("--dry-run", is_flag=True, help="Show request; do not send.")
507
+ @click.option(
508
+ "--description",
509
+ default=None,
510
+ help="New description JSON or text.",
511
+ )
512
+ @click.option(
513
+ "--input-file",
514
+ required=False,
515
+ type=click.Path(exists=True, dir_okay=False, readable=True, path_type=str),
516
+ help="Optional file content to upload with update.",
517
+ )
518
+ @click.option(
519
+ "--ignore-nulls/--no-ignore-nulls",
520
+ default=True,
521
+ show_default=True,
522
+ help="If true, null and empty fields in the provided clob will be ignored and the existing value of those fields left in place.",
523
+ )
524
+ @common_api_options
525
+ def update_cmd(**kwargs):
526
+ from cwmscli.commands.clob import update_cmd
527
+
528
+ update_cmd(**kwargs)
529
+
530
+
531
+ # ================================================================================
532
+ # List
533
+ # ================================================================================
534
+ @clob_group.command("list", help="List clobs with optional filters and sorting")
535
+ # TODO: Add link to regex docs when new CWMS-DATA site is deployed to PROD
536
+ @click.option(
537
+ "--clob-id-like", help="LIKE filter for clob ID (e.g., ``*PNG``)."
538
+ ) # Escape the wildcard/asterisk for RTD generation with double backticks
539
+ @click.option(
540
+ "--columns",
541
+ multiple=True,
542
+ callback=csv_to_list,
543
+ help="Columns to show (repeat or comma-separate).",
544
+ )
545
+ @click.option(
546
+ "--sort-by",
547
+ multiple=True,
548
+ callback=csv_to_list,
549
+ help="Columns to sort by (repeat or comma-separate).",
550
+ )
551
+ @click.option(
552
+ "--desc/--asc",
553
+ default=False,
554
+ show_default=True,
555
+ help="Sort descending instead of ascending.",
556
+ )
557
+ @click.option("--limit", type=int, default=None, help="Max rows to show.")
558
+ @click.option(
559
+ "--page-size",
560
+ type=int,
561
+ default=None,
562
+ help="Max rows to request from the clob endpoint. Defaults to --limit when set.",
563
+ )
564
+ @click.option(
565
+ "--to-csv",
566
+ type=click.Path(dir_okay=False, writable=True, path_type=str),
567
+ help="If set, write results to this CSV file.",
568
+ )
569
+ @click.option(
570
+ "--anonymous",
571
+ is_flag=True,
572
+ help="Do not send credentials for this read request, even if they are configured.",
573
+ )
574
+ @common_api_options
575
+ def list_cmd(**kwargs):
576
+ from cwmscli.commands.clob import list_cmd
577
+
578
+ list_cmd(**kwargs)
579
+
580
+
581
+ # endregion
408
582
  # ================================================================================
409
583
  # USERS
410
584
  # ================================================================================