fair-cli 0.9.9__tar.gz → 0.10.1__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 (38) hide show
  1. fair_cli-0.10.1/CHANGELOG.md +132 -0
  2. {fair_cli-0.9.9 → fair_cli-0.10.1}/CITATION.cff +2 -2
  3. {fair_cli-0.9.9 → fair_cli-0.10.1}/PKG-INFO +3 -6
  4. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/cli.py +13 -4
  5. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/common.py +1 -1
  6. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/identifiers.py +21 -6
  7. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/register.py +1 -1
  8. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/requests.py +90 -6
  9. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/storage.py +93 -4
  10. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/sync.py +138 -67
  11. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/session.py +45 -34
  12. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/staging.py +4 -6
  13. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/__init__.py +205 -93
  14. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/globbing.py +65 -0
  15. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/utilities.py +13 -3
  16. {fair_cli-0.9.9 → fair_cli-0.10.1}/pyproject.toml +9 -3
  17. fair_cli-0.9.9/CHANGELOG.md +0 -33
  18. {fair_cli-0.9.9 → fair_cli-0.10.1}/LICENSE +0 -0
  19. {fair_cli-0.9.9 → fair_cli-0.10.1}/README.md +0 -0
  20. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/__init__.py +0 -0
  21. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/configuration/__init__.py +0 -0
  22. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/configuration/validation.py +0 -0
  23. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/exceptions.py +0 -0
  24. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/files.txt +0 -0
  25. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/history.py +0 -0
  26. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/logging.py +0 -0
  27. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/__init__.py +0 -0
  28. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/file_formats.json +0 -0
  29. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/file_types.py +0 -0
  30. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/server.py +0 -0
  31. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/versioning.py +0 -0
  32. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/run.py +0 -0
  33. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/__init__.py +0 -0
  34. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/config.jinja +0 -0
  35. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/hist.jinja +0 -0
  36. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/testing.py +0 -0
  37. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/validation.py +0 -0
  38. {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/virtualenv.py +0 -0
@@ -0,0 +1,132 @@
1
+ # 2026-10-05 [v0.10.1](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.10.1)
2
+
3
+ ## Changed behaviour
4
+ - A wildcard entry keeps to one namespace: the one its `use:` names, else the default for its block. It
5
+ used to match names in every namespace, so a `write:` pattern could write a name into a namespace
6
+ other than its own.
7
+ - A wildcard entry gives one entry for each name it matches: for a `read:`, the version its `use:` names,
8
+ else the highest; for a `write:`, the version to be written. It used to give one for every version, and
9
+ to ignore a version on a `read:`. A `read:` pattern that matches nothing is not an error.
10
+ - `fair run` refuses a `read:` of a data product, or of a version of one, that is not in the registry,
11
+ naming the namespace and registry it looked in. Such a read used to pass unchecked, or as version 0.0.0.
12
+ - `fair push` fails if a file cannot be uploaded. It used to warn and carry on, leaving the remote with
13
+ the file's records and no file. A file is now uploaded before its records are written, so a push that
14
+ failed can be run again.
15
+ - `fair pull` checks each file it fetches against the hash the remote registry holds for it, and refuses
16
+ one that differs.
17
+
18
+ ## Added
19
+ - Registering an external object records where its file was fetched from: the `root` and `path` of its
20
+ `register:` entry, as the object's `original_store`, with the hash of the file found there. `fair push`
21
+ takes that record to the remote. A file registered from the machine itself has no such record.
22
+ - `fair push` takes a refusal of an upload address with HTTP 409 to mean that the remote's store already
23
+ holds the file, sends nothing, and carries on. No released registry answers so yet.
24
+
25
+ ## Fixed
26
+ - `fair push` sent no value that was false, leaving the remote to apply its default: an external object
27
+ registered with `primary: false` arrived as primary, and a location with `public: false` as public.
28
+ - A list from a registry stopped at its first 100 rows, so wildcards, staging and version lookups went
29
+ wrong, with no message, in a registry holding more.
30
+ - `fair pull` looked for the version of a `read:` entry in the local registry only, so an entry at the
31
+ default version, for something not yet local, asked the remote for version 0.0.0 and failed.
32
+ - `fair push` held each file whole in memory, `fair pull` more than twice over, and both left a copy of
33
+ it in the temporary directory. Files are now sent and received a block at a time, whatever their
34
+ size, and nothing is left behind.
35
+ - An upload of 2 GiB or more over plain http failed on macOS.
36
+ - An external object could not be registered with a `unique_name` and `alternate_identifier_type` in
37
+ place of an `identifier`: `fair pull` stopped, reporting that the object already existed.
38
+ - `fair identify` looked at only the newest of a file's storage locations, so it could miss the data
39
+ products of a file the registry records in more than one place.
40
+ - `fair run` could not start the script when the path of the data store, which holds the job
41
+ directory, had a space in it.
42
+
43
+ ## Development
44
+ - The Python job of the implementations workflow starts an object store for its remote registry, so
45
+ that its `fair push` uploads the files. It had none, and passed while a failed upload was a warning.
46
+
47
+ # 2026-10-03 [v0.10.0](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.10.0)
48
+
49
+ ## Changed behaviour
50
+ - `fair remote add` takes the remote's token file with `--token FILE`, and prompts for it if omitted.
51
+ The command never worked before: the label was stored as the token path.
52
+ - `fair run --ci` prepares the job directory and prints it without running the script, as documented.
53
+ - `fair init --ci` leaves an existing `.fair/` alone, as `fair init` does; it used to delete it, data
54
+ store included. Use `fair purge` to start afresh.
55
+ - Wildcards: each `*` matches one segment of a name (not `/`), anchored at both ends, in `read:` and
56
+ `write:` alike, matching DataPipeline.jl. `data_product/*` no longer matches `data_product/1/2`.
57
+ - `${{GIT_TAG}}` is the nearest tag in the history of the current commit (`git describe`), not the
58
+ last tag by name; with no such tag it is an error rather than a crash.
59
+ - `run_metadata.shell` now chooses the interpreter the script runs with.
60
+
61
+ ## Fixed
62
+ - `write:` entries with a wildcard were dropped from the working config (#140). The pattern is now kept
63
+ for new names, and existing matches are written as the pattern entry describes them (its `use: version`,
64
+ `file_type`, `description`).
65
+ - `fair push` from a project whose data store was not the local registry's first storage root recorded
66
+ its files on the remote under the pusher's `file://` path (data-registry #229).
67
+ - `fair pull` of a `read:` entry from a remote registry failed.
68
+ - A DOI whose publisher refuses automated requests (e.g. Wiley, HTTP 403) was rejected as an invalid
69
+ identifier. An identifier that resolves is now accepted; one that does not (404) is still refused.
70
+ - `${{DATETIME-<format>}}` always failed with "Failed to parse formatted datetime variable".
71
+ - Substituting a path with backslashes, such as `${{CONFIG_DIR}}` on Windows, failed `fair pull` and `fair run`
72
+ (#267). Variables are now substituted into the configuration's values, so any text is inserted as it is.
73
+ - `fair remote remove` crashed.
74
+ - A ROR or GRID ID the ROR API could not parse (e.g. one containing `!`) crashed with `KeyError`
75
+ instead of being reported as not found.
76
+ - Run from a subdirectory of a project, commands lost configuration changes and left a stale session
77
+ file, so the next command did not start the registry and `fair registry stop` needed `--force`.
78
+ - Staging ignored the configured local registry and always used port 8000.
79
+ - A download that failed with an HTTP error registered the error page as the data.
80
+ - `fair run` could crash on Windows when the model printed a character the console cannot encode.
81
+ - `fair run` in a git repository without the configured remote gave a bare `IndexError`; it now says
82
+ which remote is missing, or reads `run_metadata: remote_repo:` if given.
83
+ - `fair init` in a subdirectory of a FAIR repository named it as `'True'`.
84
+
85
+ ## Development
86
+ - CI ran no tests: an unused dev dependency (`pylama`) crashed `pytest --markers`, leaving the test loop
87
+ empty. It is removed, and a failure to list the tests now fails the job; all 8 jobs run the full suite.
88
+ - numpy is chosen per Python version (>= 2.3 from 3.11), so Python 3.14 gets a wheel, not a source build.
89
+ - Download tests use a local HTTP server rather than github.com.
90
+ - `test_configuration.py::test_local_config_query` is marked `xfail`: it has never passed, and its intent
91
+ is unknown.
92
+
93
+ # 2026-09-22 [v0.9.9](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.9.9)
94
+ - Requires Python >= 3.10, in step with data-registry v1.2.2.
95
+ - `${{RUN_ID}}` in a `write:` name is left for the language API to fill in at `finalise`.
96
+ - ROR/GRID organisation lookups failed after the ROR API moved to its v2 schema.
97
+
98
+ # Between v0.2.3 and v0.9.8
99
+ Not recorded release by release; see the [releases](https://github.com/FAIRDataPipeline/FAIR-CLI/releases)
100
+ and the git log.
101
+ - Fix virtual environment bug in `fair` binaries where `venv` assumes the main executable is `python`.
102
+ - Registration of `author` and `namespace` objects.
103
+ - Allow specification of tag to install registry from during `fair registry install`.
104
+ - Wildcard '*' parsing introduced for data products.
105
+ - Ability to `push` to a registry added.
106
+ - Added `--dirty` option to `fair run` to allow running with uncommitted changes.
107
+ - Added `config.yaml` file validation.
108
+ - Added initialisation from existing registry.
109
+ - Switch to setting port not local URI during initialisation.
110
+ - Added option to specify port on `fair registry start`.
111
+ - Added `cli-config.yaml` file validation during initialisation.
112
+ - Added `fair reset` to list of commands.
113
+ - Add tab completion for staging data products.
114
+
115
+ # 2021-11-17 [v0.2.3](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.3)
116
+ - Move handling of the user `config.yaml` file to a separate class `JobConfiguration`.
117
+ - Added various fixes to improve functionality within Windows.
118
+ - Move registry installation from script execution to internal function which sets up virtual environment etc.
119
+ - Added a test suite to the project.
120
+ - Added additional recognised identifiers for author setup from an organisation GRID and ROR.
121
+
122
+ # 2021-10-06 [v0.2.2](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.2)
123
+ - Update to package metadata for PyPi
124
+
125
+ # 2021-10-06 [v0.2.1](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.1)
126
+ - Automatic starter `config.yaml` generation.
127
+ - Local and Global CLI configurations.
128
+ - Start/stop local registry either explicitly or during synchronisations.
129
+ - Run logs available via git-like interface.
130
+ - Added ability to add/remove files.
131
+ - Repository style handling, acts like another git-like tool per project.
132
+ - Creation of an interface for `fair` using `click`.
@@ -26,7 +26,7 @@ authors:
26
26
  affiliation: University of Glasgow
27
27
  orcid: https://orcid.org/0000-0002-4424-9890
28
28
 
29
- date-released: 2025-04-08
29
+ date-released: 2026-10-05
30
30
  keywords:
31
31
  - FAIR Data Pipeline
32
32
  - FAIR
@@ -36,4 +36,4 @@ license: BSD-2-Clause
36
36
  message: If you use this software, please cite it using these metadata.
37
37
  repository-code: https://github.com/FAIRDataPipeline/FAIR-CLI/
38
38
  title: "The FAIR Data Pipeline command line tool"
39
- version: 0.9.9
39
+ version: 0.10.1
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: fair-cli
3
- Version: 0.9.9
3
+ Version: 0.10.1
4
4
  Summary: Synchronization interface for the SCRC FAIR Data Pipeline registry
5
+ Home-page: https://www.fairdatapipeline.org/
5
6
  License: BSD-2-Clause
6
- License-File: LICENSE
7
7
  Keywords: FAIR Data Pipeline,FAIR,Data Management,Provenance
8
8
  Author: Richard Reeve
9
9
  Author-email: richard.reeve@glasgow.ac.uk
@@ -20,8 +20,6 @@ Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
- Classifier: Programming Language :: Python :: 3.14
24
- Classifier: Programming Language :: Python :: 3.15
25
23
  Classifier: Topic :: Database :: Front-Ends
26
24
  Classifier: Topic :: Scientific/Engineering
27
25
  Provides-Extra: all
@@ -40,7 +38,6 @@ Requires-Dist: simplejson (>=3.17.5,<4.0.0)
40
38
  Requires-Dist: toml (>=0.10.2,<0.11.0)
41
39
  Requires-Dist: validators (>=0.18.2,<0.19.0)
42
40
  Project-URL: Documentation, https://www.fairdatapipeline.org/docs/interface/fdp/
43
- Project-URL: Homepage, https://www.fairdatapipeline.org/
44
41
  Project-URL: Issue Tracker, https://github.com/FAIRDataPipeline/FAIR-CLI/issues
45
42
  Project-URL: Repository, https://github.com/FAIRDataPipeline/FAIR-CLI
46
43
  Description-Content-Type: text/markdown
@@ -616,9 +616,16 @@ def remote(ctx, verbose: bool = False, debug: bool = False):
616
616
 
617
617
 
618
618
  @remote.command()
619
- @click.argument("options", nargs=-1)
619
+ @click.argument("options", nargs=-1, required=True)
620
+ @click.option(
621
+ "--token",
622
+ "token_file",
623
+ help="File containing the API token for the remote registry",
624
+ prompt="Remote API Token File",
625
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
626
+ )
620
627
  @click.option("--debug/--no-debug", help="Run in debug mode", default=False)
621
- def add(options: typing.List[str], debug: bool) -> None:
628
+ def add(options: typing.List[str], token_file: str, debug: bool) -> None:
622
629
  """Add a remote registry URL with option to give it a label if multiple
623
630
  remotes may be used.
624
631
 
@@ -628,13 +635,15 @@ def add(options: typing.List[str], debug: bool) -> None:
628
635
  size 1 or 2 list containing either:
629
636
  - label, url
630
637
  - url
638
+ token_file : str
639
+ file containing the API token for the remote registry
631
640
  """
632
641
  _url = options[1] if len(options) > 1 else options[0]
633
642
  _label = options[0] if len(options) > 1 else "origin"
634
643
 
635
644
  try:
636
645
  with fdp_session.FAIR(os.getcwd(), debug=debug) as fair_session:
637
- fair_session.add_remote(_url, _label)
646
+ fair_session.add_remote(_url, token_file, _label)
638
647
  except fdp_exc.FAIRCLIException as e:
639
648
  e.err_print()
640
649
  if e.level.lower() == "error":
@@ -654,7 +663,7 @@ def remove(label: str, debug: bool) -> None:
654
663
  """
655
664
  try:
656
665
  with fdp_session.FAIR(os.getcwd(), debug=debug) as fair_session:
657
- fair_session.remove_remove(label)
666
+ fair_session.remove_remote(label)
658
667
  except fdp_exc.FAIRCLIException as e:
659
668
  e.err_print()
660
669
  if e.level.lower() == "error":
@@ -19,7 +19,7 @@ Members
19
19
  FAIR_FOLDER - name for FAIR local repository directory
20
20
 
21
21
  Functions
22
- -------
22
+ ---------
23
23
  registry_home - returns the location of the local data registry
24
24
  find_fair_root - returns the closest '.fair' directory in the upper hierarchy
25
25
  find_git_root - returns the closest '.git' directory
@@ -192,10 +192,12 @@ def check_ror(ror: str) -> typing.Dict:
192
192
 
193
193
  def check_grid(grid_id: str) -> typing.Dict:
194
194
  """Checks if valid GRID ID using ROR (https://ror.org/) public api
195
+
195
196
  Parameters
196
197
  ----------
197
198
  grid_id : str
198
199
  GRID ID to be checked
200
+
199
201
  Returns
200
202
  -------
201
203
  typing.Dict
@@ -230,12 +232,16 @@ def _check_generic_ror(id: str) -> typing.Dict:
230
232
  logger.debug(f"{_url} Responded with {_response.status_code}")
231
233
  return _result_dict
232
234
 
233
- if _response.json()["number_of_results"] == 0:
235
+ # A query ROR cannot parse (e.g. one containing "!") still returns 200, with
236
+ # "errors" in place of results
237
+ _items = _response.json().get("items")
238
+ if not _items:
239
+ logger.debug(f"{_url} returned no results: {_response.json().get('errors')}")
234
240
  return _result_dict
235
241
 
236
- _id = _response.json()["items"][0]["id"]
242
+ _id = _items[0]["id"]
237
243
  # The ROR API lists every name of an organisation, marking the one to show as "ror_display"
238
- _names = _response.json()["items"][0]["names"]
244
+ _names = _items[0]["names"]
239
245
  _name = next(
240
246
  (n["value"] for n in _names if "ror_display" in n["types"]),
241
247
  _names[0]["value"],
@@ -251,7 +257,9 @@ def _check_generic_ror(id: str) -> typing.Dict:
251
257
  def check_id_permitted(identifier: str, retries: int = 5) -> bool:
252
258
  """Check a user provided identifier is permitted
253
259
 
254
- This ID is expected to be a valid URL
260
+ This ID is expected to be a valid URL. Permitted means that it
261
+ resolves: the URL, after any redirects, is served, or it redirects to a
262
+ page that refuses access (401/403), as a publisher behind a DOI may.
255
263
 
256
264
  Parameters
257
265
  ----------
@@ -274,9 +282,16 @@ def check_id_permitted(identifier: str, retries: int = 5) -> bool:
274
282
  headers = {}
275
283
  if fake_agent:
276
284
  headers = {"User-Agent": str(UserAgent().chrome)}
277
- requests.get(
285
+ _response = requests.get(
278
286
  identifier, verify=False, allow_redirects=True, headers=headers
279
- ).raise_for_status()
287
+ )
288
+ # Some publishers answer 403 to any automated request whatever
289
+ # the user agent, so a refusal reached through a redirect (e.g.
290
+ # from doi.org) still means the identifier resolved. A 404
291
+ # anywhere is still rejected.
292
+ if _response.history and _response.status_code in (401, 403):
293
+ return True
294
+ _response.raise_for_status()
280
295
  return True
281
296
  except (
282
297
  requests.exceptions.MissingSchema,
@@ -13,7 +13,7 @@ Contents
13
13
  ========
14
14
 
15
15
  Functions
16
- -------
16
+ ---------
17
17
 
18
18
  """
19
19
 
@@ -84,6 +84,44 @@ def local_token(registry_dir: str = None) -> str:
84
84
  return _file_lines[0].strip()
85
85
 
86
86
 
87
+ # The rest of a list, which a registry gives a page at a time, each page with
88
+ # the address of the next. Only the cursor is taken from that address: the
89
+ # rest of it is the registry's own idea of where it is, which behind a proxy
90
+ # need not be where it is reached
91
+ def _later_pages(
92
+ url: str, headers: typing.Dict, params: typing.Dict, page: typing.Dict
93
+ ) -> typing.List:
94
+ _results: typing.List = []
95
+ _status = 200
96
+
97
+ while page.get("next"):
98
+ try:
99
+ _next = urllib.parse.urlparse(page["next"])
100
+ _cursor = urllib.parse.parse_qs(_next.query)["cursor"][0]
101
+ _request = requests.get(
102
+ url, headers=headers, params={**params, "cursor": _cursor}
103
+ )
104
+ _status = _request.status_code
105
+ page = _request.json()
106
+ _results += page["results"]
107
+ except requests.exceptions.ConnectionError as e:
108
+ raise fdp_exc.UnexpectedRegistryServerState(
109
+ f"Failed to make registry API request '{url}'",
110
+ hint="Is this remote correct and the server running?",
111
+ ) from e
112
+ except (
113
+ json.JSONDecodeError,
114
+ simplejson.errors.JSONDecodeError,
115
+ KeyError,
116
+ ) as exc:
117
+ raise fdp_exc.RegistryAPICallError(
118
+ f"Failed to retrieve every page of results from '{url}'",
119
+ error_code=_status,
120
+ ) from exc
121
+
122
+ return _results
123
+
124
+
87
125
  def _access(
88
126
  uri: str,
89
127
  method: str = None,
@@ -180,6 +218,10 @@ def _access(
180
218
  f"Request failed with status code {_request.status_code}: {_info}",
181
219
  error_code=_request.status_code,
182
220
  )
221
+
222
+ if method == "get" and "results" in _json_req:
223
+ _result += _later_pages(_url, _headers, params, _json_req)
224
+
183
225
  return _result
184
226
 
185
227
 
@@ -215,8 +257,10 @@ def post(
215
257
 
216
258
  headers.update({"Content-Type": "application/json"})
217
259
 
260
+ # False and 0 are values, not gaps: left out, the registry would apply
261
+ # its own default, which for 'public' and 'primary_not_supplement' is true
218
262
  for param, value in data.copy().items():
219
- if not value:
263
+ if not value and not isinstance(value, (bool, int, float)):
220
264
  logger.debug(
221
265
  f"Key in post data '{param}' \
222
266
  has no value so will be ignored"
@@ -483,7 +527,13 @@ def put_file(upload_url: str, file_loc: str) -> bool:
483
527
  bool: Will return True if the upload succeeded.
484
528
  """
485
529
  s = requests.Session()
486
- _req = s.put(upload_url, data=open(file_loc, mode="rb").read())
530
+ # Sent from the open file, a block at a time, whatever its size. An empty
531
+ # file is sent as no bytes: given a file with nothing to read, requests
532
+ # would not state a length, which an object store insists on
533
+ with open(file_loc, mode="rb") as _file:
534
+ _req = s.put(
535
+ upload_url, data=_file if os.path.getsize(file_loc) else b""
536
+ )
487
537
  if _req.status_code not in [200, 201]:
488
538
  raise fdp_exc.RegistryError(
489
539
  f"File: {file_loc} could not be uploaded,\
@@ -492,7 +542,7 @@ def put_file(upload_url: str, file_loc: str) -> bool:
492
542
  return True
493
543
 
494
544
 
495
- def download_file(url: str, chunk_size: int = 8192) -> str:
545
+ def download_file(url: str, chunk_size: int = 1024 * 1024) -> str:
496
546
  """Download a file from a given URL
497
547
 
498
548
  Parameters
@@ -500,16 +550,26 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
500
550
  url : str
501
551
  address of remote file
502
552
  chunk_size : int, optional
503
- chunk size for download, by default 8192
553
+ chunk size for download, by default 1 MiB
504
554
 
505
555
  Returns
506
556
  -------
507
557
  str
508
558
  path of downloaded temporary file
559
+
560
+ Raises
561
+ ------
562
+ requests.HTTPError
563
+ if the server answers with an error status
564
+ fdp_exc.FAIRCLIException
565
+ if the file could not be fetched, or not all of it
509
566
  """
510
567
  # Save the data to a temporary file so we can calculate the hash
511
568
  _file = tempfile.NamedTemporaryFile(delete=False)
512
569
  _fname = _file.name
570
+ # Only the name is wanted: the file is written, and on failure removed,
571
+ # by name, which Windows refuses while this handle is open
572
+ _file.close()
513
573
 
514
574
  # Copy File if local
515
575
  if "file://" in url:
@@ -517,6 +577,7 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
517
577
  try:
518
578
  shutil.copy2(_local_fname, _fname)
519
579
  except Exception as e:
580
+ os.remove(_fname)
520
581
  raise fdp_exc.FAIRCLIException(
521
582
  f"Failed to download file '{url}'"
522
583
  f" due to connection error: {traceback.format_exc()}"
@@ -526,14 +587,37 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
526
587
  requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
527
588
  headers = {"User-Agent": str(UserAgent().chrome)}
528
589
  response = requests.get(
529
- url, allow_redirects=True, verify=False, headers=headers
590
+ url,
591
+ allow_redirects=True,
592
+ verify=False,
593
+ headers=headers,
594
+ stream=True,
530
595
  )
531
- open(_fname, mode="wb").write(response.content)
532
596
  except Exception as e:
597
+ os.remove(_fname)
533
598
  raise fdp_exc.FAIRCLIException(
534
599
  f"Failed to download file '{url}'"
535
600
  f" due to connection error: {traceback.format_exc()}"
536
601
  ) from e
602
+ # Outside the try, so that callers can tell an HTTP error from a
603
+ # failure to connect, and an error page is never saved as the data
604
+ try:
605
+ response.raise_for_status()
606
+ # Written as it arrives, a block at a time, whatever its size
607
+ with open(_fname, mode="wb") as _out:
608
+ for _chunk in response.iter_content(chunk_size=chunk_size):
609
+ _out.write(_chunk)
610
+ except requests.HTTPError:
611
+ os.remove(_fname)
612
+ raise
613
+ except requests.RequestException as e:
614
+ os.remove(_fname)
615
+ raise fdp_exc.FAIRCLIException(
616
+ f"Failed to download all of file '{url}'"
617
+ f" due to connection error: {traceback.format_exc()}"
618
+ ) from e
619
+ finally:
620
+ response.close()
537
621
 
538
622
  return _fname
539
623
 
@@ -473,6 +473,13 @@ def store_data_file(
473
473
  registry_uri=uri,
474
474
  registry_token=token,
475
475
  data_product_url=_data_prod_url,
476
+ original_store_url=_get_url_from_original_store(
477
+ data=data,
478
+ registry_uri=uri,
479
+ registry_token=token,
480
+ storage_loc_url=_post_store_loc,
481
+ is_public=public,
482
+ ),
476
483
  )
477
484
 
478
485
 
@@ -493,7 +500,12 @@ def _get_url_from_storage_loc(
493
500
  "hash": _hash,
494
501
  }
495
502
 
496
- _search_data = {"hash": _hash}
503
+ # The same file may be recorded under another root as well, as where an
504
+ # external object was fetched from
505
+ _search_data = {
506
+ "hash": _hash,
507
+ "storage_root": fdp_req.get_obj_id_from_url(root_store_url),
508
+ }
497
509
 
498
510
  return fdp_req.post_else_get(
499
511
  registry_uri,
@@ -504,6 +516,73 @@ def _get_url_from_storage_loc(
504
516
  )
505
517
 
506
518
 
519
+ # The storage location of the place an external object's file was fetched
520
+ # from: the 'root' and 'path' of its 'register' entry, with the hash its
521
+ # location in the data store was given. None if the entry names no place
522
+ # that anyone else could fetch it from: a file on this machine, or (a root
523
+ # that is a bare path) one beside the registry itself.
524
+ def _get_url_from_original_store(
525
+ data: typing.Dict,
526
+ registry_uri: str,
527
+ registry_token: str,
528
+ storage_loc_url: str,
529
+ is_public: bool,
530
+ ) -> typing.Optional[str]:
531
+ _root = data.get("root")
532
+ _path = data.get("path")
533
+
534
+ if not _root or not _path or _root.startswith(("/", "file://")):
535
+ return None
536
+
537
+ _root_url = fdp_req.post_else_get(
538
+ registry_uri,
539
+ "storage_root",
540
+ registry_token,
541
+ data={"root": _root, "local": False},
542
+ params={"root": _root},
543
+ )
544
+ _hash = fdp_req.url_get(storage_loc_url, registry_token)["hash"]
545
+
546
+ # A registry records a file once under a root, whatever its path there
547
+ _recorded = [
548
+ location
549
+ for location in fdp_req.get(
550
+ registry_uri,
551
+ "storage_location",
552
+ registry_token,
553
+ params={
554
+ "hash": _hash,
555
+ "storage_root": fdp_req.get_obj_id_from_url(_root_url),
556
+ },
557
+ )
558
+ if str(location["public"]).lower() == str(is_public).lower()
559
+ ]
560
+
561
+ if _recorded:
562
+ if _recorded[0]["path"] != _path:
563
+ logger.warning(
564
+ "The file at '%s%s' is already recorded at '%s%s', "
565
+ "which is given as where it came from",
566
+ _root,
567
+ _path,
568
+ _root,
569
+ _recorded[0]["path"],
570
+ )
571
+ return _recorded[0]["url"]
572
+
573
+ return fdp_req.post(
574
+ registry_uri,
575
+ "storage_location",
576
+ registry_token,
577
+ data={
578
+ "path": _path,
579
+ "storage_root": _root_url,
580
+ "public": str(is_public).lower(),
581
+ "hash": _hash,
582
+ },
583
+ )["url"]
584
+
585
+
507
586
  def _get_url_from_file_type(
508
587
  data: typing.Dict, local_file: str, registry_uri: str, registry_token: str
509
588
  ) -> str:
@@ -539,6 +618,7 @@ def _get_url_from_external_obj(
539
618
  registry_uri: str,
540
619
  registry_token: str,
541
620
  data_product_url: str,
621
+ original_store_url: str = None,
542
622
  ) -> typing.Dict:
543
623
  _expected_ext_obj_keys = ("release_date", "primary", "title")
544
624
 
@@ -553,6 +633,7 @@ def _get_url_from_external_obj(
553
633
  "title": data["title"],
554
634
  "primary_not_supplement": f'{data["primary"]}',
555
635
  "release_date": data["release_date"],
636
+ "original_store": original_store_url,
556
637
  }
557
638
  _external_obj_data.update(_get_identifier_from_data(data, local_file))
558
639
 
@@ -672,10 +753,10 @@ def _get_identifier_from_data(data: typing.Dict, label: str) -> typing.Dict[str,
672
753
  raise fdp_exc.UserConfigError(
673
754
  f"No identifier/alternate_identifier given for item '{label}'",
674
755
  hint="You must provide either a URL 'identifier', or "
675
- "'unique_name' and 'source_name' keys",
756
+ "'unique_name' and 'alternate_identifier_type' keys",
676
757
  ) from e
677
758
 
678
- _identifier["alternate_identifier"] = data.get(
759
+ _identifier["alternate_identifier_type"] = data.get(
679
760
  "alternate_identifier_type", "local source descriptor"
680
761
  )
681
762
 
@@ -868,6 +949,14 @@ def upload_remote_file(
868
949
  """
869
950
  if not os.path.exists(file_loc):
870
951
  raise fdp_exc.FileNotFoundError(f"File: {file_loc} does not exist")
871
- _upload_url = get_upload_url(file_loc, remote_uri, remote_token)
952
+ try:
953
+ _upload_url = get_upload_url(file_loc, remote_uri, remote_token)
954
+ except fdp_exc.RegistryAPICallError as e:
955
+ # A registry refuses an address to upload a file it already holds,
956
+ # found by its hash: there is then nothing to send
957
+ if e.error_code != 409:
958
+ raise
959
+ logger.debug(f"File {file_loc} is already in the registry's store")
960
+ return
872
961
  logger.debug(f"Uploading {file_loc} to URL: {_upload_url}")
873
962
  fdp_req.put_file(_upload_url, file_loc)