cwms-cli 0.8.1__tar.gz → 0.9.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 (84) hide show
  1. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/PKG-INFO +2 -1
  2. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/commands_cwms.py +5 -9
  3. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/users.py +36 -8
  4. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/getusgs_cda.py +107 -0
  5. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/deps.py +36 -6
  6. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/pyproject.toml +1 -1
  7. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/LICENSE +0 -0
  8. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/README.md +0 -0
  9. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/__init__.py +0 -0
  10. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/__main__.py +0 -0
  11. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/_generated/__init__.py +0 -0
  12. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/_generated/ownership_data.py +0 -0
  13. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/callbacks/__init__.py +0 -0
  14. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/blob.py +0 -0
  15. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/clob.py +0 -0
  16. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  17. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/README.md +0 -0
  18. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  19. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  20. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/config.py +0 -0
  21. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  22. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  23. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/parser.py +0 -0
  24. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  25. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  26. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  27. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  28. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  29. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  30. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  31. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  32. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  33. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  34. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/transform.py +0 -0
  35. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  36. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  37. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  38. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  39. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  40. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/csv2cwms/writer.py +0 -0
  41. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/env.py +0 -0
  42. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/shef/__init__.py +0 -0
  43. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/shef/import_critfile.py +0 -0
  44. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/shef/import_infile.py +0 -0
  45. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/commands/shef/shef_parameters.csv +0 -0
  46. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/dss/__init__.py +0 -0
  47. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/dss/cli.py +0 -0
  48. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/dss/naming.py +0 -0
  49. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/dss/transfer.py +0 -0
  50. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/README.md +0 -0
  51. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/__init__.py +0 -0
  52. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/__main__.py +0 -0
  53. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/location/location.py +0 -0
  54. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/location/location_ids.py +0 -0
  55. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  56. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/root.py +0 -0
  57. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries.py +0 -0
  58. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries_data.py +0 -0
  59. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  60. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/ownership.py +0 -0
  61. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/requirements.py +0 -0
  62. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/__init__.py +0 -0
  63. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/__main__.py +0 -0
  64. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  65. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  66. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  67. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/__init__.py +0 -0
  68. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/auth.py +0 -0
  69. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/callback_success.html +0 -0
  70. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/click_help.py +0 -0
  71. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/colors.py +0 -0
  72. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/env_store.py +0 -0
  73. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/friendly_errors.py +0 -0
  74. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/interaction.py +0 -0
  75. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/intervals.py +0 -0
  76. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/io.py +0 -0
  77. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/links.py +0 -0
  78. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/logging/__init__.py +0 -0
  79. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/logging/formatters.py +0 -0
  80. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/paths.py +0 -0
  81. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/ssl_errors.py +0 -0
  82. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/update.py +0 -0
  83. {cwms_cli-0.8.1 → cwms_cli-0.9.0}/cwmscli/utils/version.py +0 -0
  84. {cwms_cli-0.8.1 → cwms_cli-0.9.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.8.1
3
+ Version: 0.9.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
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Provides-Extra: dss
20
21
  Requires-Dist: click (>=8.1.8,<9.0.0)
21
22
  Requires-Dist: colorama (>=0.4.6,<0.5.0)
@@ -534,15 +534,13 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
534
534
  @click.group(
535
535
  "blob",
536
536
  help="Manage CWMS Blobs (upload, download, delete, update, list)",
537
- epilog=textwrap.dedent(
538
- """
537
+ epilog=textwrap.dedent("""
539
538
  Example Usage:\n
540
539
  - Store a PDF/image as a CWMS blob with optional description\n
541
540
  - Download a blob by id to your local filesystem\n
542
541
  - Update a blob's name/description/mime-type\n
543
542
  - Bulk list blobs for an office
544
- """
545
- ),
543
+ """),
546
544
  )
547
545
  def blob_group():
548
546
  pass
@@ -755,14 +753,12 @@ def list_cmd(**kwargs):
755
753
  @click.group(
756
754
  "clob",
757
755
  help="Manage CWMS Clobs (upload, download, delete, update, list)",
758
- epilog=textwrap.dedent(
759
- """
756
+ epilog=textwrap.dedent("""
760
757
  Example Usage:\n
761
758
  - Download a clob by id to your local filesystem\n
762
759
  - Update a clob's name/description/mime-type\n
763
760
  - Bulk list clobs for an office
764
- """
765
- ),
761
+ """),
766
762
  )
767
763
  @requires(reqs.cwms)
768
764
  def clob_group():
@@ -1017,7 +1013,7 @@ def users_roles_list_user(user_name, office, api_root, api_key, api_key_loc):
1017
1013
  multiple=True,
1018
1014
  default=None,
1019
1015
  callback=csv_to_list,
1020
- help="enter admin, readonly, readwrite, or individual role name(s) to add. Repeat the option or pass a comma/pipe-separated list.",
1016
+ help="enter admin, readonly, readwrite, batchadmin, or individual role name(s) to add. Repeat the option or pass a comma/pipe-separated list.",
1021
1017
  )
1022
1018
  @requires(reqs.cwms)
1023
1019
  def users_roles_add(office, api_root, api_key, api_key_loc, user_name, roles):
@@ -58,11 +58,45 @@ def _fetch_roles(cwms_module) -> list[str]:
58
58
  _handle_api_error(error, cwms_module)
59
59
 
60
60
 
61
+ # Role shortcut expansions used by `_expand_role_shortcuts` and
62
+ # referenced during validation so expanded roles are accepted even when the
63
+ # CWMS role catalog returned by the API does not include all shortcut values.
64
+ ROLE_SHORTCUTS: dict[str, list[str]] = {
65
+ "admin": [
66
+ "All Users",
67
+ "CWMS Users",
68
+ "TS ID Creator",
69
+ "CWMS User Admins",
70
+ "CWMS PD Users",
71
+ "Data Acquisition Mgr",
72
+ ],
73
+ "readonly": ["All Users", "CWMS Users"],
74
+ "readwrite": ["All Users", "CWMS Users", "TS ID Creator"],
75
+ "batchadmin": [
76
+ "All Users",
77
+ "CWMS Users",
78
+ "TS ID Creator",
79
+ "Data Acquisition Mgr",
80
+ ],
81
+ }
82
+
83
+
61
84
  def _fetch_users(
62
85
  cwms_module, office=None, username_like=None, page_size: int = 5000
63
86
  ) -> list[dict]:
64
87
  users: list[dict] = []
65
88
 
89
+ # If a username_like filter was provided, ensure it is wrapped with
90
+ # leading and trailing wildcards so CDA receives a contains-style query.
91
+ if username_like:
92
+ _ul = str(username_like).strip()
93
+ if _ul:
94
+ if not _ul.startswith("*"):
95
+ _ul = "*" + _ul
96
+ if not _ul.endswith("*"):
97
+ _ul = _ul + "*"
98
+ username_like = _ul
99
+
66
100
  try:
67
101
  response = cwms_module.get_users(
68
102
  office_id=office, username_like=username_like, page_size=page_size
@@ -135,19 +169,13 @@ def _split_roles(
135
169
 
136
170
 
137
171
  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
172
  expanded_roles: list[str] = []
145
173
  for role in roles:
146
174
  key = role.strip().casefold()
147
175
  if key == "all":
148
176
  expanded_roles.append(role) # Keep "all" as is, handled elsewhere
149
- elif key in emap:
150
- expanded_roles.extend(emap[key])
177
+ elif key in ROLE_SHORTCUTS:
178
+ expanded_roles.extend(ROLE_SHORTCUTS[key])
151
179
  else:
152
180
  expanded_roles.append(role)
153
181
  return expanded_roles
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
  from datetime import datetime, timedelta
3
4
  from typing import Optional
4
5
 
@@ -375,6 +376,35 @@ def CWMS_writeData(USGS_ts, USGS_data, USGS_data_method, days_back):
375
376
  office = row["office-id"]
376
377
  values["quality-code"] = 0
377
378
 
379
+ # Resample to TSID interval when needed.
380
+ values = resample_to_tsid_interval(values, ts_id)
381
+
382
+ # Drop rows with missing values produced by resampling
383
+ # (CWMS CDA rejects NaN/Na values in the values array).
384
+ try:
385
+ n_before = len(values)
386
+ values = values.dropna(subset=["value"]).copy()
387
+ n_after = len(values)
388
+ if n_after != n_before:
389
+ logging.info(
390
+ f"Dropped {n_before - n_after} rows with missing values for {ts_id} after resampling"
391
+ )
392
+ except Exception:
393
+ # If anything goes wrong, continue and let the store call fail
394
+ # so the caller sees the original error.
395
+ pass
396
+
397
+ # Ensure quality-code is numeric and has no NaN entries.
398
+ if "quality-code" in values.columns:
399
+ try:
400
+ values["quality-code"] = (
401
+ values["quality-code"].fillna(0).astype(int)
402
+ )
403
+ except Exception:
404
+ values["quality-code"] = values["quality-code"].fillna(
405
+ 0
406
+ )
407
+
378
408
  # write values to CWMS database
379
409
  try:
380
410
  data = cwms.timeseries_df_to_json(
@@ -422,3 +452,80 @@ def CWMS_writeData(USGS_ts, USGS_data, USGS_data_method, days_back):
422
452
  logging.info(
423
453
  f"The following ts_ids errored because multiple method TSID were present for the USGS station. A USGS method TSID needs to be defined in the time series group in CWMS or an incorrect TSID is defined. {mult_ids}"
424
454
  )
455
+
456
+
457
+ def resample_to_tsid_interval(values: pd.DataFrame, ts_id: str) -> pd.DataFrame:
458
+ """Resample the incoming values DataFrame to the interval encoded in ``ts_id``.
459
+
460
+ The DataFrame is expected to have columns ``date-time`` and ``value``. The
461
+ function returns a new DataFrame with the same columns. If the TSID encodes
462
+ an irregular interval (contains '~' or '0') the original DataFrame is
463
+ returned unchanged. Otherwise the function infers the incoming data
464
+ frequency and resamples only when it differs from the TSID interval.
465
+ """
466
+ if values is None or values.empty:
467
+ return values
468
+
469
+ try:
470
+ interval_segment = ts_id.split(".")[3]
471
+ except Exception:
472
+ return values
473
+
474
+ if "~" in interval_segment or interval_segment == "0":
475
+ return values
476
+
477
+ m = re.match(r"^(\d+)(.*)", interval_segment)
478
+ if m:
479
+ interval_number = int(m.group(1))
480
+ interval_type = m.group(2)
481
+ else:
482
+ return values
483
+
484
+ if interval_type == "Minutes":
485
+ target_str = f"{interval_number}min"
486
+ else:
487
+ # use lower-case single-letter codes (pandas prefers lower-case symbols)
488
+ target_str = f"{interval_number}{interval_type[0].lower()}"
489
+
490
+ df = values.copy()
491
+ # Coerce date-time to datetimes; drop rows without a valid timestamp because
492
+ # they cannot be resampled. Sort and deduplicate the index to ensure
493
+ # deterministic resampling behavior.
494
+ df["date-time"] = pd.to_datetime(df["date-time"], errors="coerce")
495
+ df = df.dropna(subset=["date-time"]).copy()
496
+ if df.empty:
497
+ return df
498
+ df = df.set_index("date-time")
499
+ df = df.sort_index()
500
+ # If duplicate timestamps exist, keep the first occurrence (matching prior
501
+ # behavior of using .first() during resample).
502
+ df = df[~df.index.duplicated(keep="first")]
503
+
504
+ current_delta = None
505
+ try:
506
+ inferred = pd.infer_freq(df.index)
507
+ if inferred:
508
+ current_delta = pd.to_timedelta(inferred)
509
+ except Exception:
510
+ current_delta = None
511
+
512
+ if current_delta is None:
513
+ diffs = df.index.to_series().diff().dropna()
514
+ if not diffs.empty:
515
+ current_delta = diffs.median()
516
+
517
+ try:
518
+ target_delta = pd.to_timedelta(target_str)
519
+ except Exception:
520
+ target_delta = None
521
+
522
+ if target_delta is not None and (
523
+ current_delta is None or current_delta != target_delta
524
+ ):
525
+ logging.info(
526
+ f"Resampling {ts_id}: from {current_delta} to {target_delta} (target={target_str})"
527
+ )
528
+ df_resampled = df.resample(target_str).first()
529
+ return df_resampled.reset_index()
530
+
531
+ return df.reset_index()
@@ -1,6 +1,7 @@
1
1
  import importlib
2
2
  import importlib.metadata
3
3
  import os
4
+ from typing import Callable
4
5
 
5
6
  import click
6
7
 
@@ -57,6 +58,15 @@ def requires(*requirements):
57
58
  missing = []
58
59
  version_issues = []
59
60
 
61
+ # choose a version parsing function: prefer packaging, fallback to pkg_resources
62
+ try:
63
+ from packaging.version import parse as _parse_version
64
+ except Exception:
65
+ try:
66
+ from pkg_resources import parse_version as _parse_version
67
+ except Exception:
68
+ _parse_version = None
69
+
60
70
  for req in requirements:
61
71
  mod = req["module"]
62
72
  pkg = req.get("package", mod)
@@ -78,12 +88,32 @@ def requires(*requirements):
78
88
  if min_version:
79
89
  try:
80
90
  actual_version = importlib.metadata.version(pkg)
81
- if actual_version < min_version:
82
- version_issues.append(
83
- f"- python package `{pkg}` version `{actual_version}` found, "
84
- f"but `{min_version}` or higher is required.\n\t"
85
- f"Update the packge to the required minimum version to use this command."
86
- )
91
+ if _parse_version is not None:
92
+ try:
93
+ if _parse_version(actual_version) < _parse_version(
94
+ min_version
95
+ ):
96
+ version_issues.append(
97
+ f"- python package `{pkg}` version `{actual_version}` found, "
98
+ f"but `{min_version}` or higher is required.\n\t"
99
+ f"Update the package to the required minimum version to use this command."
100
+ )
101
+ except Exception:
102
+ # Fall back to string comparison if parsing fails
103
+ if actual_version < min_version:
104
+ version_issues.append(
105
+ f"- python package `{pkg}` version `{actual_version}` found, "
106
+ f"but `{min_version}` or higher is required.\n\t"
107
+ f"Update the package to the required minimum version to use this command."
108
+ )
109
+ else:
110
+ # No parser available — fall back to lexical comparison
111
+ if actual_version < min_version:
112
+ version_issues.append(
113
+ f"- python package `{pkg}` version `{actual_version}` found, "
114
+ f"but `{min_version}` or higher is required.\n\t"
115
+ f"Update the package to the required minimum version to use this command."
116
+ )
87
117
  except importlib.metadata.PackageNotFoundError:
88
118
  version_issues.append(
89
119
  f"- `{pkg}` is installed but version could not be verified"
@@ -2,7 +2,7 @@
2
2
  name = "cwms-cli"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-cli"
4
4
 
5
- version = "0.8.1"
5
+ version = "0.9.0"
6
6
 
7
7
 
8
8
  packages = [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes