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.
- {fair_cli-0.10.0 → fair_cli-0.10.1}/CHANGELOG.md +46 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/CITATION.cff +2 -2
- {fair_cli-0.10.0 → fair_cli-0.10.1}/PKG-INFO +1 -1
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/requests.py +83 -7
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/storage.py +93 -4
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/sync.py +86 -51
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/session.py +16 -14
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/__init__.py +105 -54
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/globbing.py +41 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/pyproject.toml +1 -1
- {fair_cli-0.10.0 → fair_cli-0.10.1}/LICENSE +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/README.md +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/__init__.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/cli.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/common.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/configuration/__init__.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/configuration/validation.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/exceptions.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/files.txt +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/history.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/identifiers.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/logging.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/register.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/__init__.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/file_formats.json +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/file_types.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/server.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/registry/versioning.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/run.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/staging.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/__init__.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/config.jinja +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/templates/hist.jinja +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/testing.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/user_config/validation.py +0 -0
- {fair_cli-0.10.0 → fair_cli-0.10.1}/fair/utilities.py +0 -0
- {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-
|
|
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.
|
|
39
|
+
version: 0.10.1
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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 '
|
|
756
|
+
"'unique_name' and 'alternate_identifier_type' keys",
|
|
676
757
|
) from e
|
|
677
758
|
|
|
678
|
-
_identifier["
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1381
|
+
_storage_locations = fdp_req.get(
|
|
1382
1382
|
_api_url, "storage_location", _token, params={"hash": _hash}
|
|
1383
1383
|
)
|
|
1384
|
-
if
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
|
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
|
|
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=
|
|
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(
|
|
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
|
-
#
|
|
1079
|
-
#
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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 "
|
|
1112
|
-
|
|
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"]
|
|
1405
|
+
_exec = [arg.format(self.script) for arg in SHELLS[self.shell]["exec"]]
|
|
1357
1406
|
|
|
1358
|
-
self._logger.debug(
|
|
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
|
|
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]:
|
|
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
|