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.
- fair_cli-0.10.1/CHANGELOG.md +132 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/CITATION.cff +2 -2
- {fair_cli-0.9.9 → fair_cli-0.10.1}/PKG-INFO +3 -6
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/cli.py +13 -4
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/common.py +1 -1
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/identifiers.py +21 -6
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/register.py +1 -1
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/requests.py +90 -6
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/storage.py +93 -4
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/sync.py +138 -67
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/session.py +45 -34
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/staging.py +4 -6
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/__init__.py +205 -93
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/globbing.py +65 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/utilities.py +13 -3
- {fair_cli-0.9.9 → fair_cli-0.10.1}/pyproject.toml +9 -3
- fair_cli-0.9.9/CHANGELOG.md +0 -33
- {fair_cli-0.9.9 → fair_cli-0.10.1}/LICENSE +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/README.md +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/__init__.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/configuration/__init__.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/configuration/validation.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/exceptions.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/files.txt +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/history.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/logging.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/__init__.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/file_formats.json +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/file_types.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/server.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/registry/versioning.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/run.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/__init__.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/config.jinja +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/templates/hist.jinja +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/testing.py +0 -0
- {fair_cli-0.9.9 → fair_cli-0.10.1}/fair/user_config/validation.py +0 -0
- {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:
|
|
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.
|
|
39
|
+
version: 0.10.1
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: fair-cli
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
)
|
|
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,
|
|
@@ -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,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
|
|
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,
|
|
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
|
-
|
|
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)
|