esgpull 0.9.4__tar.gz → 0.9.5__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 (167) hide show
  1. {esgpull-0.9.4 → esgpull-0.9.5}/.github/workflows/ci.yml +4 -0
  2. {esgpull-0.9.4 → esgpull-0.9.5}/PKG-INFO +3 -3
  3. {esgpull-0.9.4 → esgpull-0.9.5}/README.md +1 -1
  4. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/context.py +8 -3
  5. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/esgpull.py +10 -5
  6. esgpull-0.9.5/esgpull/migrations/versions/0.9.5_update_tables.py +28 -0
  7. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/tui.py +11 -0
  8. {esgpull-0.9.4 → esgpull-0.9.5}/pyproject.toml +2 -2
  9. {esgpull-0.9.4 → esgpull-0.9.5}/requirements-dev.lock +20 -20
  10. {esgpull-0.9.4 → esgpull-0.9.5}/requirements.lock +10 -10
  11. esgpull-0.9.5/tests/cli/test_download.py +50 -0
  12. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_context.py +86 -7
  13. {esgpull-0.9.4 → esgpull-0.9.5}/uv.lock +2 -2
  14. {esgpull-0.9.4 → esgpull-0.9.5}/.github/workflows/doc.yml +0 -0
  15. {esgpull-0.9.4 → esgpull-0.9.5}/.github/workflows/pypi-publish.yml +0 -0
  16. {esgpull-0.9.4 → esgpull-0.9.5}/.gitignore +0 -0
  17. {esgpull-0.9.4 → esgpull-0.9.5}/.pre-commit-config.yaml +0 -0
  18. {esgpull-0.9.4 → esgpull-0.9.5}/CITATION.cff +0 -0
  19. {esgpull-0.9.4 → esgpull-0.9.5}/LICENSE +0 -0
  20. {esgpull-0.9.4 → esgpull-0.9.5}/alembic.ini +0 -0
  21. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/configuration.md +0 -0
  22. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/download.md +0 -0
  23. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/glossary.md +0 -0
  24. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_1.svg +0 -0
  25. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_2.svg +0 -0
  26. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_3.svg +0 -0
  27. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_4.svg +0 -0
  28. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_5.svg +0 -0
  29. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/download_6.svg +0 -0
  30. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_1.svg +0 -0
  31. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_2.svg +0 -0
  32. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_3.svg +0 -0
  33. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_4.svg +0 -0
  34. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_5.svg +0 -0
  35. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/intro_6.svg +0 -0
  36. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/quickstart_1.svg +0 -0
  37. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_1.svg +0 -0
  38. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_2.svg +0 -0
  39. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_3.svg +0 -0
  40. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_4.svg +0 -0
  41. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_5.svg +0 -0
  42. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_6.svg +0 -0
  43. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_7.svg +0 -0
  44. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/images/search_ignore.svg +0 -0
  45. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/index.md +0 -0
  46. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/installation.md +0 -0
  47. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/plugins.md +0 -0
  48. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/queries.md +0 -0
  49. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/quickstart.md +0 -0
  50. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/search.md +0 -0
  51. {esgpull-0.9.4 → esgpull-0.9.5}/docs/docs/stylesheets/extra.css +0 -0
  52. {esgpull-0.9.4 → esgpull-0.9.5}/docs/includes/abbreviations.md +0 -0
  53. {esgpull-0.9.4 → esgpull-0.9.5}/docs/mkdocs.yml +0 -0
  54. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/__init__.py +0 -0
  55. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/__init__.py +0 -0
  56. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/add.py +0 -0
  57. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/autoremove.py +0 -0
  58. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/config.py +0 -0
  59. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/convert.py +0 -0
  60. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/decorators.py +0 -0
  61. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/download.py +0 -0
  62. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/facet.py +0 -0
  63. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/get.py +0 -0
  64. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/index_nodes.py +0 -0
  65. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/install.py +0 -0
  66. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/plugins.py +0 -0
  67. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/remove.py +0 -0
  68. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/retry.py +0 -0
  69. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/search.py +0 -0
  70. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/self.py +0 -0
  71. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/show.py +0 -0
  72. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/status.py +0 -0
  73. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/track.py +0 -0
  74. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/update.py +0 -0
  75. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/cli/utils.py +0 -0
  76. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/config.py +0 -0
  77. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/constants.py +0 -0
  78. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/database.py +0 -0
  79. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/download.py +0 -0
  80. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/exceptions.py +0 -0
  81. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/fs.py +0 -0
  82. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/graph.py +0 -0
  83. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/install_config.py +0 -0
  84. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/README +0 -0
  85. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/env.py +0 -0
  86. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/script.py.mako +0 -0
  87. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.0_update_tables.py +0 -0
  88. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.1_update_tables.py +0 -0
  89. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.2_update_tables.py +0 -0
  90. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.3_update_tables.py +0 -0
  91. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.4_update_tables.py +0 -0
  92. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.5_update_tables.py +0 -0
  93. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.6_update_tables.py +0 -0
  94. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.7_update_tables.py +0 -0
  95. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.3.8_update_tables.py +0 -0
  96. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.4.0_update_tables.py +0 -0
  97. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.0_update_tables.py +0 -0
  98. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.1_update_tables.py +0 -0
  99. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.2_update_tables.py +0 -0
  100. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.3_update_tables.py +0 -0
  101. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.4_update_tables.py +0 -0
  102. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.5.5_update_tables.py +0 -0
  103. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.0_update_tables.py +0 -0
  104. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.1_update_tables.py +0 -0
  105. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.2_update_tables.py +0 -0
  106. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.3_update_tables.py +0 -0
  107. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.4_update_tables.py +0 -0
  108. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.6.5_update_tables.py +0 -0
  109. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.7.0_update_tables.py +0 -0
  110. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.7.1_update_tables.py +0 -0
  111. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.7.2_update_tables.py +0 -0
  112. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.7.3_update_tables.py +0 -0
  113. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.8.0_update_tables.py +0 -0
  114. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.9.0_update_tables.py +0 -0
  115. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.9.1_update_tables.py +0 -0
  116. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.9.2_update_tables.py +0 -0
  117. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.9.3_update_tables.py +0 -0
  118. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/0.9.4_update_tables.py +0 -0
  119. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/14c72daea083_query_add_column_updated_at.py +0 -0
  120. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/c7c8541fa741_query_add_column_added_at.py +0 -0
  121. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/d14f179e553c_file_add_composite_index_dataset_id_.py +0 -0
  122. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/migrations/versions/e7edab5d4e4b_add_dataset_tracking.py +0 -0
  123. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/__init__.py +0 -0
  124. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/base.py +0 -0
  125. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/dataset.py +0 -0
  126. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/facet.py +0 -0
  127. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/file.py +0 -0
  128. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/options.py +0 -0
  129. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/query.py +0 -0
  130. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/selection.py +0 -0
  131. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/sql.py +0 -0
  132. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/synda_file.py +0 -0
  133. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/tag.py +0 -0
  134. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/models/utils.py +0 -0
  135. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/plugin.py +0 -0
  136. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/processor.py +0 -0
  137. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/py.typed +0 -0
  138. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/result.py +0 -0
  139. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/utils.py +0 -0
  140. {esgpull-0.9.4 → esgpull-0.9.5}/esgpull/version.py +0 -0
  141. {esgpull-0.9.4 → esgpull-0.9.5}/pdm.lock +0 -0
  142. {esgpull-0.9.4 → esgpull-0.9.5}/tests/__init__.py +0 -0
  143. {esgpull-0.9.4 → esgpull-0.9.5}/tests/assets/error_plugin.py +0 -0
  144. {esgpull-0.9.4 → esgpull-0.9.5}/tests/assets/incompatible_plugin.py +0 -0
  145. {esgpull-0.9.4 → esgpull-0.9.5}/tests/assets/priority_test_plugin.py +0 -0
  146. {esgpull-0.9.4 → esgpull-0.9.5}/tests/assets/sample_plugin.py +0 -0
  147. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/__init__.py +0 -0
  148. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_dataset.py +0 -0
  149. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_parse.py +0 -0
  150. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_plugin_cli.py +0 -0
  151. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_remove.py +0 -0
  152. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_show_dates.py +0 -0
  153. {esgpull-0.9.4 → esgpull-0.9.5}/tests/cli/test_update.py +0 -0
  154. {esgpull-0.9.4 → esgpull-0.9.5}/tests/conftest.py +0 -0
  155. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_config.py +0 -0
  156. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_dataset.py +0 -0
  157. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_db.py +0 -0
  158. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_esgpull.py +0 -0
  159. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_fs.py +0 -0
  160. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_graph.py +0 -0
  161. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_plugin.py +0 -0
  162. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_processor.py +0 -0
  163. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_query.py +0 -0
  164. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_selection.py +0 -0
  165. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_synda.py +0 -0
  166. {esgpull-0.9.4 → esgpull-0.9.5}/tests/test_utils.py +0 -0
  167. {esgpull-0.9.4 → esgpull-0.9.5}/tests/utils.py +0 -0
@@ -9,6 +9,8 @@ on:
9
9
  - esgpull/**
10
10
  - tests/**
11
11
  - .github/workflows/ci.yml
12
+ - pyproject.toml
13
+ - uv.lock
12
14
  pull_request:
13
15
  branches:
14
16
  - main
@@ -17,6 +19,8 @@ on:
17
19
  - esgpull/**
18
20
  - tests/**
19
21
  - .github/workflows/ci.yml
22
+ - pyproject.toml
23
+ - uv.lock
20
24
 
21
25
  jobs:
22
26
  tests:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: esgpull
3
- Version: 0.9.4
3
+ Version: 0.9.5
4
4
  Summary: ESGF data discovery, download, replication tool
5
5
  Project-URL: Repository, https://github.com/ESGF/esgf-download
6
6
  Project-URL: Documentation, https://esgf.github.io/esgf-download/
@@ -31,13 +31,13 @@ Requires-Dist: pyparsing>=3.0.9
31
31
  Requires-Dist: pyyaml>=6.0
32
32
  Requires-Dist: rich>=12.6.0
33
33
  Requires-Dist: setuptools>=65.4.1
34
- Requires-Dist: sqlalchemy>=2.0.0b2
34
+ Requires-Dist: sqlalchemy<2.1,>=2.0.0b2
35
35
  Requires-Dist: tomlkit>=0.11.5
36
36
  Description-Content-Type: text/markdown
37
37
 
38
38
  # esgpull - ESGF data management utility
39
39
 
40
- [![Rye](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/rye/main/artwork/badge.json)](https://rye.astral.sh)
40
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
41
41
 
42
42
  `esgpull` is a tool that simplifies usage of the [ESGF Search API](https://esgf.github.io/esg-search/ESGF_Search_RESTful_API.html) for data discovery, and manages procedures related to downloading and storing files from ESGF.
43
43
 
@@ -1,6 +1,6 @@
1
1
  # esgpull - ESGF data management utility
2
2
 
3
- [![Rye](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/rye/main/artwork/badge.json)](https://rye.astral.sh)
3
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
4
4
 
5
5
  `esgpull` is a tool that simplifies usage of the [ESGF Search API](https://esgf.github.io/esg-search/ESGF_Search_RESTful_API.html) for data discovery, and manages procedures related to downloading and storing files from ESGF.
6
6
 
@@ -590,6 +590,7 @@ class Context:
590
590
  ) -> list[File]:
591
591
  files: list[File] = []
592
592
  shas: set[str] = set()
593
+ file_ids: set[str] = set()
593
594
  async for result in self._fetch(*results):
594
595
  files_result = result.to(ResultFiles)
595
596
  files_result.process()
@@ -597,9 +598,13 @@ class Context:
597
598
  for file in files_result.data:
598
599
  if not keep_duplicates and file.sha in shas:
599
600
  logger.debug(f"Duplicate file {file.file_id}")
600
- else:
601
- files.append(file)
602
- shas.add(file.sha)
601
+ continue
602
+ if not keep_duplicates and file.file_id in file_ids:
603
+ logger.debug(f"Duplicate file_id {file.file_id}")
604
+ continue
605
+ files.append(file)
606
+ shas.add(file.sha)
607
+ file_ids.add(file.file_id)
603
608
  return files
604
609
 
605
610
  async def _search_as_queries(
@@ -54,7 +54,7 @@ from esgpull.plugin import (
54
54
  )
55
55
  from esgpull.processor import Processor
56
56
  from esgpull.result import Err, Ok, Result
57
- from esgpull.tui import UI, DummyLive, Verbosity, logger
57
+ from esgpull.tui import UI, DummyLive, ErrorCountColumn, Verbosity, logger
58
58
  from esgpull.utils import format_size
59
59
 
60
60
 
@@ -387,6 +387,7 @@ class Esgpull:
387
387
  SpinnerColumn(),
388
388
  MofNCompleteColumn(),
389
389
  TimeRemainingColumn(compact=True, elapsed_when_finished=True),
390
+ ErrorCountColumn(),
390
391
  )
391
392
  file_columns: list[str | ProgressColumn] = [
392
393
  TextColumn("[cyan][{task.id}] [b blue]{task.fields[sha]}"),
@@ -434,7 +435,9 @@ class Esgpull:
434
435
  if use_db:
435
436
  self.db.add(*processor.files)
436
437
  queue_size = len(processor.tasks)
437
- main_task_id = main_progress.add_task("", total=queue_size)
438
+ main_task_id = main_progress.add_task(
439
+ "", total=queue_size, nb_errors=0
440
+ )
438
441
  # TODO: rename ? installed/downloaded/completed/...
439
442
  files: list[File] = []
440
443
  errors: list[Err] = []
@@ -475,11 +478,13 @@ class Esgpull:
475
478
  )
476
479
  case Err(_, err):
477
480
  queue_size -= 1
478
- main_progress.update(
479
- main_task_id, total=queue_size
480
- )
481
481
  result.data.file.status = FileStatus.Error
482
482
  errors.append(result)
483
+ main_progress.update(
484
+ main_task_id,
485
+ total=queue_size,
486
+ nb_errors=len(errors),
487
+ )
483
488
  emit(
484
489
  Event.file_error,
485
490
  file=result.data.file,
@@ -0,0 +1,28 @@
1
+ """update tables
2
+
3
+ Revision ID: 0.9.5
4
+ Revises: 0.9.4
5
+ Create Date: 2026-02-04 15:52:02.010431
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = '0.9.5'
14
+ down_revision = '0.9.4'
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ # ### commands auto generated by Alembic - please adjust! ###
21
+ pass
22
+ # ### end Alembic commands ###
23
+
24
+
25
+ def downgrade() -> None:
26
+ # ### commands auto generated by Alembic - please adjust! ###
27
+ pass
28
+ # ### end Alembic commands ###
@@ -101,6 +101,17 @@ class ExceptionToDebugFilter(logging.Filter):
101
101
  return True
102
102
 
103
103
 
104
+ class ErrorCountColumn(ProgressColumn):
105
+ def render(self, task):
106
+ nb_errors = task.fields.get("nb_errors", 0)
107
+
108
+ if nb_errors == 0:
109
+ return Text("")
110
+
111
+ suffix = "" if nb_errors == 1 else "s"
112
+ return Text(f"({nb_errors} download{suffix} failed)", style="red")
113
+
114
+
104
115
  @define
105
116
  class UI:
106
117
  path: Path = field(converter=Path)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "esgpull"
7
- version = "0.9.4"
7
+ version = "0.9.5"
8
8
  classifiers = [
9
9
  "License :: OSI Approved :: BSD License",
10
10
  "Programming Language :: Python :: 3",
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "pyyaml>=6.0",
28
28
  "tomlkit>=0.11.5",
29
29
  "rich>=12.6.0",
30
- "sqlalchemy>=2.0.0b2",
30
+ "sqlalchemy>=2.0.0b2,<2.1",
31
31
  "setuptools>=65.4.1",
32
32
  "aiostream>=0.4.5",
33
33
  "attrs>=22.1.0",
@@ -14,7 +14,7 @@ aiofiles==25.1.0
14
14
  # via esgpull
15
15
  aiostream==0.7.1
16
16
  # via esgpull
17
- alembic==1.18.0
17
+ alembic==1.18.3
18
18
  # via esgpull
19
19
  annotated-types==0.7.0
20
20
  # via pydantic
@@ -25,7 +25,7 @@ asttokens==3.0.1
25
25
  attrs==25.4.0
26
26
  # via cattrs
27
27
  # via esgpull
28
- babel==2.17.0
28
+ babel==2.18.0
29
29
  # via mkdocs-material
30
30
  backrefs==6.1
31
31
  # via mkdocs-material
@@ -49,11 +49,11 @@ colorama==0.4.6
49
49
  # via mkdocs-material
50
50
  comm==0.2.3
51
51
  # via ipykernel
52
- coverage==7.13.1
52
+ coverage==7.13.3
53
53
  # via pytest-cov
54
- cryptography==46.0.3
54
+ cryptography==46.0.4
55
55
  # via pyopenssl
56
- debugpy==1.8.19
56
+ debugpy==1.8.20
57
57
  # via ipykernel
58
58
  decorator==5.2.1
59
59
  # via ipdb
@@ -73,7 +73,7 @@ filelock==3.20.3
73
73
  # via pytest-mypy
74
74
  ghp-import==2.1.0
75
75
  # via mkdocs
76
- greenlet==3.3.0
76
+ greenlet==3.3.1
77
77
  # via sqlalchemy
78
78
  h11==0.16.0
79
79
  # via httpcore
@@ -107,11 +107,11 @@ jupyter-core==5.9.1
107
107
  # via ipykernel
108
108
  # via jupyter-client
109
109
  # via jupyter-console
110
- librt==0.7.7
110
+ librt==0.7.8
111
111
  # via mypy
112
112
  mako==1.3.10
113
113
  # via alembic
114
- markdown==3.10
114
+ markdown==3.10.1
115
115
  # via mkdocs
116
116
  # via mkdocs-material
117
117
  # via pymdown-extensions
@@ -143,8 +143,8 @@ mypy-extensions==1.1.0
143
143
  nest-asyncio==1.6.0
144
144
  # via esgpull
145
145
  # via ipykernel
146
- orjson==3.11.5
147
- packaging==25.0
146
+ orjson==3.11.7
147
+ packaging==26.0
148
148
  # via esgpull
149
149
  # via ipykernel
150
150
  # via mkdocs
@@ -153,7 +153,7 @@ paginate==0.5.7
153
153
  # via mkdocs-material
154
154
  parso==0.8.5
155
155
  # via jedi
156
- pathspec==1.0.3
156
+ pathspec==1.0.4
157
157
  # via mkdocs
158
158
  # via mypy
159
159
  pexpect==4.9.0
@@ -168,13 +168,13 @@ pluggy==1.6.0
168
168
  prompt-toolkit==3.0.52
169
169
  # via ipython
170
170
  # via jupyter-console
171
- psutil==7.2.1
171
+ psutil==7.2.2
172
172
  # via ipykernel
173
173
  ptyprocess==0.7.0
174
174
  # via pexpect
175
175
  pure-eval==0.2.3
176
176
  # via stack-data
177
- pycparser==2.23
177
+ pycparser==3.0
178
178
  # via cffi
179
179
  pydantic==2.12.5
180
180
  # via esgpull
@@ -189,11 +189,11 @@ pygments==2.19.2
189
189
  # via mkdocs-material
190
190
  # via pytest
191
191
  # via rich
192
- pymdown-extensions==10.20
192
+ pymdown-extensions==10.20.1
193
193
  # via mkdocs-material
194
194
  pyopenssl==25.3.0
195
195
  # via esgpull
196
- pyparsing==3.3.1
196
+ pyparsing==3.3.2
197
197
  # via esgpull
198
198
  pytest==9.0.2
199
199
  # via pytest-cov
@@ -221,13 +221,13 @@ pyzmq==27.1.0
221
221
  # via jupyter-console
222
222
  requests==2.32.5
223
223
  # via mkdocs-material
224
- rich==14.2.0
224
+ rich==14.3.2
225
225
  # via esgpull
226
- setuptools==80.9.0
226
+ setuptools==80.10.2
227
227
  # via esgpull
228
228
  six==1.17.0
229
229
  # via python-dateutil
230
- sqlalchemy==2.0.45
230
+ sqlalchemy==2.0.46
231
231
  # via alembic
232
232
  # via esgpull
233
233
  stack-data==0.6.3
@@ -275,7 +275,7 @@ validators==0.22.0
275
275
  # via click-params
276
276
  watchdog==6.0.0
277
277
  # via mkdocs
278
- wcwidth==0.2.14
278
+ wcwidth==0.5.3
279
279
  # via prompt-toolkit
280
- wrapt==2.0.1
280
+ wrapt==2.1.1
281
281
  # via deprecated
@@ -14,7 +14,7 @@ aiofiles==25.1.0
14
14
  # via esgpull
15
15
  aiostream==0.7.1
16
16
  # via esgpull
17
- alembic==1.18.0
17
+ alembic==1.18.3
18
18
  # via esgpull
19
19
  annotated-types==0.7.0
20
20
  # via pydantic
@@ -35,14 +35,14 @@ click==8.3.1
35
35
  # via esgpull
36
36
  click-params==0.5.0
37
37
  # via esgpull
38
- cryptography==46.0.3
38
+ cryptography==46.0.4
39
39
  # via pyopenssl
40
40
  deprecated==1.3.1
41
41
  # via click-params
42
42
  exceptiongroup==1.3.1
43
43
  # via anyio
44
44
  # via cattrs
45
- greenlet==3.3.0
45
+ greenlet==3.3.1
46
46
  # via sqlalchemy
47
47
  h11==0.16.0
48
48
  # via httpcore
@@ -63,11 +63,11 @@ mdurl==0.1.2
63
63
  # via markdown-it-py
64
64
  nest-asyncio==1.6.0
65
65
  # via esgpull
66
- packaging==25.0
66
+ packaging==26.0
67
67
  # via esgpull
68
68
  platformdirs==4.5.1
69
69
  # via esgpull
70
- pycparser==2.23
70
+ pycparser==3.0
71
71
  # via cffi
72
72
  pydantic==2.12.5
73
73
  # via esgpull
@@ -80,17 +80,17 @@ pygments==2.19.2
80
80
  # via rich
81
81
  pyopenssl==25.3.0
82
82
  # via esgpull
83
- pyparsing==3.3.1
83
+ pyparsing==3.3.2
84
84
  # via esgpull
85
85
  python-dotenv==1.2.1
86
86
  # via pydantic-settings
87
87
  pyyaml==6.0.3
88
88
  # via esgpull
89
- rich==14.2.0
89
+ rich==14.3.2
90
90
  # via esgpull
91
- setuptools==80.9.0
91
+ setuptools==80.10.2
92
92
  # via esgpull
93
- sqlalchemy==2.0.45
93
+ sqlalchemy==2.0.46
94
94
  # via alembic
95
95
  # via esgpull
96
96
  tomli==2.4.0
@@ -114,5 +114,5 @@ typing-inspection==0.4.2
114
114
  # via pydantic-settings
115
115
  validators==0.22.0
116
116
  # via click-params
117
- wrapt==2.0.1
117
+ wrapt==2.1.1
118
118
  # via deprecated
@@ -0,0 +1,50 @@
1
+ from pathlib import Path
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from esgpull import Esgpull
6
+ from esgpull.cli.download import download
7
+ from esgpull.config import Config
8
+ from esgpull.models import File, FileStatus, Query
9
+
10
+
11
+ def test_download_errors_in_progress(root: Path, config: Config):
12
+ # Configure the environment
13
+ config.generate(overwrite=True)
14
+ runner = CliRunner()
15
+ esg = Esgpull(root)
16
+
17
+ # Create a query without updating (no datasets will be created)
18
+ query = Query()
19
+ query.compute_sha()
20
+ query.track(query.options.default())
21
+
22
+ # Manually create files
23
+ failing_file = File(
24
+ file_id="failing_file",
25
+ dataset_id="test_dataset_id",
26
+ master_id="test_master_id",
27
+ url="https://nonexistent.path/to/file.nc",
28
+ version="1.0",
29
+ filename="failing.nc",
30
+ local_path="test/failing",
31
+ data_node="test_node",
32
+ checksum="12345",
33
+ checksum_type="SHA256",
34
+ size=1000,
35
+ status=FileStatus.Queued,
36
+ )
37
+ failing_file.compute_sha()
38
+
39
+ # Add files to query
40
+ query.files.append(failing_file)
41
+
42
+ # Add query to database
43
+ esg.db.add(query)
44
+
45
+ # Run the download instruction in the CLI
46
+ result_download = runner.invoke(download)
47
+
48
+ # Parse the output to extract
49
+ output = result_download.output
50
+ assert "download failed" in output
@@ -304,7 +304,11 @@ def test_hits_never_empty(
304
304
  ("index_node", "exc"),
305
305
  ## TODO: test bridge, but it is super slow
306
306
  [
307
- (IPSL_NODE, does_not_raise()),
307
+ pytest.param(
308
+ IPSL_NODE,
309
+ does_not_raise(),
310
+ marks=pytest.mark.xfail(reason="unstable"),
311
+ ),
308
312
  (CEDA_NODE, does_not_raise()),
309
313
  ("https://github.com", pytest.raises(Exception)),
310
314
  ("not_a_real.url", pytest.raises(Exception)),
@@ -337,7 +341,7 @@ def test_bridge_exact_match_params(ctx):
337
341
 
338
342
 
339
343
  def test_bridge_wildcard_query_param(ctx):
340
- query = Query(selection=dict(source_id='CESM*', variable_id='tas*'))
344
+ query = Query(selection=dict(source_id="CESM*", variable_id="tas*"))
341
345
  result = ctx.prepare_hits(
342
346
  query,
343
347
  file=False,
@@ -348,12 +352,12 @@ def test_bridge_wildcard_query_param(ctx):
348
352
  assert "query" in params
349
353
  assert "source_id" not in params
350
354
  assert "variable_id" not in params
351
- assert 'source_id:CESM*' in params["query"]
352
- assert 'variable_id:tas*' in params["query"]
355
+ assert "source_id:CESM*" in params["query"]
356
+ assert "variable_id:tas*" in params["query"]
353
357
 
354
358
 
355
359
  def test_bridge_mixed_exact_wildcard(ctx):
356
- query = Query(selection=dict(source_id="CESM2", variable_id='tas*'))
360
+ query = Query(selection=dict(source_id="CESM2", variable_id="tas*"))
357
361
  result = ctx.prepare_hits(
358
362
  query,
359
363
  file=False,
@@ -364,7 +368,7 @@ def test_bridge_mixed_exact_wildcard(ctx):
364
368
  assert "source_id" in params
365
369
  assert params["source_id"] == "CESM2"
366
370
  assert "query" in params
367
- assert 'variable_id:tas*' in params["query"]
371
+ assert "variable_id:tas*" in params["query"]
368
372
  assert "variable_id" not in params
369
373
 
370
374
 
@@ -424,6 +428,81 @@ def test_solr_unchanged(ctx):
424
428
  params = dict(result.request.url.params.items())
425
429
 
426
430
  assert "query" in params
427
- assert params['query'] == 'source_id:CESM2 AND variable_id:tas'
431
+ assert params["query"] == "source_id:CESM2 AND variable_id:tas"
428
432
  assert "source_id" not in params
429
433
  assert "variable_id" not in params
434
+
435
+
436
+ def test_files_skips_duplicate_file_ids(ctx):
437
+ """Test that _files skips duplicate file_ids to prevent DB constraint violations."""
438
+ import asyncio
439
+ from unittest.mock import patch, MagicMock
440
+ from esgpull.context import ResultFiles
441
+
442
+ # Create Solr response docs that will be deserialized by File.serialize()
443
+ # Two files with same file_id (dataset.v1.file.nc) but different checksums
444
+ solr_docs = [
445
+ {
446
+ "instance_id": "dataset.v1.file.nc|node1.com",
447
+ "dataset_id": "dataset.v1|node1.com",
448
+ "title": "file.nc",
449
+ "url": "https://node1.com/file.nc|application/netcdf",
450
+ "data_node": "node1.com",
451
+ "checksum": "abc123",
452
+ "checksum_type": "SHA256",
453
+ "size": 1000,
454
+ "directory_format_template_": "%(root)s/v1", # Simple template
455
+ },
456
+ {
457
+ "instance_id": "dataset.v1.file.nc|node2.com",
458
+ "dataset_id": "dataset.v1|node2.com",
459
+ "title": "file.nc",
460
+ "url": "https://node2.com/file.nc|application/netcdf",
461
+ "data_node": "node2.com",
462
+ "checksum": "def456", # Different checksum!
463
+ "checksum_type": "SHA256",
464
+ "size": 1000,
465
+ "directory_format_template_": "%(root)s/v1", # Simple template
466
+ },
467
+ {
468
+ "instance_id": "dataset.v1.other.nc|node1.com",
469
+ "dataset_id": "dataset.v1|node1.com",
470
+ "title": "other.nc",
471
+ "url": "https://node1.com/other.nc|application/netcdf",
472
+ "data_node": "node1.com",
473
+ "checksum": "xyz789",
474
+ "checksum_type": "SHA256",
475
+ "size": 2000,
476
+ "directory_format_template_": "%(root)s/v1", # Simple template
477
+ },
478
+ ]
479
+
480
+ # Create a ResultFiles with proper JSON that will be parsed by process()
481
+ query = Query()
482
+ result = ResultFiles(query=query, file=True)
483
+ result.request = MagicMock()
484
+ result.json = {"response": {"docs": solr_docs}}
485
+
486
+ # Mock _fetch to yield our result
487
+ async def mock_fetch(*results):
488
+ yield result
489
+
490
+ async def run_test():
491
+ with patch.object(ctx, "_fetch", mock_fetch):
492
+ return await ctx._files(result, keep_duplicates=False)
493
+
494
+ result_files = asyncio.run(run_test())
495
+
496
+ # Should only have 2 files (second one with duplicate file_id is skipped)
497
+ assert len(result_files) == 2
498
+
499
+ # Verify the file_ids in the result
500
+ file_ids = {f.file_id for f in result_files}
501
+ assert file_ids == {"dataset.v1.file.nc", "dataset.v1.other.nc"}
502
+
503
+ # The first file with dataset.v1.file.nc should be kept (abc123 checksum)
504
+ duplicate_files = [
505
+ f for f in result_files if f.file_id == "dataset.v1.file.nc"
506
+ ]
507
+ assert len(duplicate_files) == 1
508
+ assert duplicate_files[0].checksum == "abc123"
@@ -379,7 +379,7 @@ wheels = [
379
379
 
380
380
  [[package]]
381
381
  name = "esgpull"
382
- version = "0.9.4"
382
+ version = "0.9.5"
383
383
  source = { editable = "." }
384
384
  dependencies = [
385
385
  { name = "aiofiles" },
@@ -433,7 +433,7 @@ requires-dist = [
433
433
  { name = "pyyaml", specifier = ">=6.0" },
434
434
  { name = "rich", specifier = ">=12.6.0" },
435
435
  { name = "setuptools", specifier = ">=65.4.1" },
436
- { name = "sqlalchemy", specifier = ">=2.0.0b2" },
436
+ { name = "sqlalchemy", specifier = ">=2.0.0b2,<2.1" },
437
437
  { name = "tomlkit", specifier = ">=0.11.5" },
438
438
  ]
439
439
 
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
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
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
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
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
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
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