dds-cli 2.13.0__tar.gz → 2.14.2__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 (106) hide show
  1. {dds_cli-2.13.0 → dds_cli-2.14.2}/PKG-INFO +13 -15
  2. {dds_cli-2.13.0 → dds_cli-2.14.2}/README.md +4 -4
  3. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/__init__.py +1 -1
  4. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/__main__.py +81 -82
  5. dds_cli-2.14.2/dds_cli/constants.py +25 -0
  6. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/custom_decorators.py +1 -0
  7. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/data_getter.py +68 -25
  8. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/data_lister.py +21 -24
  9. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/data_putter.py +18 -12
  10. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/file_compressor.py +5 -7
  11. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/file_encryptor.py +1 -1
  12. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/file_handler_local.py +22 -25
  13. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/file_handler_remote.py +2 -4
  14. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/project_info.py +1 -1
  15. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/project_status.py +12 -3
  16. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/user.py +4 -4
  17. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/utils.py +4 -6
  18. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/version.py +1 -1
  19. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/PKG-INFO +13 -15
  20. dds_cli-2.14.2/dds_cli.egg-info/SOURCES.txt +62 -0
  21. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/requires.txt +9 -8
  22. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/top_level.txt +0 -1
  23. dds_cli-2.14.2/pyproject.toml +16 -0
  24. {dds_cli-2.13.0 → dds_cli-2.14.2}/setup.py +5 -8
  25. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_auth.py +18 -16
  26. dds_cli-2.14.2/tests/test_commands.py +185 -0
  27. dds_cli-2.14.2/tests/test_data_getter.py +314 -0
  28. dds_cli-2.14.2/tests/test_data_putter.py +85 -0
  29. dds_cli-2.14.2/tests/test_decorators.py +129 -0
  30. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_file_handler_local.py +118 -0
  31. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_project_status.py +133 -0
  32. dds_cli-2.13.0/dds_cli/constants.py +0 -14
  33. dds_cli-2.13.0/dds_cli/dds_gui/__init__.py +0 -6
  34. dds_cli-2.13.0/dds_cli/dds_gui/app.py +0 -71
  35. dds_cli-2.13.0/dds_cli/dds_gui/components/__init__.py +0 -1
  36. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_button.py +0 -65
  37. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_container.py +0 -80
  38. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_footer.py +0 -17
  39. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_form.py +0 -27
  40. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_input.py +0 -19
  41. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_modal.py +0 -138
  42. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_select.py +0 -23
  43. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_status_chip.py +0 -61
  44. dds_cli-2.13.0/dds_cli/dds_gui/components/dds_text_item.py +0 -17
  45. dds_cli-2.13.0/dds_cli/dds_gui/dds_state_manager.py +0 -165
  46. dds_cli-2.13.0/dds_cli/dds_gui/pages/__init__.py +0 -1
  47. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/__init__.py +0 -1
  48. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/authentication.py +0 -56
  49. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/authentication_form.py +0 -138
  50. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/modals/__init__.py +0 -1
  51. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/modals/login_modal.py +0 -35
  52. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/modals/logout_modal.py +0 -28
  53. dds_cli-2.13.0/dds_cli/dds_gui/pages/authentication/modals/reauthenticate_modal.py +0 -35
  54. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view.py +0 -69
  55. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view_mode/__init__.py +0 -1
  56. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view_mode/project_actions.py +0 -32
  57. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view_mode/project_actions_tabs/__init__.py +0 -1
  58. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view_mode/project_actions_tabs/download_data.py +0 -61
  59. dds_cli-2.13.0/dds_cli/dds_gui/pages/project_view_mode/project_actions_tabs/user_access.py +0 -41
  60. dds_cli-2.13.0/dds_cli/dds_gui/types/__init__.py +0 -1
  61. dds_cli-2.13.0/dds_cli/dds_gui/types/dds_severity_types.py +0 -12
  62. dds_cli-2.13.0/dds_cli/dds_gui/types/dds_status_types.py +0 -14
  63. dds_cli-2.13.0/dds_cli.egg-info/SOURCES.txt +0 -97
  64. dds_cli-2.13.0/gui_build/gui_standalone.py +0 -11
  65. dds_cli-2.13.0/pyproject.toml +0 -3
  66. dds_cli-2.13.0/tests/__init__.py +0 -0
  67. dds_cli-2.13.0/tests/gui_tests/__init__.py +0 -0
  68. dds_cli-2.13.0/tests/gui_tests/test_authentication.py +0 -1567
  69. dds_cli-2.13.0/tests/gui_tests/test_important_information.py +0 -458
  70. dds_cli-2.13.0/tests/gui_tests/test_project_content.py +0 -1000
  71. dds_cli-2.13.0/tests/gui_tests/test_project_information.py +0 -650
  72. dds_cli-2.13.0/tests/gui_tests/test_project_list.py +0 -558
  73. dds_cli-2.13.0/tests/test_data_getter.py +0 -133
  74. {dds_cli-2.13.0 → dds_cli-2.14.2}/LICENSE +0 -0
  75. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/account_manager.py +0 -0
  76. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/auth.py +0 -0
  77. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/base.py +0 -0
  78. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/data_remover.py +0 -0
  79. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/directory.py +0 -0
  80. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/exceptions.py +0 -0
  81. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/file_handler.py +0 -0
  82. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/message_helper.py +0 -0
  83. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/motd_manager.py +0 -0
  84. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/options.py +0 -0
  85. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/project_creator.py +0 -0
  86. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/s3_connector.py +0 -0
  87. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/status.py +0 -0
  88. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/superadmin_helper.py +0 -0
  89. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/text_handler.py +0 -0
  90. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/timestamp.py +0 -0
  91. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli/unit_manager.py +0 -0
  92. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/dependency_links.txt +0 -0
  93. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/entry_points.txt +0 -0
  94. {dds_cli-2.13.0 → dds_cli-2.14.2}/dds_cli.egg-info/not-zip-safe +0 -0
  95. {dds_cli-2.13.0 → dds_cli-2.14.2}/setup.cfg +0 -0
  96. {dds_cli-2.13.0/gui_build → dds_cli-2.14.2/tests}/__init__.py +0 -0
  97. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_account_manager.py +0 -0
  98. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_base.py +0 -0
  99. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_data_remover.py +0 -0
  100. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_file_compressor.py +0 -0
  101. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_file_encryptor.py +0 -0
  102. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_motd_manager.py +0 -0
  103. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_s3_connector.py +0 -0
  104. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_superadmin_helper.py +0 -0
  105. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_user.py +0 -0
  106. {dds_cli-2.13.0 → dds_cli-2.14.2}/tests/test_utils.py +0 -0
@@ -1,39 +1,37 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dds_cli
3
- Version: 2.13.0
3
+ Version: 2.14.2
4
4
  Summary: A command line tool to manage data and projects in the SciLifeLab Data Delivery System.
5
5
  Home-page: https://github.com/ScilifelabDataCentre/dds_cli
6
6
  Author: SciLifeLab Data Centre
7
7
  License: MIT
8
8
  Classifier: Development Status :: 4 - Beta
9
9
  Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3.8
11
- Classifier: Programming Language :: Python :: 3.9
12
10
  Classifier: Programming Language :: Python :: 3.10
13
11
  Classifier: Programming Language :: Python :: 3.11
14
12
  Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
15
14
  Description-Content-Type: text/markdown
16
15
  License-File: LICENSE
17
16
  Requires-Dist: boto3==1.24.73
18
17
  Requires-Dist: botocore==1.27.73
19
18
  Requires-Dist: click==8.1.3
20
- Requires-Dist: click-pathlib==2020.3.13.0
21
- Requires-Dist: cryptography==44.0.1
19
+ Requires-Dist: cryptography==46.0.6
22
20
  Requires-Dist: immutabledict==2.2.1
23
21
  Requires-Dist: jwcrypto==1.5.6
24
22
  Requires-Dist: prettytable==3.7.0
25
23
  Requires-Dist: prompt-toolkit==3.0.40
26
- Requires-Dist: PyNaCl==1.5.0
27
- Requires-Dist: pytz==2022.2.1
24
+ Requires-Dist: PyNaCl==1.6.2
25
+ Requires-Dist: pytz==2026.1.post1
28
26
  Requires-Dist: PyYAML==6.0.2
29
- Requires-Dist: questionary==1.10.0
30
- Requires-Dist: requests==2.32.4
31
- Requires-Dist: rich==13.6.0
27
+ Requires-Dist: questionary==2.1.1
28
+ Requires-Dist: requests==2.33.0
29
+ Requires-Dist: rich==14.3.3
32
30
  Requires-Dist: rich-click==1.5.2
33
31
  Requires-Dist: simplejson==3.17.6
34
32
  Requires-Dist: tzlocal==4.2
35
33
  Requires-Dist: zstandard==0.23.0
36
- Requires-Dist: textual==2.1.2
34
+ Requires-Dist: legacy-cgi==2.6.1; python_version >= "3.13"
37
35
  Dynamic: author
38
36
  Dynamic: classifier
39
37
  Dynamic: description
@@ -61,15 +59,15 @@ Dynamic: summary
61
59
  <a href="https://opensource.org/licenses/MIT">
62
60
  <img alt="Licence: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg">
63
61
  </a>
64
- <a href="https://github.com/psf/black">
65
- <img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg">
62
+ <a href="https://github.com/astral-sh/ruff">
63
+ <img alt="Linting: ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
66
64
  </a>
67
65
  <a href="https://prettier.io/">
68
66
  <img alt="Code style: prettier" src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg">
69
67
  </a>
70
68
  <br />
71
- <a href="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-black-cli.yml">
72
- <img alt="Formatter: black" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-black-cli.yml/badge.svg?event=push">
69
+ <a href="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-ruff.yml">
70
+ <img alt="Linting: ruff" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-ruff.yml/badge.svg?event=push">
73
71
  </a>
74
72
  <img alt="CodeQL" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/codeql-cli.yml/badge.svg">
75
73
  <a href="https://codecov.io/github/ScilifelabDataCentre/dds_cli" >
@@ -15,15 +15,15 @@
15
15
  <a href="https://opensource.org/licenses/MIT">
16
16
  <img alt="Licence: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg">
17
17
  </a>
18
- <a href="https://github.com/psf/black">
19
- <img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg">
18
+ <a href="https://github.com/astral-sh/ruff">
19
+ <img alt="Linting: ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
20
20
  </a>
21
21
  <a href="https://prettier.io/">
22
22
  <img alt="Code style: prettier" src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg">
23
23
  </a>
24
24
  <br />
25
- <a href="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-black-cli.yml">
26
- <img alt="Formatter: black" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-black-cli.yml/badge.svg?event=push">
25
+ <a href="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-ruff.yml">
26
+ <img alt="Linting: ruff" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/lint-ruff.yml/badge.svg?event=push">
27
27
  </a>
28
28
  <img alt="CodeQL" src="https://github.com/ScilifelabDataCentre/dds_cli/actions/workflows/codeql-cli.yml/badge.svg">
29
29
  <a href="https://codecov.io/github/ScilifelabDataCentre/dds_cli" >
@@ -1,4 +1,4 @@
1
- # pylint: skip-file
1
+ # ruff: noqa
2
2
  """DDS CLI."""
3
3
 
4
4
  import datetime
@@ -8,61 +8,59 @@
8
8
  import concurrent.futures
9
9
  import itertools
10
10
  import logging
11
+ import pathlib
11
12
  import sys
12
13
 
13
14
  # Installed
14
- import pathlib
15
- import rich_click as click
16
- import click_pathlib
15
+
16
+ import questionary
17
17
  import rich
18
18
  import rich.logging
19
19
  import rich.markup
20
20
  import rich.progress
21
21
  import rich.prompt
22
- import questionary
22
+ import rich_click as click
23
23
 
24
24
  # Own modules
25
25
  import dds_cli
26
26
  import dds_cli.account_manager
27
- import dds_cli.unit_manager
28
- import dds_cli.motd_manager
29
- import dds_cli.superadmin_helper
27
+ import dds_cli.auth
30
28
  import dds_cli.data_getter
31
29
  import dds_cli.data_lister
32
30
  import dds_cli.data_putter
33
31
  import dds_cli.data_remover
34
32
  import dds_cli.directory
33
+ import dds_cli.message_helper
34
+ import dds_cli.motd_manager
35
35
  import dds_cli.project_creator
36
- import dds_cli.auth
37
- import dds_cli.project_status
38
36
  import dds_cli.project_info
37
+ import dds_cli.project_status
38
+ import dds_cli.superadmin_helper
39
+ import dds_cli.unit_manager
39
40
  import dds_cli.user
40
41
  import dds_cli.utils
41
- import dds_cli.message_helper
42
42
  from dds_cli.options import (
43
+ break_on_fail_flag,
43
44
  destination_option,
44
45
  email_arg,
45
46
  email_option,
46
47
  folder_option,
48
+ json_flag,
49
+ nomail_flag,
47
50
  num_threads_option,
48
51
  project_option,
52
+ silent_flag,
53
+ size_flag,
49
54
  sort_projects_option,
50
55
  source_option,
51
56
  source_path_file_option,
52
57
  token_path_option,
53
- username_option,
54
- break_on_fail_flag,
55
- json_flag,
56
- nomail_flag,
57
- silent_flag,
58
- size_flag,
59
58
  tree_flag,
60
59
  usage_flag,
60
+ username_option,
61
61
  users_flag,
62
62
  )
63
63
 
64
- # import dds_cli.dds_gui.app
65
-
66
64
  ####################################################################################################
67
65
  # START LOGGING CONFIG ###################################################### START LOGGING CONFIG #
68
66
  ####################################################################################################
@@ -201,27 +199,11 @@ def dds_main(click_ctx, verbose, force_no_log, log_file, no_prompt, token_path):
201
199
  else:
202
200
  file_handler = dds_cli.utils.setup_logging_to_file(filename=log_file)
203
201
  LOG.addHandler(file_handler)
204
- else:
205
- if force_no_log:
206
- LOG.warning(
207
- "You have chosen to turn off the recommended default logging with the '--force-no-log' option."
208
- )
209
- click_ctx.obj.update({"DEFAULT_LOG": False})
210
-
211
-
212
- ### GUI COMMAND ###
213
-
214
- # TODO: Should totp be passed to the gui?
215
-
216
-
217
- # @dds_main.command(name="gui")
218
- # @click.pass_obj
219
- # def gui(click_ctx):
220
- # """Start the DDS GUI."""
221
- # gui_app = dds_cli.dds_gui.app.DDSApp(token_path=click_ctx.get("TOKEN_PATH"))
222
- # gui_app.title = "SciLifeLab Data Delivery System"
223
- # gui_app.sub_title = "CLI Version: " + dds_cli.__version__
224
- # gui_app.run()
202
+ elif force_no_log:
203
+ LOG.warning(
204
+ "You have chosen to turn off the recommended default logging with the '--force-no-log' option."
205
+ )
206
+ click_ctx.obj.update({"DEFAULT_LOG": False})
225
207
 
226
208
 
227
209
  # ************************************************************************************************ #
@@ -285,29 +267,27 @@ def list_projects_and_contents(
285
267
  projects = lister.list_projects(sort_by=sort, show_all=show_all)
286
268
  if json:
287
269
  dds_cli.utils.console.print_json(data=projects)
288
- else:
289
- # If an interactive terminal, ask user if they want to view files for a project
290
- if sys.stdout.isatty() and not lister.no_prompt:
291
- project_ids = [p["Project ID"] for p in projects]
292
- LOG.info(
293
- "Would you like to view files in a specific project? "
294
- "Leave blank to exit."
295
- )
296
- # Keep asking until we get a valid response
297
- while project not in project_ids:
298
- try:
299
- project = questionary.autocomplete(
300
- "Project ID:",
301
- choices=project_ids,
302
- validate=lambda x: x in project_ids or x == "",
303
- style=dds_cli.dds_questionary_styles,
304
- ).unsafe_ask()
305
- assert project and project != ""
306
-
307
- # If didn't enter anything, convert to None and exit
308
- except (KeyboardInterrupt, AssertionError):
309
- LOG.debug("No project entered, exiting.")
310
- break
270
+ # If an interactive terminal, ask user if they want to view files for a project
271
+ elif sys.stdout.isatty() and not lister.no_prompt:
272
+ project_ids = [p["Project ID"] for p in projects]
273
+ LOG.info(
274
+ "Would you like to view files in a specific project? Leave blank to exit."
275
+ )
276
+ # Keep asking until we get a valid response
277
+ while project not in project_ids:
278
+ try:
279
+ project = questionary.autocomplete(
280
+ "Project ID:",
281
+ choices=project_ids,
282
+ validate=lambda x: x in project_ids or x == "",
283
+ style=dds_cli.dds_questionary_styles,
284
+ ).unsafe_ask()
285
+ assert project and project != ""
286
+
287
+ # If didn't enter anything, convert to None and exit
288
+ except (KeyboardInterrupt, AssertionError):
289
+ LOG.debug("No project entered, exiting.")
290
+ break
311
291
 
312
292
  # List all files in a project if we know a project ID
313
293
  if project:
@@ -383,6 +363,7 @@ def list_projects_and_contents(
383
363
  dds_cli.exceptions.AuthenticationError,
384
364
  dds_cli.exceptions.ApiResponseError,
385
365
  dds_cli.exceptions.ApiRequestError,
366
+ dds_cli.exceptions.DDSCLIException,
386
367
  ) as err:
387
368
  LOG.error(err)
388
369
  sys.exit(1)
@@ -544,13 +525,14 @@ def configure():
544
525
  "Which method would you like to use?", choices=["Email", "Authenticator App", "Cancel"]
545
526
  ).ask()
546
527
 
528
+ auth_method: str | None = None # type hint, initialized
547
529
  if auth_method_choice == "Cancel":
548
530
  LOG.info("Two-factor authentication method not configured.")
549
531
  sys.exit(0)
550
532
  elif auth_method_choice == "Authenticator App":
551
- auth_method: str = "totp"
533
+ auth_method = "totp"
552
534
  elif auth_method_choice == "Email":
553
- auth_method: str = "hotp"
535
+ auth_method = "hotp"
554
536
 
555
537
  with dds_cli.auth.Auth(authenticate=True, force_renew_token=False) as authenticator:
556
538
  authenticator.twofactor(auth_method=auth_method)
@@ -797,15 +779,14 @@ def delete_user(click_ctx, email, self, is_invite):
797
779
  proceed_deletion = rich.prompt.Confirm.ask(
798
780
  f"Delete invitation of {email} to Data Delivery System?"
799
781
  )
782
+ elif self:
783
+ proceed_deletion = rich.prompt.Confirm.ask(
784
+ "Are you sure? Deleted accounts can't be restored!"
785
+ )
800
786
  else:
801
- if self:
802
- proceed_deletion = rich.prompt.Confirm.ask(
803
- "Are you sure? Deleted accounts can't be restored!"
804
- )
805
- else:
806
- proceed_deletion = rich.prompt.Confirm.ask(
807
- f"Delete Data Delivery System user account associated with {email}"
808
- )
787
+ proceed_deletion = rich.prompt.Confirm.ask(
788
+ f"Delete Data Delivery System user account associated with {email}"
789
+ )
809
790
 
810
791
  if proceed_deletion:
811
792
  try:
@@ -885,6 +866,8 @@ def activate_user(click_ctx, email):
885
866
  Super Admins: All users
886
867
  Unit Admins: Unit Admins / Personnel
887
868
  """
869
+ proceed_activation = False # default assignment
870
+
888
871
  if click_ctx.get("NO_PROMPT", False):
889
872
  pass
890
873
  else:
@@ -925,6 +908,8 @@ def deactivate_user(click_ctx, email):
925
908
  Super Admins: All users
926
909
  Unit Admins: Unit Admins / Personnel
927
910
  """
911
+ proceed_deactivation = False # default assignment
912
+
928
913
  if click_ctx.get("NO_PROMPT", False):
929
914
  pass
930
915
  else:
@@ -1166,6 +1151,13 @@ def display_project_status(click_ctx, project, show_history):
1166
1151
  sys.exit(1)
1167
1152
 
1168
1153
 
1154
+ def validate_deadline(_ctx, _param, value):
1155
+ """Validate that the deadline is a positive number of days between 1 and 90."""
1156
+ if value is not None and value not in range(1, 91):
1157
+ raise click.BadParameter("Deadline must be a positive number of days between 1 and 90.")
1158
+ return value
1159
+
1160
+
1169
1161
  # -- dds project status release -- #
1170
1162
  @project_status.command(name="release", no_args_is_help=True)
1171
1163
  # Options
@@ -1174,7 +1166,8 @@ def display_project_status(click_ctx, project, show_history):
1174
1166
  "--deadline",
1175
1167
  required=False,
1176
1168
  type=int,
1177
- help="Deadline in days when releasing a project.",
1169
+ callback=validate_deadline,
1170
+ help="Deadline in days when releasing a project. Must be a positive number of days (maximum 90 days).",
1178
1171
  )
1179
1172
  @nomail_flag(help_message="Do not send e-mail notifications regarding project updates.")
1180
1173
  @click.pass_obj
@@ -1315,7 +1308,8 @@ def delete_project(click_ctx, project: str):
1315
1308
  "--new-deadline",
1316
1309
  required=False,
1317
1310
  type=int,
1318
- help="Number of days to extend the deadline.",
1311
+ callback=validate_deadline,
1312
+ help="Number of days to extend the deadline. Must be a positive number of days (maximum 90 days).",
1319
1313
  )
1320
1314
  @click.pass_obj
1321
1315
  def extend_deadline(click_ctx, project: str, new_deadline: int):
@@ -1631,7 +1625,9 @@ def data_group_command(_):
1631
1625
  "--mount-dir",
1632
1626
  "-md",
1633
1627
  required=False,
1634
- type=click_pathlib.Path(exists=False, file_okay=False, dir_okay=True, resolve_path=True),
1628
+ type=click.Path(
1629
+ exists=False, file_okay=False, dir_okay=True, resolve_path=True, path_type=pathlib.Path
1630
+ ),
1635
1631
  help=(
1636
1632
  "New directory where the files will be mounted before upload "
1637
1633
  "and any error log files will be saved for a specific upload."
@@ -1739,6 +1735,7 @@ def put_data(
1739
1735
  dds_cli.exceptions.ApiRequestError,
1740
1736
  dds_cli.exceptions.NoKeyError,
1741
1737
  dds_cli.exceptions.NoDataError,
1738
+ dds_cli.exceptions.DDSCLIException,
1742
1739
  ) as err:
1743
1740
  LOG.error(err)
1744
1741
  sys.exit(1)
@@ -1753,7 +1750,9 @@ def put_data(
1753
1750
  @source_path_file_option()
1754
1751
  @destination_option(
1755
1752
  help_message="Destination of downloaded data.",
1756
- option_type=click_pathlib.Path(exists=False, file_okay=False, dir_okay=True, resolve_path=True),
1753
+ option_type=click.Path(
1754
+ exists=False, file_okay=False, dir_okay=True, resolve_path=True, path_type=pathlib.Path
1755
+ ),
1757
1756
  )
1758
1757
  # Flags
1759
1758
  @break_on_fail_flag(help_message="Cancel download of all files if one fails.")
@@ -2020,12 +2019,11 @@ def rm_data(click_ctx, project, file, folder, rm_all):
2020
2019
  if rm_all:
2021
2020
  if no_prompt:
2022
2021
  LOG.warning("Deleting all files within project '%s'", project)
2023
- else:
2024
- if not rich.prompt.Confirm.ask(
2025
- f"Are you sure you want to delete all files within project '{project}'?"
2026
- ):
2027
- LOG.info("Probably for the best. Exiting.")
2028
- sys.exit(0)
2022
+ elif not rich.prompt.Confirm.ask(
2023
+ f"Are you sure you want to delete all files within project '{project}'?"
2024
+ ):
2025
+ LOG.info("Probably for the best. Exiting.")
2026
+ sys.exit(0)
2029
2027
 
2030
2028
  try:
2031
2029
  with dds_cli.data_remover.DataRemover(
@@ -2269,6 +2267,7 @@ def get_stats(click_ctx):
2269
2267
  dds_cli.exceptions.AuthenticationError,
2270
2268
  dds_cli.exceptions.ApiResponseError,
2271
2269
  dds_cli.exceptions.ApiRequestError,
2270
+ dds_cli.exceptions.DDSCLIException,
2272
2271
  ) as err:
2273
2272
  LOG.error(err)
2274
2273
  sys.exit(1)
@@ -0,0 +1,25 @@
1
+ """Shared constants used in the DDS CLI.
2
+
3
+ Using this will avoid e.g. circular imports and
4
+ make it easier to maintain.
5
+
6
+ TODO: Move other constants here from __init__.py.
7
+ """
8
+
9
+ # Timeout settings for upload and download
10
+ READ_TIMEOUT = 300
11
+ CONNECT_TIMEOUT = 60
12
+
13
+ # Retry settings for download
14
+ DOWNLOAD_MAX_RETRIES = 5
15
+ DOWNLOAD_BACKOFF_FACTOR = 2
16
+ DOWNLOAD_INITIAL_WAIT = 1 # seconds
17
+
18
+ # Import these constants when using '*'
19
+ __all__ = [
20
+ "READ_TIMEOUT",
21
+ "CONNECT_TIMEOUT",
22
+ "DOWNLOAD_MAX_RETRIES",
23
+ "DOWNLOAD_BACKOFF_FACTOR",
24
+ "DOWNLOAD_INITIAL_WAIT",
25
+ ]
@@ -154,6 +154,7 @@ def removal_spinner(func):
154
154
  SpinnerColumn(spinner_name="dots12", style="white"),
155
155
  console=dds_cli.utils.stderr_console,
156
156
  ) as progress:
157
+ description: str | None = None # type hint, initialized
157
158
  # Determine spinner text
158
159
  if func.__name__ == "remove_all":
159
160
  description = f"Removing all files in project {self.project}"
@@ -7,6 +7,7 @@
7
7
  # Standard library
8
8
  import logging
9
9
  import pathlib
10
+ import time
10
11
 
11
12
  # Installed
12
13
  import requests
@@ -101,8 +102,17 @@ class DataGetter(base.DDSBaseClass):
101
102
 
102
103
  if not self.filehandler.data:
103
104
  if self.temporary_directory and self.temporary_directory.is_dir():
104
- LOG.debug("Deleting temporary folder '%s'.", self.temporary_directory)
105
- dds_cli.utils.delete_folder(self.temporary_directory)
105
+ LOG.debug("Deleting staging directory '%s'.", self.temporary_directory)
106
+ try:
107
+ dds_cli.utils.delete_folder(self.temporary_directory)
108
+ except OSError as err:
109
+ # Folder deletion may fail if log file is still being written to
110
+ # This is not critical - the important thing is to show the error message
111
+ LOG.error(
112
+ "Could not delete staging directory %s: %s",
113
+ self.temporary_directory,
114
+ err,
115
+ )
106
116
  raise dds_cli.exceptions.DownloadError("No files to download.")
107
117
 
108
118
  self.status = self.filehandler.create_download_status_dict()
@@ -234,35 +244,68 @@ class DataGetter(base.DDSBaseClass):
234
244
  error = ""
235
245
  file_local = self.filehandler.data[file]["path_downloaded"]
236
246
  file_remote = self.filehandler.data[file]["url"]
247
+ file_name_in_db = escape(str(self.filehandler.data[file]["name_in_db"]))
237
248
 
238
- try:
239
- with requests.get(
240
- file_remote,
241
- stream=True,
242
- timeout=(constants.CONNECT_TIMEOUT, constants.READ_TIMEOUT),
243
- ) as req:
244
- req.raise_for_status()
245
- with file_local.open(mode="wb") as new_file:
246
- for chunk in req.iter_content(chunk_size=FileSegment.SEGMENT_SIZE_CIPHER):
247
- progress.update(task, advance=len(chunk))
248
- new_file.write(chunk)
249
- except (
249
+ retryable_exceptions = (
250
250
  requests.exceptions.ConnectTimeout,
251
- requests.exceptions.HTTPError,
252
251
  requests.exceptions.ReadTimeout,
253
252
  requests.exceptions.Timeout,
254
253
  requests.exceptions.ConnectionError,
255
- ) as err:
256
- if (
257
- hasattr(err, "response")
258
- and hasattr(err.response, "status_code")
259
- and err.response.status_code == 404
260
- ):
261
- error = "File not found! Please contact support."
262
- else:
254
+ )
255
+
256
+ max_retries = constants.DOWNLOAD_MAX_RETRIES
257
+ backoff_factor = constants.DOWNLOAD_BACKOFF_FACTOR
258
+ wait = constants.DOWNLOAD_INITIAL_WAIT
259
+ retry_messages = []
260
+
261
+ for attempt in range(1, max_retries + 1):
262
+ error = ""
263
+ if attempt > 1:
264
+ progress.reset(task, completed=0)
265
+
266
+ try:
267
+ with requests.get(
268
+ file_remote,
269
+ stream=True,
270
+ timeout=(constants.CONNECT_TIMEOUT, constants.READ_TIMEOUT),
271
+ ) as req:
272
+ req.raise_for_status()
273
+ with file_local.open(mode="wb") as new_file:
274
+ for chunk in req.iter_content(chunk_size=FileSegment.SEGMENT_SIZE_CIPHER):
275
+ progress.update(task, advance=len(chunk))
276
+ new_file.write(chunk)
277
+ except (requests.exceptions.HTTPError, *retryable_exceptions) as err:
278
+ if (
279
+ isinstance(err, requests.exceptions.HTTPError)
280
+ and hasattr(err, "response")
281
+ and hasattr(err.response, "status_code")
282
+ and err.response.status_code == 404
283
+ ):
284
+ error = "File not found! Please contact support."
285
+ break
263
286
  error = str(err)
264
- else:
265
- downloaded = True
287
+ if attempt < max_retries:
288
+ retry_msg = (
289
+ f"Download attempt {attempt}/{max_retries} failed for "
290
+ f"'{file_name_in_db}': {error}. Retrying in {wait}s..."
291
+ )
292
+ retry_messages.append(retry_msg)
293
+ LOG.warning(retry_msg)
294
+ time.sleep(wait)
295
+ wait *= backoff_factor
296
+ else:
297
+ downloaded = True
298
+ break
299
+
300
+ if not downloaded and error:
301
+ if retry_messages:
302
+ error = " | ".join(retry_messages) + f" | Final error: {error}"
303
+ LOG.error(
304
+ "Download failed for '%s' after %d attempts: %s",
305
+ file_name_in_db,
306
+ max_retries,
307
+ error,
308
+ )
266
309
 
267
310
  return downloaded, error
268
311
 
@@ -5,26 +5,24 @@
5
5
  ###############################################################################
6
6
 
7
7
  # Standard library
8
- from dataclasses import dataclass
9
- import logging
10
- from typing import Tuple, Union, List
11
8
  import datetime
9
+ import logging
12
10
  import pathlib
11
+ from dataclasses import dataclass
12
+ from typing import List, Tuple, Union
13
13
 
14
14
  # Installed
15
- from rich.padding import Padding
15
+ import pytz
16
+ import tzlocal
16
17
  from rich.markup import escape
18
+ from rich.padding import Padding
17
19
  from rich.table import Table
18
20
  from rich.tree import Tree
19
- import pytz
20
- import tzlocal
21
21
 
22
22
  # Own modules
23
- from dds_cli import base
24
- from dds_cli import exceptions
25
- import dds_cli.utils
26
- from dds_cli import DDSEndpoint
23
+ from dds_cli import DDSEndpoint, base, exceptions
27
24
  from dds_cli import text_handler as th
25
+ import dds_cli.utils
28
26
 
29
27
 
30
28
  ###############################################################################
@@ -235,7 +233,7 @@ class DataLister(base.DDSBaseClass):
235
233
  error_message="Failed to list the project's directory tree",
236
234
  )
237
235
 
238
- if not "files_folders" in resp_json:
236
+ if "files_folders" not in resp_json:
239
237
  raise exceptions.NoDataError(f"Could not find folder: '{folder}'")
240
238
 
241
239
  sorted_files_folders = sorted(resp_json["files_folders"], key=lambda f: f["name"])
@@ -268,7 +266,11 @@ class DataLister(base.DDSBaseClass):
268
266
  # Get max length of size string
269
267
  max_size = max(
270
268
  (
271
- len(x["size"].split(" ")[0])
269
+ len(
270
+ dds_cli.utils.format_api_response(
271
+ response=x["size"], key="Size", binary=self.binary
272
+ ).split(" ", maxsplit=1)[0]
273
+ )
272
274
  for x in sorted_files_folders
273
275
  if show_size and "size" in x
274
276
  ),
@@ -280,9 +282,12 @@ class DataLister(base.DDSBaseClass):
280
282
  is_folder = item.pop("folder")
281
283
 
282
284
  if not is_folder:
283
- tree.subtrees.append(
284
- (escape(item["name"]), item.get("size") if show_size else None)
285
- )
285
+ formatted_size = None
286
+ if show_size and "size" in item:
287
+ formatted_size = dds_cli.utils.format_api_response(
288
+ response=item["size"], key="Size", binary=self.binary
289
+ )
290
+ tree.subtrees.append((escape(item["name"]), formatted_size))
286
291
  else:
287
292
  subtree, _max_string, _max_size = __construct_file_tree(
288
293
  pathlib.Path(folder, item["name"]).as_posix() if folder else item["name"],
@@ -348,15 +353,7 @@ class DataLister(base.DDSBaseClass):
348
353
  string_len=len(node[0]),
349
354
  max_string_len=max_str - 4 * depth,
350
355
  )
351
- line += f"{tab}{node[1].split()[0]}"
352
-
353
- # Define space between number and size format
354
- tabs_bf_format = th.TextHandler.format_tabs(
355
- string_len=len(node[1].split()[1]),
356
- max_string_len=max_size,
357
- tab_len=2,
358
- )
359
- line += f"{tabs_bf_format}{node[1].split()[1]}"
356
+ line += f"{tab}{node[1].split()[0]} {node[1].split()[1]}"
360
357
  tree.add(line)
361
358
 
362
359
  return tree, tree_length