fair-cli 0.10.0__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 (37) hide show
  1. {fair_cli-0.10.0 → fair_cli-0.10.1}/CHANGELOG.md +46 -0
  2. {fair_cli-0.10.0 → fair_cli-0.10.1}/CITATION.cff +2 -2
  3. {fair_cli-0.10.0 → fair_cli-0.10.1}/PKG-INFO +1 -1
  4. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/requests.py +83 -7
  5. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/storage.py +93 -4
  6. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/sync.py +86 -51
  7. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/session.py +16 -14
  8. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/__init__.py +105 -54
  9. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/globbing.py +41 -0
  10. {fair_cli-0.10.0 → fair_cli-0.10.1}/pyproject.toml +1 -1
  11. {fair_cli-0.10.0 → fair_cli-0.10.1}/LICENSE +0 -0
  12. {fair_cli-0.10.0 → fair_cli-0.10.1}/README.md +0 -0
  13. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/__init__.py +0 -0
  14. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/cli.py +0 -0
  15. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/common.py +0 -0
  16. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/configuration/__init__.py +0 -0
  17. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/configuration/validation.py +0 -0
  18. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/exceptions.py +0 -0
  19. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/files.txt +0 -0
  20. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/history.py +0 -0
  21. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/identifiers.py +0 -0
  22. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/logging.py +0 -0
  23. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/register.py +0 -0
  24. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/__init__.py +0 -0
  25. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/file_formats.json +0 -0
  26. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/file_types.py +0 -0
  27. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/server.py +0 -0
  28. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/versioning.py +0 -0
  29. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/run.py +0 -0
  30. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/staging.py +0 -0
  31. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/__init__.py +0 -0
  32. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/config.jinja +0 -0
  33. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/hist.jinja +0 -0
  34. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/testing.py +0 -0
  35. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/validation.py +0 -0
  36. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/utilities.py +0 -0
  37. {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/virtualenv.py +0 -0
@@ -1,3 +1,49 @@
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
+
1
47
  # 2026-10-03 [v0.10.0](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.10.0)
2
48
 
3
49
  ## Changed behaviour
@@ -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: 2026-10-03
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.10.0
39
+ version: 0.10.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fair-cli
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: Synchronization interface for the SCRC FAIR Data Pipeline registry
5
5
  Home-page: https://www.fairdatapipeline.org/
6
6
  License: BSD-2-Clause
@@ -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,7 +550,7 @@ 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
  -------
@@ -511,10 +561,15 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
511
561
  ------
512
562
  requests.HTTPError
513
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
514
566
  """
515
567
  # Save the data to a temporary file so we can calculate the hash
516
568
  _file = tempfile.NamedTemporaryFile(delete=False)
517
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()
518
573
 
519
574
  # Copy File if local
520
575
  if "file://" in url:
@@ -522,6 +577,7 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
522
577
  try:
523
578
  shutil.copy2(_local_fname, _fname)
524
579
  except Exception as e:
580
+ os.remove(_fname)
525
581
  raise fdp_exc.FAIRCLIException(
526
582
  f"Failed to download file '{url}'"
527
583
  f" due to connection error: {traceback.format_exc()}"
@@ -531,17 +587,37 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
531
587
  requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
532
588
  headers = {"User-Agent": str(UserAgent().chrome)}
533
589
  response = requests.get(
534
- url, allow_redirects=True, verify=False, headers=headers
590
+ url,
591
+ allow_redirects=True,
592
+ verify=False,
593
+ headers=headers,
594
+ stream=True,
535
595
  )
536
596
  except Exception as e:
597
+ os.remove(_fname)
537
598
  raise fdp_exc.FAIRCLIException(
538
599
  f"Failed to download file '{url}'"
539
600
  f" due to connection error: {traceback.format_exc()}"
540
601
  ) from e
541
602
  # Outside the try, so that callers can tell an HTTP error from a
542
603
  # failure to connect, and an error page is never saved as the data
543
- response.raise_for_status()
544
- open(_fname, mode="wb").write(response.content)
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()
545
621
 
546
622
  return _fname
547
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)
@@ -567,6 +567,23 @@ def sync_data_products(
567
567
  if result["external_object"]:
568
568
  result = fdp_req.url_get(result["external_object"], token=origin_token)
569
569
 
570
+ # The file is moved before its records are written, so that a failure
571
+ # leaves no record, and the next attempt does not take this data
572
+ # product for one already there. If local_data_store assume we're
573
+ # syncing from remote to local, else a public file is uploaded to
574
+ # object storage
575
+ if local_data_store:
576
+ logger.debug("Retrieving files from remote registry data storage")
577
+ fetch_data_product(origin_token, local_data_store, _data_product)
578
+ elif _is_public:
579
+ upload_object(
580
+ origin_uri,
581
+ dest_uri,
582
+ dest_token,
583
+ origin_token,
584
+ result_object["url"],
585
+ )
586
+
570
587
  sync_dependency_chain(
571
588
  object_url=result["url"],
572
589
  dest_uri=dest_uri,
@@ -576,18 +593,8 @@ def sync_data_products(
576
593
  local_data_store=local_data_store,
577
594
  public=_is_public,
578
595
  )
579
- # If local_data_store assume we're syncing from remote to local
580
- if local_data_store:
581
- logger.debug("Retrieving files from remote registry data storage")
582
- fetch_data_product(origin_token, local_data_store, _data_product)
583
- # Else going from local to remote
584
- else:
585
- # If the storage location is public upload the files to object storage
586
- if _is_public:
587
- upload_object(
588
- origin_uri, dest_uri, dest_token, origin_token, result_object["url"]
589
- )
590
-
596
+ # Going from local to remote
597
+ if not local_data_store:
591
598
  for origin_component_url in result_object["components"]:
592
599
  origin_input_code_runs = fdp_req.get(
593
600
  origin_uri,
@@ -925,7 +932,10 @@ def sync_code_run(
925
932
  data={"inputs": list(set(inputs)), "outputs": list(set(outputs))},
926
933
  )
927
934
  else:
928
- # Get and sync model config
935
+ # Get and sync model config, the file before its records
936
+ upload_object(
937
+ origin_uri, dest_uri, dest_token, origin_token, code_run["model_config"]
938
+ )
929
939
  sync_dependency_chain(
930
940
  object_url=code_run["model_config"],
931
941
  dest_uri=dest_uri,
@@ -937,9 +947,6 @@ def sync_code_run(
937
947
  _dest_code_run_model_config = get_dest_object_url(
938
948
  code_run["model_config"], dest_uri, dest_token, origin_token
939
949
  )
940
- upload_object(
941
- origin_uri, dest_uri, dest_token, origin_token, code_run["model_config"]
942
- )
943
950
  # If theres a code_repo sync it
944
951
  _dest_code_run_code_repo = None
945
952
  if code_run["code_repo"]:
@@ -954,7 +961,14 @@ def sync_code_run(
954
961
  _dest_code_run_code_repo = get_dest_object_url(
955
962
  code_run["code_repo"], dest_uri, dest_token, origin_token
956
963
  )
957
- # Sync Submision Script
964
+ # Sync Submision Script, the file before its records
965
+ upload_object(
966
+ origin_uri,
967
+ dest_uri,
968
+ dest_token,
969
+ origin_token,
970
+ code_run["submission_script"],
971
+ )
958
972
  sync_dependency_chain(
959
973
  object_url=code_run["submission_script"],
960
974
  dest_uri=dest_uri,
@@ -966,13 +980,6 @@ def sync_code_run(
966
980
  _dest_code_run_submission_script = get_dest_object_url(
967
981
  code_run["submission_script"], dest_uri, dest_token, origin_token
968
982
  )
969
- upload_object(
970
- origin_uri,
971
- dest_uri,
972
- dest_token,
973
- origin_token,
974
- code_run["submission_script"],
975
- )
976
983
 
977
984
  # If the code run is not in the remote registry post the coderun
978
985
  dest_code_run = fdp_req.post(
@@ -1157,18 +1164,10 @@ def fetch_data_product(
1157
1164
  "as there is no physical storage location",
1158
1165
  data_product,
1159
1166
  )
1167
+ return
1160
1168
 
1161
1169
  _storage_loc = fdp_req.url_get(_object["storage_location"], remote_token)
1162
1170
 
1163
- _path = _storage_loc["path"]
1164
- _path = urllib.parse.quote(_path)
1165
- _root = fdp_req.url_get(_storage_loc["storage_root"], remote_token)
1166
-
1167
- _reg_parse = urllib.parse.urlparse(_endpoint)
1168
- _reg_url = f"{_reg_parse.scheme}://{_reg_parse.netloc}"
1169
-
1170
- _downloaded_file = download_from_registry(_reg_url, _root["root"], _path)
1171
-
1172
1171
  _namespace = fdp_req.url_get(data_product["namespace"], remote_token)
1173
1172
 
1174
1173
  if _file_type_url := _object.get("file_type", None):
@@ -1180,15 +1179,36 @@ def fetch_data_product(
1180
1179
  local_data_store, _namespace["name"], data_product["name"]
1181
1180
  )
1182
1181
 
1183
- os.makedirs(_local_dir, exist_ok=True)
1184
-
1185
1182
  _out_file = os.path.join(_local_dir, f'{data_product["version"]}{_file_type}')
1186
1183
 
1187
1184
  if os.path.exists(_out_file):
1188
1185
  logger.debug("File '%s' already exists skipping download", _out_file)
1189
1186
  return
1190
1187
 
1191
- shutil.copy(_downloaded_file, _out_file)
1188
+ _path = _storage_loc["path"]
1189
+ _path = urllib.parse.quote(_path)
1190
+ _root = fdp_req.url_get(_storage_loc["storage_root"], remote_token)
1191
+
1192
+ _reg_parse = urllib.parse.urlparse(_endpoint)
1193
+ _reg_url = f"{_reg_parse.scheme}://{_reg_parse.netloc}"
1194
+
1195
+ _downloaded_file = download_from_registry(_reg_url, _root["root"], _path)
1196
+
1197
+ # A file is kept only if it is the one the registry describes
1198
+ _hash = fdp_store.calculate_file_hash(_downloaded_file)
1199
+ if _hash != _storage_loc["hash"]:
1200
+ os.remove(_downloaded_file)
1201
+ raise fdp_exc.SynchronisationError(
1202
+ f"Data product '{_namespace['name']}:{data_product['name']}"
1203
+ f"@v{data_product['version']}' was not fetched: the file at "
1204
+ f"'{_root['root']}{_path}' has hash '{_hash}', and the registry "
1205
+ f"records '{_storage_loc['hash']}'",
1206
+ error_code=1,
1207
+ )
1208
+
1209
+ os.makedirs(_local_dir, exist_ok=True)
1210
+
1211
+ shutil.move(_downloaded_file, _out_file)
1192
1212
 
1193
1213
 
1194
1214
  def download_from_registry(registry_url: str, root: str, path: str) -> str:
@@ -1249,8 +1269,6 @@ def upload_object(
1249
1269
  """
1250
1270
  Upload a file from the remote registry given the object url.
1251
1271
 
1252
- This function only preduces a warning if the file cannot be uploaded, allowing the file to be uploaded manually afterwards
1253
-
1254
1272
  Parameters
1255
1273
  ----------
1256
1274
  origin_url : str
@@ -1267,8 +1285,12 @@ def upload_object(
1267
1285
  Returns
1268
1286
  -------
1269
1287
  bool
1270
- was the file successfully uploaded
1288
+ whether there was a file to upload
1271
1289
 
1290
+ Raises
1291
+ ------
1292
+ fdp_exc.SynchronisationError
1293
+ if the file was not uploaded
1272
1294
  """
1273
1295
  _object = fdp_req.url_get(object_url, origin_token)
1274
1296
  if not _object["storage_location"]:
@@ -1282,18 +1304,31 @@ def upload_object(
1282
1304
  _object_storage_location_root = fdp_req.url_get(
1283
1305
  _object_storage_location["storage_root"], origin_token
1284
1306
  )
1285
- _file_loc = download_from_registry(
1286
- origin_uri,
1287
- _object_storage_location_root["root"],
1288
- _object_storage_location["path"],
1289
- )
1307
+ # A file on this machine is uploaded from where it is. Any other is
1308
+ # fetched first, to a temporary file which is removed afterwards
1309
+ _is_local = _is_local_root(_object_storage_location_root)
1310
+ if _is_local:
1311
+ _file_loc = (
1312
+ _object_storage_location_root["root"]
1313
+ + _object_storage_location["path"]
1314
+ ).replace("file://", "")
1315
+ else:
1316
+ _file_loc = download_from_registry(
1317
+ origin_uri,
1318
+ _object_storage_location_root["root"],
1319
+ _object_storage_location["path"],
1320
+ )
1290
1321
  try:
1291
1322
  fdp_store.upload_remote_file(_file_loc, dest_uri, dest_token)
1292
- logger.debug(f"File {_file_loc} Uploaded Successfully")
1293
- return True
1294
- except Exception:
1295
- logger.warning(
1296
- f'File upload error: {_object["description"]} was not uploaded to remote registry please upload the file manually'
1297
- )
1323
+ except Exception as e:
1298
1324
  logger.debug(f"{traceback.format_exc()}")
1299
- return False
1325
+ raise fdp_exc.SynchronisationError(
1326
+ f"File '{_file_loc}' ({_object['description']}) was not uploaded "
1327
+ f"to registry '{dest_uri}': {e}",
1328
+ error_code=1,
1329
+ ) from e
1330
+ finally:
1331
+ if not _is_local:
1332
+ os.remove(_file_loc)
1333
+ logger.debug(f"File {_file_loc} Uploaded Successfully")
1334
+ return True
@@ -1378,24 +1378,26 @@ class FAIR:
1378
1378
  raise fdp_exc.FileNotFoundError(f"File: {file_path} does not exist")
1379
1379
  click.echo(f"Information about file: {file_path}")
1380
1380
  _hash = fdp_store.calculate_file_hash(file_path)
1381
- _storage_location = fdp_req.get(
1381
+ _storage_locations = fdp_req.get(
1382
1382
  _api_url, "storage_location", _token, params={"hash": _hash}
1383
1383
  )
1384
- if _storage_location:
1385
- _storage_location = _storage_location[0]
1386
- else:
1384
+ if not _storage_locations:
1387
1385
  click.echo(f"File: {file_path} could not be found on the {_registry}")
1388
1386
  return
1389
- _objects = fdp_req.get(
1390
- _api_url,
1391
- "object",
1392
- _token,
1393
- params={
1394
- "storage_location": fdp_req.get_obj_id_from_url(
1395
- _storage_location["url"]
1396
- )
1397
- },
1398
- )
1387
+ # A file may be recorded in more than one place: where it is stored,
1388
+ # and where an external object was fetched from
1389
+ _objects = []
1390
+ for _storage_location in _storage_locations:
1391
+ _objects += fdp_req.get(
1392
+ _api_url,
1393
+ "object",
1394
+ _token,
1395
+ params={
1396
+ "storage_location": fdp_req.get_obj_id_from_url(
1397
+ _storage_location["url"]
1398
+ )
1399
+ },
1400
+ )
1399
1401
  for _object in _objects:
1400
1402
  if _object["data_products"]:
1401
1403
  for _data_product_url in _object["data_products"]:
@@ -72,24 +72,26 @@ JOB2CLI_MAPPINGS = {
72
72
  "run_metadata.write_data_store": "registries.local.data_store",
73
73
  }
74
74
 
75
+ # The command for each shell is a list of its arguments, so that the path of
76
+ # the script stays one argument when it has a space in it
75
77
  SHELLS: typing.Dict[str, str] = {
76
- "pwsh": {"exec": "pwsh -command \". '{0}'\"", "extension": "ps1"},
77
- "batch": {"exec": "{0}", "extension": "bat"},
78
+ "pwsh": {"exec": ["pwsh", "-command", ". '{0}'"], "extension": "ps1"},
79
+ "batch": {"exec": ["{0}"], "extension": "bat"},
78
80
  "powershell": {
79
- "exec": "powershell -command \". '{0}'\"",
81
+ "exec": ["powershell", "-command", ". '{0}'"],
80
82
  "extension": "ps1",
81
83
  },
82
- "python2": {"exec": "python2 {0}", "extension": "py"},
83
- "python3": {"exec": "python3 {0}", "extension": "py"},
84
- "python": {"exec": "python {0}", "extension": "py"},
85
- "R": {"exec": "R -f {0}", "extension": "R"},
86
- "julia": {"exec": "julia {0}", "extension": "jl"},
84
+ "python2": {"exec": ["python2", "{0}"], "extension": "py"},
85
+ "python3": {"exec": ["python3", "{0}"], "extension": "py"},
86
+ "python": {"exec": ["python", "{0}"], "extension": "py"},
87
+ "R": {"exec": ["R", "-f", "{0}"], "extension": "R"},
88
+ "julia": {"exec": ["julia", "{0}"], "extension": "jl"},
87
89
  "bash": {
88
- "exec": "bash -eo pipefail {0}",
90
+ "exec": ["bash", "-eo", "pipefail", "{0}"],
89
91
  "extension": "sh",
90
92
  },
91
- "java": {"exec": "java {0}", "extension": "java"},
92
- "sh": {"exec": "sh -e {0}", "extension": "sh"},
93
+ "java": {"exec": ["java", "{0}"], "extension": "java"},
94
+ "sh": {"exec": ["sh", "-e", "{0}"], "extension": "sh"},
93
95
  }
94
96
 
95
97
 
@@ -416,14 +418,29 @@ class JobConfiguration(MutableMapping):
416
418
  )
417
419
 
418
420
  _search_key = SEARCH_KEYS[_obj_type]
421
+ _search = {_search_key: block_entry[_obj_type]}
419
422
 
420
423
  try:
424
+ # A data product pattern stands for names in one namespace: the
425
+ # one its entry names, else the default for its block
426
+ if _obj_type == "data_product":
427
+ _namespace = block_entry.get("use", {}).get("namespace") or (
428
+ self.default_input_namespace
429
+ if block_type == "read"
430
+ else self.default_output_namespace
431
+ )
432
+ _search["namespace"] = fdp_reg.convert_key_value_to_id(
433
+ registry_uri, "namespace", _namespace, registry_token
434
+ )
421
435
  _results_local = fdp_req.get(
422
436
  registry_uri,
423
437
  _obj_type,
424
438
  registry_token,
425
- params={_search_key: block_entry[_obj_type]},
439
+ params=_search,
426
440
  )
441
+ except fdp_exc.RegistryError:
442
+ # The registry has no such namespace, so nothing in it to match
443
+ _results_local = []
427
444
  except fdp_exc.RegistryAPICallError as e:
428
445
  raise fdp_exc.UserConfigError(
429
446
  f"Failed to retrieve entries on local registry for {_obj_type}"
@@ -460,6 +477,13 @@ class JobConfiguration(MutableMapping):
460
477
  "version", block_entry.get("version", None)
461
478
  )
462
479
 
480
+ # One entry for each name. A read takes the version its entry
481
+ # names, as a read of a single name does, else the highest; a
482
+ # write is given its version when versions are filled
483
+ _results_local = fdp_glob.get_one_version_per_name(
484
+ _results_local, _version if block_type == "read" else None
485
+ )
486
+
463
487
  _new_entries = fdp_glob.get_data_product_objects(
464
488
  registry_token, _results_local, block_type, _version
465
489
  )
@@ -804,9 +828,15 @@ class JobConfiguration(MutableMapping):
804
828
  else:
805
829
  self._expand_wildcards(self.local_uri, fdp_req.local_token())
806
830
 
831
+ # What 'pull' fetches is on the remote registry, so that is where the
832
+ # version of a read is looked for first
833
+ _remote = (None, None)
834
+ if job_mode == CMD_MODE.PULL and not local:
835
+ _remote = (remote_uri, remote_token)
836
+
807
837
  for block_type in self._block_types:
808
838
  if block_type in self:
809
- self[block_type] = self._fill_versions(block_type)
839
+ self[block_type] = self._fill_versions(block_type, *_remote)
810
840
 
811
841
  if job_mode == CMD_MODE.PULL and "register" in self:
812
842
  self._logger.debug("Fetching registrations")
@@ -1029,7 +1059,9 @@ class JobConfiguration(MutableMapping):
1029
1059
 
1030
1060
  return _new_config
1031
1061
 
1032
- def _fill_versions(self, block_type: str) -> typing.List[typing.Dict]:
1062
+ def _fill_versions(
1063
+ self, block_type: str, remote_uri: str = None, remote_token: str = None
1064
+ ) -> typing.List[typing.Dict]:
1033
1065
  self._logger.debug("Filling version information")
1034
1066
  _entries: typing.List[typing.Dict] = []
1035
1067
 
@@ -1075,45 +1107,62 @@ class JobConfiguration(MutableMapping):
1075
1107
  _new_item["use"]["data_product"] = _name
1076
1108
  _namespace = item["use"]["namespace"]
1077
1109
 
1078
- # If no ID exists for the namespace then this object has not yet
1079
- # been written to the target registry and so a version number
1080
- # cannot be deduced this way
1081
- try:
1082
- _id_namespace = fdp_reg.convert_key_value_to_id(
1083
- self.local_uri,
1084
- "namespace",
1085
- _namespace,
1086
- fdp_req.local_token(),
1087
- )
1088
- if "${{" in _version:
1110
+ # A read to be fetched is looked for where it will come from,
1111
+ # and only then in the local registry
1112
+ _registries = [(self.local_uri, fdp_req.local_token())]
1113
+ if block_type == "read" and remote_uri:
1114
+ _registries.insert(0, (remote_uri, remote_token))
1115
+
1116
+ for _uri, _token in _registries:
1117
+ # If no ID exists for the namespace then this object has not
1118
+ # yet been written to the target registry and so a version
1119
+ # number cannot be deduced this way
1120
+ try:
1121
+ _search = {
1122
+ "name": _name,
1123
+ "namespace": fdp_reg.convert_key_value_to_id(
1124
+ _uri, "namespace", _namespace, _token
1125
+ ),
1126
+ }
1127
+ if "${{" not in _version:
1128
+ _search["version"] = _version
1089
1129
  _results = fdp_req.get(
1090
- self.local_uri,
1091
- "data_product",
1092
- fdp_req.local_token(),
1093
- params={"name": _name, "namespace": _id_namespace},
1130
+ _uri, "data_product", _token, params=_search
1094
1131
  )
1095
- if "LATEST" in _version:
1096
- _version = fdp_ver.get_latest_version(_results)
1097
- else:
1098
- _results = fdp_req.get(
1099
- self.local_uri,
1100
- "data_product",
1101
- fdp_req.local_token(),
1102
- params={
1103
- "name": _name,
1104
- "namespace": _id_namespace,
1105
- "version": _version,
1106
- },
1107
- )
1108
- except fdp_exc.RegistryError:
1109
- _results = []
1132
+ except fdp_exc.RegistryError:
1133
+ _results = []
1134
+
1135
+ if "*" in _name:
1136
+ _results = [
1137
+ result
1138
+ for result in _results
1139
+ if fdp_glob.matches_wildcard(_name, result["name"])
1140
+ ]
1141
+
1142
+ if _results:
1143
+ break
1144
+
1145
+ # A read of one name must be of something. With nothing found,
1146
+ # the latest version would be 0.0.0, and a version given would
1147
+ # stand unchecked
1148
+ if (
1149
+ block_type == "read"
1150
+ and "*" not in _name
1151
+ and "cache" not in item["use"]
1152
+ and not _results
1153
+ ):
1154
+ _wanted = f"version '{_version}'"
1155
+ if "${{" in _version:
1156
+ _wanted = "any version"
1157
+ raise fdp_exc.UserConfigError(
1158
+ f"Cannot read data product '{_name}': {_wanted} of it "
1159
+ f"was not found in namespace '{_namespace}' on registry "
1160
+ + " or ".join(f"'{_uri}'" for _uri, _ in _registries),
1161
+ hint="Has a run written it, or 'fair pull' fetched it?",
1162
+ )
1110
1163
 
1111
- if "*" in _name:
1112
- _results = [
1113
- result
1114
- for result in _results
1115
- if fdp_glob.matches_wildcard(_name, result["name"])
1116
- ]
1164
+ if "LATEST" in _version:
1165
+ _version = fdp_ver.get_latest_version(_results)
1117
1166
 
1118
1167
  try:
1119
1168
  _version = fdp_ver.get_correct_version(
@@ -1353,14 +1402,16 @@ class JobConfiguration(MutableMapping):
1353
1402
  if not self.env:
1354
1403
  raise fdp_exc.InternalError("Command execution environment setup failed")
1355
1404
 
1356
- _exec = SHELLS[self.shell]["exec"].format(self.script)
1405
+ _exec = [arg.format(self.script) for arg in SHELLS[self.shell]["exec"]]
1357
1406
 
1358
- self._logger.debug("Executing command: %s", _exec)
1407
+ self._logger.debug(
1408
+ "Executing command: %s", subprocess.list2cmdline(_exec)
1409
+ )
1359
1410
 
1360
1411
  _log_tail: typing.List[str] = []
1361
1412
 
1362
1413
  _process = subprocess.Popen(
1363
- _exec.split(),
1414
+ _exec,
1364
1415
  stdout=subprocess.PIPE,
1365
1416
  stderr=subprocess.STDOUT,
1366
1417
  universal_newlines=True,
@@ -1392,7 +1443,7 @@ class JobConfiguration(MutableMapping):
1392
1443
  self.close_log()
1393
1444
  self._logger.error(
1394
1445
  "Command '%s' failed with exit code %s, log tail:\n\t%s",
1395
- _exec,
1446
+ subprocess.list2cmdline(_exec),
1396
1447
  _process.returncode,
1397
1448
  "\n\t".join(_log_tail),
1398
1449
  )
@@ -14,9 +14,12 @@ Constants
14
14
  - DISPOSABLES: tuple of keys to be removed before adding to config.yaml
15
15
 
16
16
  """
17
+ import contextlib
17
18
  import re
18
19
  import typing
19
20
 
21
+ import semver
22
+
20
23
  import fair.exceptions as fdp_exc
21
24
  import fair.registry.requests as fdp_req
22
25
  from fair.registry import SEARCH_KEYS
@@ -62,6 +65,44 @@ def matches_wildcard(pattern: str, name: str) -> bool:
62
65
  return re.fullmatch(_regex, name) is not None
63
66
 
64
67
 
68
+ def get_one_version_per_name(
69
+ results_list: typing.List[typing.Dict], version: str = None
70
+ ) -> typing.List[typing.Dict]:
71
+ """Return one of the results of a data product search for each name
72
+
73
+ Parameters
74
+ ----------
75
+ results_list : typing.List[typing.Dict]
76
+ results of registry search for the wildcard
77
+ version : str, optional
78
+ version to take of each name, leaving out a name that has none such;
79
+ by default, or if this is not a version, the highest of each name
80
+
81
+ Returns
82
+ -------
83
+ typing.List[typing.Dict]
84
+ a result for each name, in the order the names first appear
85
+ """
86
+ _wanted = None
87
+ if version:
88
+ with contextlib.suppress(ValueError):
89
+ _wanted = semver.VersionInfo.parse(version)
90
+
91
+ _versions: typing.Dict[str, semver.VersionInfo] = {}
92
+ _results: typing.Dict[str, typing.Dict] = {}
93
+
94
+ for result in results_list:
95
+ _name = result[SEARCH_KEYS["data_product"]]
96
+ _version = semver.VersionInfo.parse(result["version"])
97
+ if _wanted is not None and _version != _wanted:
98
+ continue
99
+ if _name not in _versions or _version > _versions[_name]:
100
+ _versions[_name] = _version
101
+ _results[_name] = result
102
+
103
+ return list(_results.values())
104
+
105
+
65
106
  def get_single_layer_objects(
66
107
  results_list: typing.List[typing.Dict], object_type: str
67
108
  ) -> typing.List[typing.Dict]:
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  description = "Synchronization interface for the SCRC FAIR Data Pipeline registry"
25
25
  name = "fair-cli"
26
- version = "0.10.0"
26
+ version = "0.10.1"
27
27
 
28
28
  homepage = "https://www.fairdatapipeline.org/"
29
29
 
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