cwms-cli 0.3.7__tar.gz → 0.4.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 (67) hide show
  1. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/PKG-INFO +1 -1
  2. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/__main__.py +1 -0
  3. cwms_cli-0.4.0/cwmscli/callbacks/__init__.py +27 -0
  4. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/commands_cwms.py +154 -1
  5. cwms_cli-0.4.0/cwmscli/commands/users.py +420 -0
  6. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/requirements.py +1 -1
  7. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/getusgs_cda.py +79 -16
  8. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/__init__.py +15 -4
  9. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/click_help.py +3 -0
  10. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/colors.py +0 -2
  11. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/deps.py +1 -1
  12. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/pyproject.toml +6 -2
  13. cwms_cli-0.3.7/cwmscli/callbacks/__init__.py +0 -18
  14. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/LICENSE +0 -0
  15. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/README.md +0 -0
  16. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/__init__.py +0 -0
  17. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/blob.py +0 -0
  18. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  19. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/README.md +0 -0
  20. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  21. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  22. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/config.py +0 -0
  23. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  24. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  25. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/parser.py +0 -0
  26. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  27. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  28. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  29. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  30. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  31. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  32. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  33. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  34. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  35. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  36. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/transform.py +0 -0
  37. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  38. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  39. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  40. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  41. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  42. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/csv2cwms/writer.py +0 -0
  43. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/commands/shef_critfile_import.py +0 -0
  44. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/README.md +0 -0
  45. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/__init__.py +0 -0
  46. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/__main__.py +0 -0
  47. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/location/location.py +0 -0
  48. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/location/location_ids.py +0 -0
  49. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  50. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/root.py +0 -0
  51. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/timeseries/timeseries.py +0 -0
  52. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  53. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/__init__.py +0 -0
  54. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/__main__.py +0 -0
  55. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  56. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  57. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  58. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/friendly_errors.py +0 -0
  59. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/intervals.py +0 -0
  60. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/io.py +0 -0
  61. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/links.py +0 -0
  62. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/logging/__init__.py +0 -0
  63. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/logging/formatters.py +0 -0
  64. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/ssl_errors.py +0 -0
  65. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/update.py +0 -0
  66. {cwms_cli-0.3.7 → cwms_cli-0.4.0}/cwmscli/utils/version.py +0 -0
  67. {cwms_cli-0.3.7 → cwms_cli-0.4.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.3.7
3
+ Version: 0.4.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.users_group)
88
89
  cli.add_command(load.load_group)
89
90
  add_version_to_help_tree(cli)
90
91
 
@@ -0,0 +1,27 @@
1
+ # Click callbacks for click
2
+
3
+ import re
4
+
5
+
6
+ def csv_to_list(ctx, param, value):
7
+ """Accept multiple values either via repeated flags or a single delimiter-separated string.
8
+
9
+ Supported delimiters are comma (,) and pipe (|) to make CLI usage easier and avoid
10
+ shell pipe interpretation issues when users type role shortcuts.
11
+ """
12
+ if value is None:
13
+ return None
14
+ if isinstance(value, (list, tuple)):
15
+ out = []
16
+ for v in value:
17
+ if isinstance(v, str) and ("," in v or "|" in v):
18
+ for part in re.split(r"[,|]", v):
19
+ part = part.strip()
20
+ if part:
21
+ out.append(part)
22
+ else:
23
+ out.append(v)
24
+ return tuple(out)
25
+ if isinstance(value, str):
26
+ return tuple(p.strip() for p in re.split(r"[,|]", value) if p.strip())
27
+ return value
@@ -9,7 +9,17 @@ import click
9
9
  from cwmscli import requirements as reqs
10
10
  from cwmscli.callbacks import csv_to_list
11
11
  from cwmscli.commands import csv2cwms
12
- from cwmscli.utils import api_key_loc_option, colors, common_api_options, to_uppercase
12
+ from cwmscli.utils import (
13
+ api_key_loc_option,
14
+ api_key_option,
15
+ api_root_option,
16
+ colors,
17
+ common_api_options,
18
+ get_api_key,
19
+ office_option,
20
+ office_option_notrequired,
21
+ to_uppercase,
22
+ )
13
23
  from cwmscli.utils.deps import requires
14
24
  from cwmscli.utils.update import (
15
25
  build_update_package_spec,
@@ -393,3 +403,146 @@ def list_cmd(**kwargs):
393
403
  from cwmscli.commands.blob import list_cmd
394
404
 
395
405
  list_cmd(**kwargs)
406
+
407
+
408
+ # ================================================================================
409
+ # USERS
410
+ # ================================================================================
411
+ user_name_option = click.option(
412
+ "-u",
413
+ "--user-name",
414
+ type=str,
415
+ default=None,
416
+ required=True,
417
+ help="Existing user name.",
418
+ )
419
+
420
+
421
+ @click.group(
422
+ "users",
423
+ help="Manage CWMS users and user-management roles",
424
+ )
425
+ def users_group():
426
+ pass
427
+
428
+
429
+ @users_group.command(
430
+ "user-ids",
431
+ help="List all available user IDs for an office or lookup using like filter",
432
+ )
433
+ @office_option_notrequired
434
+ @api_root_option
435
+ @api_key_option
436
+ @api_key_loc_option
437
+ @click.option(
438
+ "-ul",
439
+ "--username-like",
440
+ type=str,
441
+ default=None,
442
+ help="Enter any part of a user name to filter user id listing. Case-insensitive.",
443
+ )
444
+ @requires(reqs.cwms)
445
+ def users_user_ids(office, api_root, api_key, api_key_loc, username_like):
446
+ from cwmscli.commands.users import list_user_ids
447
+
448
+ list_user_ids(
449
+ office=office,
450
+ api_root=api_root,
451
+ api_key=api_key,
452
+ api_key_loc=api_key_loc,
453
+ username_like=username_like,
454
+ )
455
+
456
+
457
+ @click.group(
458
+ "roles",
459
+ help="Manage CWMS users and user-management roles",
460
+ )
461
+ def roles_group():
462
+ pass
463
+
464
+
465
+ @roles_group.command(
466
+ "list-all",
467
+ help="List assignable CWMS user-management roles",
468
+ )
469
+ @api_root_option
470
+ @api_key_option
471
+ @api_key_loc_option
472
+ @requires(reqs.cwms)
473
+ def users_roles_list_all(api_root, api_key, api_key_loc):
474
+ from cwmscli.commands.users import list_roles
475
+
476
+ list_roles(api_root=api_root, api_key=api_key, api_key_loc=api_key_loc)
477
+
478
+
479
+ @roles_group.command("list-user", help="List roles for a specific user and office")
480
+ @user_name_option
481
+ @office_option_notrequired
482
+ @api_root_option
483
+ @api_key_option
484
+ @api_key_loc_option
485
+ @requires(reqs.cwms)
486
+ def users_roles_list_user(user_name, office, api_root, api_key, api_key_loc):
487
+ from cwmscli.commands.users import list_user_roles
488
+
489
+ list_user_roles(
490
+ user_name=user_name,
491
+ office=office,
492
+ api_root=api_root,
493
+ api_key=api_key,
494
+ api_key_loc=api_key_loc,
495
+ )
496
+
497
+
498
+ @roles_group.command("add", help="Add one or more roles to an existing user")
499
+ @common_api_options
500
+ @api_key_loc_option
501
+ @user_name_option
502
+ @click.option(
503
+ "--roles",
504
+ multiple=True,
505
+ default=None,
506
+ callback=csv_to_list,
507
+ help="enter admin, readonly, readwrite, or individual role name(s) to add. Repeat the option or pass a comma/pipe-separated list.",
508
+ )
509
+ @requires(reqs.cwms)
510
+ def users_roles_add(office, api_root, api_key, api_key_loc, user_name, roles):
511
+ from cwmscli.commands.users import add_roles
512
+
513
+ add_roles(
514
+ user_name=user_name,
515
+ roles=roles,
516
+ office=office,
517
+ api_root=api_root,
518
+ api_key=api_key,
519
+ api_key_loc=api_key_loc,
520
+ )
521
+
522
+
523
+ @roles_group.command("delete", help="Remove one or more roles from an existing user")
524
+ @common_api_options
525
+ @api_key_loc_option
526
+ @user_name_option
527
+ @click.option(
528
+ "--roles",
529
+ multiple=True,
530
+ default=None,
531
+ callback=csv_to_list,
532
+ help="enter 'all' to delete all roles, or admin, readonly, readwrite, or individual role name(s) to delete. Repeat the option or pass a comma/pipe-separated list.",
533
+ )
534
+ @requires(reqs.cwms)
535
+ def users_roles_delete(office, api_root, api_key, api_key_loc, user_name, roles):
536
+ from cwmscli.commands.users import delete_roles
537
+
538
+ delete_roles(
539
+ user_name=user_name,
540
+ roles=roles,
541
+ office=office,
542
+ api_root=api_root,
543
+ api_key=api_key,
544
+ api_key_loc=api_key_loc,
545
+ )
546
+
547
+
548
+ users_group.add_command(roles_group)
@@ -0,0 +1,420 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Union
4
+
5
+ import click
6
+
7
+ from cwmscli.utils import colors, get_api_key
8
+
9
+
10
+ def _format_table(headers: list[str], rows: list[list[str]]) -> str:
11
+ widths = [len(header) for header in headers]
12
+ for row in rows:
13
+ for idx, cell in enumerate(row):
14
+ widths[idx] = max(widths[idx], len(cell))
15
+
16
+ def render_row(row: list[str]) -> str:
17
+ return " ".join(cell.ljust(widths[idx]) for idx, cell in enumerate(row))
18
+
19
+ divider = " ".join("-" * width for width in widths)
20
+ lines = [render_row(headers), divider]
21
+ lines.extend(render_row(row) for row in rows)
22
+ return "\n".join(lines)
23
+
24
+
25
+ def _cmd(text: str) -> str:
26
+ return colors.c(text, "blue", bright=True)
27
+
28
+
29
+ def _format_api_error(error: Exception, cwms_module) -> str:
30
+ message = str(error)
31
+ if isinstance(error, cwms_module.api.PermissionError):
32
+ status_text = "CDA responded with 403 Forbidden."
33
+ if status_text in message:
34
+ message = message.replace(status_text, colors.err(status_text))
35
+ message = (
36
+ f"{message} Use an admin API key or sign in as a user with user-management "
37
+ "admin roles. I.e. 'CWMS User Admins'"
38
+ )
39
+ return message
40
+
41
+
42
+ def _handle_api_error(error: Exception, cwms_module) -> None:
43
+ raise click.ClickException(_format_api_error(error, cwms_module)) from None
44
+
45
+
46
+ def _init_cwms(api_root: str, api_key: str, api_key_loc: str) -> object:
47
+ import cwms
48
+
49
+ resolved_api_key = get_api_key(api_key, api_key_loc)
50
+ cwms.init_session(api_root=api_root, api_key=resolved_api_key)
51
+ return cwms
52
+
53
+
54
+ def _fetch_roles(cwms_module) -> list[str]:
55
+ try:
56
+ return sorted(cwms_module.get_roles(), key=str.casefold)
57
+ except cwms_module.api.ApiError as error:
58
+ _handle_api_error(error, cwms_module)
59
+
60
+
61
+ def _fetch_users(
62
+ cwms_module, office=None, username_like=None, page_size: int = 5000
63
+ ) -> list[dict]:
64
+ users: list[dict] = []
65
+
66
+ try:
67
+ response = cwms_module.get_users(
68
+ office_id=office, username_like=username_like, page_size=page_size
69
+ )
70
+ except cwms_module.api.ApiError as error:
71
+ _handle_api_error(error, cwms_module)
72
+
73
+ payload = getattr(response, "json", {}) or {}
74
+ page_users = payload.get("users", [])
75
+ users.extend(page_users)
76
+
77
+ return users
78
+
79
+
80
+ def _fetch_user_roles(
81
+ cwms_module, user_name: str, office: Optional[str] = None
82
+ ) -> list[str]:
83
+ roles_payload = _get_user_roles_payload(cwms_module, user_name)
84
+ return _extract_roles_from_payload(roles_payload, office)
85
+
86
+
87
+ def _get_user_roles_payload(cwms_module, user_name: str) -> dict:
88
+ try:
89
+ payload = cwms_module.get_user(user_name=user_name)
90
+ return payload.get("roles", {})
91
+ except cwms_module.api.ApiError as error:
92
+ _handle_api_error(error, cwms_module)
93
+
94
+
95
+ def _extract_roles_from_payload(
96
+ roles_payload: dict, office: Optional[str] = None
97
+ ) -> list[str]:
98
+ if isinstance(roles_payload, dict):
99
+ if office:
100
+ office_key = office.strip().upper()
101
+ office_roles = roles_payload.get(office_key, [])
102
+ if office_roles is None:
103
+ office_roles = []
104
+ return [r.strip() for r in office_roles if r and r.strip()]
105
+ all_roles = []
106
+ for rlist in roles_payload.values():
107
+ if rlist:
108
+ all_roles.extend(rlist)
109
+ return sorted(
110
+ {r.strip() for r in all_roles if r and r.strip()}, key=str.casefold
111
+ )
112
+
113
+ if isinstance(roles_payload, list):
114
+ return [r.strip() for r in roles_payload if r and r.strip()]
115
+
116
+ return []
117
+
118
+
119
+ def _existing_user_name(users: list[dict], user_name: str) -> Optional[str]:
120
+
121
+ requested = user_name.strip().casefold()
122
+ for user in users:
123
+ existing = str(user.get("user-name", "")).strip()
124
+ if existing and existing.casefold() == requested:
125
+ return existing
126
+ return None
127
+
128
+
129
+ def _split_roles(
130
+ raw_roles: Optional[Union[tuple[str, ...], list[str]]] = None
131
+ ) -> list[str]:
132
+ if not raw_roles:
133
+ return []
134
+ return [role.strip() for role in raw_roles if role and role.strip()]
135
+
136
+
137
+ def _expand_role_shortcuts(roles: list[str]) -> list[str]:
138
+ emap = {
139
+ "admin": ["All Users", "CWMS Users", "TS ID Creator", "CWMS User Admins"],
140
+ "readonly": ["All Users", "CWMS Users"],
141
+ "readwrite": ["All Users", "CWMS Users", "TS ID Creator"],
142
+ }
143
+
144
+ expanded_roles: list[str] = []
145
+ for role in roles:
146
+ key = role.strip().casefold()
147
+ if key == "all":
148
+ expanded_roles.append(role) # Keep "all" as is, handled elsewhere
149
+ elif key in emap:
150
+ expanded_roles.extend(emap[key])
151
+ else:
152
+ expanded_roles.append(role)
153
+ return expanded_roles
154
+
155
+
156
+ def _validate_role_inputs(
157
+ users: list[dict], available_roles: list[str], user_name: str, roles: list[str]
158
+ ) -> tuple[str, list[str]]:
159
+ existing_user = _existing_user_name(users, user_name)
160
+ if not existing_user:
161
+ raise click.ClickException(f"User '{user_name}' was not found in CDA /users.")
162
+
163
+ roles_lookup = {role.casefold(): role for role in available_roles}
164
+ normalized_roles: list[str] = []
165
+ invalid_roles: list[str] = []
166
+
167
+ for role in roles:
168
+ normalized = roles_lookup.get(role.casefold())
169
+ if normalized:
170
+ normalized_roles.append(normalized)
171
+ else:
172
+ invalid_roles.append(role)
173
+
174
+ if invalid_roles:
175
+ invalid = ", ".join(colors.err(role) for role in invalid_roles)
176
+ raise click.ClickException(
177
+ f"Unknown role value(s): {invalid}. Run {_cmd('cwms-cli users roles')} to see the valid role catalog."
178
+ )
179
+
180
+ deduped_roles = []
181
+ seen_roles = set()
182
+ for role in normalized_roles:
183
+ if role not in seen_roles:
184
+ seen_roles.add(role)
185
+ deduped_roles.append(role)
186
+
187
+ if not deduped_roles:
188
+ raise click.ClickException("At least one valid role is required.")
189
+
190
+ return existing_user, deduped_roles
191
+
192
+
193
+ def _render_roles_table(roles: list[str]) -> str:
194
+ rows = [[colors.c(role, "green")] for role in roles]
195
+ return _format_table(["Role"], rows)
196
+
197
+
198
+ def _render_user_roles_table(user_roles: dict[str, list[str]]) -> str:
199
+ rows = []
200
+ for office, roles in sorted(user_roles.items()):
201
+ roles_str = ", ".join(sorted(roles))
202
+ rows.append([colors.c(office, "cyan"), colors.c(roles_str, "green")])
203
+ return _format_table(["Office", "Roles"], rows)
204
+
205
+
206
+ def _render_users_table(users: list[dict]) -> str:
207
+ rows = [[colors.c(user.get("user-name", ""), "green")] for user in users]
208
+ return _format_table(["User Name"], rows)
209
+
210
+
211
+ def _prompt_for_role_inputs(
212
+ action: str, available_roles: list[str]
213
+ ) -> tuple[str, list[str]]:
214
+ click.echo(f"Enter the target user and one or more roles to {action}.")
215
+ click.echo("")
216
+ user_name = click.prompt("User name", type=str).strip()
217
+ click.echo("")
218
+ click.echo("Available roles:")
219
+ click.echo(_render_roles_table(available_roles))
220
+ click.echo("")
221
+ roles_raw = click.prompt(
222
+ "Roles (comma-separated; use names exactly as listed; or shortcuts admin/readwrite/readonly)",
223
+ type=str,
224
+ ).strip()
225
+ roles = [role.strip() for role in roles_raw.split(",") if role.strip()]
226
+ return user_name, roles
227
+
228
+
229
+ def _prompt_for_office(office: str) -> str:
230
+ colored_office = colors.c(office, "cyan", bright=True)
231
+ change_office = click.confirm(
232
+ f"You have office set to {colored_office}, would you like to change this?",
233
+ default=False,
234
+ )
235
+ if not change_office:
236
+ return office
237
+
238
+ return (
239
+ click.prompt("Office", type=str, default=office, show_default=True)
240
+ .strip()
241
+ .upper()
242
+ )
243
+
244
+
245
+ def list_roles(api_root: str, api_key: str, api_key_loc: str) -> None:
246
+ cwms = _init_cwms(api_root, api_key, api_key_loc)
247
+ roles = _fetch_roles(cwms)
248
+ header_count = colors.c(str(len(roles)), "cyan", bright=True)
249
+ click.echo(f"Available roles for user management: {header_count}")
250
+ if not roles:
251
+ click.echo(colors.warn("No roles were returned."))
252
+ return
253
+ click.echo(_render_roles_table(roles))
254
+
255
+
256
+ def list_user_roles(
257
+ user_name: str, office: Optional[str], api_root: str, api_key: str, api_key_loc: str
258
+ ) -> None:
259
+ cwms = _init_cwms(api_root, api_key, api_key_loc)
260
+ roles_payload = _get_user_roles_payload(cwms, user_name)
261
+ if isinstance(roles_payload, dict):
262
+ if office:
263
+ office_key = office.strip().upper()
264
+ office_roles = roles_payload.get(office_key, [])
265
+ if office_roles is None:
266
+ office_roles = []
267
+ user_roles = [r.strip() for r in office_roles if r and r.strip()]
268
+ header = (
269
+ f"Roles for user '{user_name}' in office '{office}': {len(user_roles)}"
270
+ )
271
+ click.echo(colors.c(header, "cyan", bright=True))
272
+ if not user_roles:
273
+ click.echo(colors.warn("No roles found."))
274
+ return
275
+ click.echo(_render_roles_table(user_roles))
276
+ else:
277
+ # List roles for each office
278
+ header = f"Roles for user '{user_name}' across all offices:"
279
+ click.echo(colors.c(header, "cyan", bright=True))
280
+ for off, roles in roles_payload.items():
281
+ if roles:
282
+ cleaned_roles = [r.strip() for r in roles if r and r.strip()]
283
+ if cleaned_roles:
284
+ click.echo(
285
+ colors.c(
286
+ f"Office '{off}': {len(cleaned_roles)} roles", "yellow"
287
+ )
288
+ )
289
+ click.echo(_render_roles_table(cleaned_roles))
290
+ click.echo("")
291
+ else:
292
+ click.echo(colors.c(f"Office '{off}': No roles", "yellow"))
293
+ click.echo("")
294
+ else:
295
+ click.echo(colors.warn("Unexpected roles format."))
296
+
297
+
298
+ def list_user_ids(
299
+ office: str,
300
+ api_root: str,
301
+ api_key: str,
302
+ api_key_loc: str,
303
+ username_like: Optional[str] = None,
304
+ ) -> None:
305
+ # This command is wired from user roles group default action. Users can
306
+ # pass an optional name filter but for now roles view is what should be shown.
307
+ cwms = _init_cwms(api_root, api_key, api_key_loc)
308
+ users = _fetch_users(cwms, office, username_like=username_like)
309
+ click.echo(_render_users_table(users))
310
+
311
+
312
+ def add_roles(
313
+ office: str,
314
+ api_root: str,
315
+ api_key: str,
316
+ api_key_loc: str,
317
+ user_name: Optional[str],
318
+ roles: Optional[Union[tuple[str, ...], list[str]]],
319
+ ) -> None:
320
+ provided_user_name = (user_name or "").strip()
321
+ provided_roles = _split_roles(roles)
322
+
323
+ if bool(provided_user_name) ^ bool(provided_roles):
324
+ raise click.ClickException(
325
+ "Either specify all add arguments (--user-name and --roles) or run "
326
+ f"{_cmd('cwms-cli users roles add')} interactively with no add-specific args."
327
+ )
328
+
329
+ cwms = _init_cwms(api_root, api_key, api_key_loc)
330
+ users = _fetch_users(cwms)
331
+ available_roles = _fetch_roles(cwms)
332
+
333
+ if not provided_user_name and not provided_roles:
334
+ office = _prompt_for_office(office)
335
+ provided_user_name, provided_roles = _prompt_for_role_inputs(
336
+ "add", available_roles
337
+ )
338
+
339
+ provided_roles = _expand_role_shortcuts(provided_roles)
340
+ existing_user, validated_roles = _validate_role_inputs(
341
+ users, available_roles, provided_user_name, provided_roles
342
+ )
343
+
344
+ try:
345
+ cwms.store_user(
346
+ user_name=existing_user, office_id=office, roles=validated_roles
347
+ )
348
+ except cwms.api.ApiError as error:
349
+ _handle_api_error(error, cwms)
350
+
351
+ click.echo(
352
+ f"Added {len(validated_roles)} role(s) to user "
353
+ f"{colors.c(existing_user, 'cyan', bright=True)} for office "
354
+ f"{colors.c(office, 'cyan', bright=True)}."
355
+ )
356
+ click.echo(_render_roles_table(validated_roles))
357
+
358
+
359
+ def delete_roles(
360
+ office: str,
361
+ api_root: str,
362
+ api_key: str,
363
+ api_key_loc: str,
364
+ user_name: Optional[str],
365
+ roles: Optional[Union[tuple[str, ...], list[str]]],
366
+ ) -> None:
367
+ provided_user_name = (user_name or "").strip()
368
+ provided_roles = _split_roles(roles)
369
+ provided_roles = _expand_role_shortcuts(provided_roles)
370
+
371
+ if bool(provided_user_name) ^ bool(provided_roles):
372
+ raise click.ClickException(
373
+ "Either specify all delete arguments (--user-name and --roles) or run "
374
+ f"{_cmd('cwms-cli users roles delete')} interactively with no delete-specific args."
375
+ )
376
+
377
+ cwms = _init_cwms(api_root, api_key, api_key_loc)
378
+ users = _fetch_users(cwms, office)
379
+ available_roles = _fetch_roles(cwms)
380
+
381
+ if not provided_user_name and not provided_roles:
382
+ office = _prompt_for_office(office)
383
+ provided_user_name, provided_roles = _prompt_for_role_inputs(
384
+ "delete", available_roles
385
+ )
386
+ provided_roles = _expand_role_shortcuts(provided_roles)
387
+
388
+ if provided_roles and any(r.strip().casefold() == "all" for r in provided_roles):
389
+ if len(provided_roles) > 1:
390
+ raise click.ClickException("'all' cannot be specified with other roles.")
391
+ existing_user = _existing_user_name(users, provided_user_name)
392
+ if not existing_user:
393
+ raise click.ClickException(f"User '{provided_user_name}' not found.")
394
+ fetched_roles = _fetch_user_roles(cwms, existing_user, office)
395
+ provided_roles = fetched_roles
396
+
397
+ existing_user, validated_roles = _validate_role_inputs(
398
+ users, available_roles, provided_user_name, provided_roles
399
+ )
400
+ if "All Users" in validated_roles:
401
+ validated_roles.remove("All Users")
402
+ click.echo(
403
+ colors.warn(
404
+ "Warning: 'All Users' role cannot be deleted directly and will remain assigned. "
405
+ "Any other specified roles will be deleted as requested."
406
+ )
407
+ )
408
+ try:
409
+ cwms.delete_user_roles(
410
+ user_name=existing_user, office_id=office, roles=validated_roles
411
+ )
412
+ except cwms.api.ApiError as error:
413
+ _handle_api_error(error, cwms)
414
+
415
+ click.echo(
416
+ f"Deleted {len(validated_roles)} role(s) from user "
417
+ f"{colors.c(existing_user, 'cyan', bright=True)} for office "
418
+ f"{colors.c(office, 'cyan', bright=True)}."
419
+ )
420
+ click.echo(_render_roles_table(validated_roles))
@@ -5,7 +5,7 @@
5
5
  cwms = {
6
6
  "module": "cwms",
7
7
  "package": "cwms-python",
8
- "version": "0.8.0",
8
+ "version": "1.0.7",
9
9
  "desc": "CWMS REST API Python client",
10
10
  "link": "https://github.com/HydrologicEngineeringCenter/cwms-python",
11
11
  }
@@ -6,6 +6,36 @@ import numpy as np
6
6
  import pandas as pd
7
7
  import requests
8
8
 
9
+ from cwmscli.utils import colors
10
+
11
+
12
+ def _log_error_and_exit(message: str, hint: str | None = None, *, exit_code: int = 1):
13
+ logging.error(colors.c(message, "red", bright=True))
14
+ if hint:
15
+ logging.error(colors.c(f"Hint: {hint}", "yellow", bright=True))
16
+ raise SystemExit(exit_code)
17
+
18
+
19
+ def _require_group_dataframe(response, *, resource_name: str, office: str):
20
+ payload = getattr(response, "json", None)
21
+
22
+ try:
23
+ df = response.df
24
+ except AttributeError as exc:
25
+ if isinstance(payload, str):
26
+ _log_error_and_exit(
27
+ f"CWMS returned an unexpected response while reading {resource_name} for office {office}.",
28
+ "Check that --api-root points to the CDA API endpoint ending in /cwms-data.",
29
+ )
30
+ raise
31
+
32
+ if df is None:
33
+ _log_error_and_exit(
34
+ f"CWMS did not return any {resource_name} data for office {office}."
35
+ )
36
+
37
+ return df
38
+
9
39
 
10
40
  def getusgs_cda(
11
41
  api_root: str,
@@ -58,12 +88,13 @@ def getusgs_cda(
58
88
  CWMS_writeData(USGS_ts, USGS_data, USGS_data_method, days_back)
59
89
  else:
60
90
  if backfill_tsids:
61
- logging.error(
62
- f"The following backload timeseries ids were not present in the USGS timeseries or Locations groups: {backfill_tsids}"
91
+ _log_error_and_exit(
92
+ f"The following backfill time series ids were not present in the USGS time series or location alias groups: {backfill_tsids}"
63
93
  )
64
94
  else:
65
- logging.error(
66
- f"USGS data was present in the timeseries or locations groups"
95
+ _log_error_and_exit(
96
+ f"No eligible USGS time series were found for office {office_id}.",
97
+ "Confirm that time series exist in Data Acquisition / USGS TS Data Acquisition and that matching entries exist in Agency Aliases / USGS Station Number.",
67
98
  )
68
99
 
69
100
 
@@ -115,18 +146,36 @@ def get_CMWS_TS_Loc_Data(office):
115
146
  usgs_param = "Not Found"
116
147
  return usgs_param
117
148
 
118
- df = cwms.get_timeseries_group(
119
- group_id="USGS TS Data Acquisition",
120
- category_id="Data Acquisition",
121
- office_id=office,
122
- category_office_id="CWMS",
123
- group_office_id="CWMS",
124
- ).df
149
+ df = _require_group_dataframe(
150
+ cwms.get_timeseries_group(
151
+ group_id="USGS TS Data Acquisition",
152
+ category_id="Data Acquisition",
153
+ office_id=office,
154
+ category_office_id="CWMS",
155
+ group_office_id="CWMS",
156
+ ),
157
+ resource_name="USGS TS Data Acquisition group",
158
+ office=office,
159
+ )
160
+ if df is None or df.empty:
161
+ _log_error_and_exit(
162
+ f"No time series are defined in Data Acquisition / USGS TS Data Acquisition for office {office}.",
163
+ "Add one or more time series to that group, then rerun the command.",
164
+ )
165
+ if "timeseries-id" not in df.columns:
166
+ _log_error_and_exit(
167
+ "The USGS TS Data Acquisition group response did not include a 'timeseries-id' column."
168
+ )
125
169
  df[["location-id", "param", "type", "int", "dur", "ver"]] = df[
126
170
  "timeseries-id"
127
171
  ].str.split(".", expand=True)
128
172
 
129
173
  df = df[df["office-id"] == office]
174
+ if df.empty:
175
+ _log_error_and_exit(
176
+ f"No time series are defined in Data Acquisition / USGS TS Data Acquisition for office {office}.",
177
+ "Add one or more office-specific time series to that group, then rerun the command.",
178
+ )
130
179
  df["base-loc"] = df["location-id"].str.split("-", expand=True)[0]
131
180
  if "alias-id" not in df.columns:
132
181
  df["alias-id"] = np.nan
@@ -135,17 +184,31 @@ def get_CMWS_TS_Loc_Data(office):
135
184
  df = df.rename(columns={"alias-id": "USGS_Method_TS"})
136
185
 
137
186
  # error in CDA with category_office_id and group_office_id. need to fix once CDA is updated
138
- Locdf = cwms.get_location_group(
139
- loc_group_id="USGS Station Number",
140
- category_id="Agency Aliases",
141
- office_id="CWMS",
142
- ).df.set_index("location-id")
187
+ Locdf = _require_group_dataframe(
188
+ cwms.get_location_group(
189
+ loc_group_id="USGS Station Number",
190
+ category_id="Agency Aliases",
191
+ office_id="CWMS",
192
+ ),
193
+ resource_name="USGS Station Number alias group",
194
+ office=office,
195
+ ).set_index("location-id")
143
196
 
144
197
  Locdf = Locdf[Locdf["office-id"] == office]
198
+ if Locdf.empty:
199
+ _log_error_and_exit(
200
+ f"No USGS location aliases are defined in Agency Aliases / USGS Station Number for office {office}.",
201
+ "Add one or more location aliases to that group, then rerun the command.",
202
+ )
145
203
  if "attribute" not in Locdf.columns:
146
204
  Locdf["attribute"] = np.nan
147
205
  # Grab all of the locations that have a USGS station number assigned to them
148
206
  USGS_alias = Locdf[Locdf["alias-id"].notnull()]
207
+ if USGS_alias.empty:
208
+ _log_error_and_exit(
209
+ f"No USGS location aliases are defined in Agency Aliases / USGS Station Number for office {office}.",
210
+ "Add one or more alias-id values for that office, then rerun the command.",
211
+ )
149
212
  # rename the columns
150
213
  USGS_alias = USGS_alias.rename(
151
214
  columns={"alias-id": "USGS_St_Num", "attribute": "Loc_attribute"}
@@ -42,6 +42,16 @@ office_option = click.option(
42
42
  callback=to_uppercase,
43
43
  help="Office to grab data for",
44
44
  )
45
+ office_option_notrequired = click.option(
46
+ "-o",
47
+ "--office",
48
+ default=None,
49
+ required=False,
50
+ envvar="OFFICE",
51
+ type=str,
52
+ callback=to_uppercase,
53
+ help="Office to grab data for",
54
+ )
45
55
  api_root_option = click.option(
46
56
  "-a",
47
57
  "--api-root",
@@ -82,17 +92,18 @@ log_level_option = click.option(
82
92
  envvar="LOG_LEVEL",
83
93
  callback=_set_log_level,
84
94
  expose_value=False, # Callback will set the log level of all methods
85
- is_eager=True, # Run before other commands (to cover any logging statements)
95
+ # Run before other commands (to cover any logging statements)
96
+ is_eager=True,
86
97
  help="Set logging verbosity (overrides default INFO).",
87
98
  )
88
99
 
89
100
 
90
101
  def get_api_key(api_key: str, api_key_loc: str) -> str:
91
- if api_key is not None:
92
- return api_key
93
- elif api_key_loc is not None:
102
+ if api_key_loc is not None:
94
103
  with open(api_key_loc, "r") as f:
95
104
  return f.readline().strip()
105
+ elif api_key is not None:
106
+ return api_key
96
107
  else:
97
108
  raise Exception(
98
109
  "must add a value to either --api-key(-k) or --api-key-loc(-kl)"
@@ -41,6 +41,9 @@ def _docs_url_for_context(ctx: click.Context) -> Optional[str]:
41
41
  command = (ctx.info_name or getattr(ctx.command, "name", None) or "").strip()
42
42
  page_map = {
43
43
  "blob": "blob",
44
+ "update": "update",
45
+ "users": "users",
46
+ "version": "version",
44
47
  }
45
48
  # Link to dedicated pages that are created in \docs
46
49
  if command in page_map:
@@ -17,8 +17,6 @@ def c(text: str, color: str, bright: bool = False) -> str:
17
17
  # Find the color in Fore and apply it to the text, then reset the style at the end
18
18
  if hasattr(Fore, color.upper()):
19
19
  color = getattr(Fore, color.upper())
20
- else:
21
- color = ""
22
20
  return f"{color}{b}{text}{Style.RESET_ALL}"
23
21
 
24
22
 
@@ -40,7 +40,7 @@ def requires(*requirements):
40
40
  {
41
41
  "module": "cwms",
42
42
  "package": "cwms-python",
43
- "version": "0.8.0",
43
+ "version": "1.0.3",
44
44
  "desc": "CWMS REST API Python client",
45
45
  "link": "https://github.com/hydrologicengineeringcenter/cwms-python"
46
46
  },
@@ -2,7 +2,7 @@
2
2
  name = "cwms-cli"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-cli"
4
4
 
5
- version = "0.3.7"
5
+ version = "0.4.0"
6
6
 
7
7
 
8
8
  packages = [
@@ -18,7 +18,7 @@ authors = ["Eric Novotny <eric.v.novotny@usace.army.mil>", "Charles Graham <char
18
18
  python = "^3.9"
19
19
  click = "^8.1.8"
20
20
  hecdss = { version = ">=0.1.24", optional = true } # Via https://github.com/HydrologicEngineeringCenter/hec-python-library/blob/main/hec/shared.py#L9-10
21
- cwms-python = { version = ">=0.8.0", optional = true}
21
+ cwms-python = { version = ">=1.0.7", optional = true}
22
22
  colorama = "^0.4.6"
23
23
 
24
24
  [tool.poetry.group.dev.dependencies]
@@ -30,6 +30,10 @@ pytest = "^8.3.5"
30
30
  #pytest-cov = "^4.1.0"
31
31
  #pandas-stubs = "^2.2.1.240316"
32
32
  yamlfix = "^1.16.0"
33
+ pandas = "^2.1.3"
34
+ cwms-python = "^1.0.7"
35
+ hecdss = "^0.1.24"
36
+
33
37
 
34
38
  [build-system]
35
39
  requires = ["poetry-core"]
@@ -1,18 +0,0 @@
1
- # Click callbacks for click
2
-
3
-
4
- def csv_to_list(ctx, param, value):
5
- """Accept multiple values either via repeated flags or a single comma-delimited string."""
6
- if value is None:
7
- return None
8
- if isinstance(value, (list, tuple)):
9
- out = []
10
- for v in value:
11
- if isinstance(v, str) and "," in v:
12
- out.extend([p.strip() for p in v.split(",") if p.strip()])
13
- else:
14
- out.append(v)
15
- return tuple(out)
16
- if isinstance(value, str):
17
- return tuple([p.strip() for p in value.split(",") if p.strip()])
18
- return value
File without changes
File without changes
File without changes
File without changes
File without changes