fair-cli 0.9.8__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. fair_cli-0.10.0/CHANGELOG.md +86 -0
  2. {fair_cli-0.9.8 → fair_cli-0.10.0}/CITATION.cff +2 -2
  3. {fair_cli-0.9.8 → fair_cli-0.10.0}/PKG-INFO +4 -5
  4. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/cli.py +13 -4
  5. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/common.py +1 -1
  6. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/identifiers.py +26 -6
  7. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/register.py +1 -1
  8. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/requests.py +9 -1
  9. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/sync.py +53 -17
  10. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/session.py +29 -20
  11. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/staging.py +4 -6
  12. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/user_config/__init__.py +113 -46
  13. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/user_config/globbing.py +24 -0
  14. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/utilities.py +13 -3
  15. {fair_cli-0.9.8 → fair_cli-0.10.0}/pyproject.toml +10 -5
  16. fair_cli-0.9.8/CHANGELOG.md +0 -33
  17. {fair_cli-0.9.8 → fair_cli-0.10.0}/LICENSE +0 -0
  18. {fair_cli-0.9.8 → fair_cli-0.10.0}/README.md +0 -0
  19. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/__init__.py +0 -0
  20. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/configuration/__init__.py +0 -0
  21. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/configuration/validation.py +0 -0
  22. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/exceptions.py +0 -0
  23. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/files.txt +0 -0
  24. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/history.py +0 -0
  25. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/logging.py +0 -0
  26. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/__init__.py +0 -0
  27. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/file_formats.json +0 -0
  28. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/file_types.py +0 -0
  29. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/server.py +0 -0
  30. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/storage.py +0 -0
  31. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/registry/versioning.py +0 -0
  32. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/run.py +0 -0
  33. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/templates/__init__.py +0 -0
  34. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/templates/config.jinja +0 -0
  35. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/templates/hist.jinja +0 -0
  36. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/testing.py +0 -0
  37. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/user_config/validation.py +0 -0
  38. {fair_cli-0.9.8 → fair_cli-0.10.0}/fair/virtualenv.py +0 -0
@@ -0,0 +1,86 @@
1
+ # 2026-10-03 [v0.10.0](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.10.0)
2
+
3
+ ## Changed behaviour
4
+ - `fair remote add` takes the remote's token file with `--token FILE`, and prompts for it if omitted.
5
+ The command never worked before: the label was stored as the token path.
6
+ - `fair run --ci` prepares the job directory and prints it without running the script, as documented.
7
+ - `fair init --ci` leaves an existing `.fair/` alone, as `fair init` does; it used to delete it, data
8
+ store included. Use `fair purge` to start afresh.
9
+ - Wildcards: each `*` matches one segment of a name (not `/`), anchored at both ends, in `read:` and
10
+ `write:` alike, matching DataPipeline.jl. `data_product/*` no longer matches `data_product/1/2`.
11
+ - `${{GIT_TAG}}` is the nearest tag in the history of the current commit (`git describe`), not the
12
+ last tag by name; with no such tag it is an error rather than a crash.
13
+ - `run_metadata.shell` now chooses the interpreter the script runs with.
14
+
15
+ ## Fixed
16
+ - `write:` entries with a wildcard were dropped from the working config (#140). The pattern is now kept
17
+ for new names, and existing matches are written as the pattern entry describes them (its `use: version`,
18
+ `file_type`, `description`).
19
+ - `fair push` from a project whose data store was not the local registry's first storage root recorded
20
+ its files on the remote under the pusher's `file://` path (data-registry #229).
21
+ - `fair pull` of a `read:` entry from a remote registry failed.
22
+ - A DOI whose publisher refuses automated requests (e.g. Wiley, HTTP 403) was rejected as an invalid
23
+ identifier. An identifier that resolves is now accepted; one that does not (404) is still refused.
24
+ - `${{DATETIME-<format>}}` always failed with "Failed to parse formatted datetime variable".
25
+ - Substituting a path with backslashes, such as `${{CONFIG_DIR}}` on Windows, failed `fair pull` and `fair run`
26
+ (#267). Variables are now substituted into the configuration's values, so any text is inserted as it is.
27
+ - `fair remote remove` crashed.
28
+ - A ROR or GRID ID the ROR API could not parse (e.g. one containing `!`) crashed with `KeyError`
29
+ instead of being reported as not found.
30
+ - Run from a subdirectory of a project, commands lost configuration changes and left a stale session
31
+ file, so the next command did not start the registry and `fair registry stop` needed `--force`.
32
+ - Staging ignored the configured local registry and always used port 8000.
33
+ - A download that failed with an HTTP error registered the error page as the data.
34
+ - `fair run` could crash on Windows when the model printed a character the console cannot encode.
35
+ - `fair run` in a git repository without the configured remote gave a bare `IndexError`; it now says
36
+ which remote is missing, or reads `run_metadata: remote_repo:` if given.
37
+ - `fair init` in a subdirectory of a FAIR repository named it as `'True'`.
38
+
39
+ ## Development
40
+ - CI ran no tests: an unused dev dependency (`pylama`) crashed `pytest --markers`, leaving the test loop
41
+ empty. It is removed, and a failure to list the tests now fails the job; all 8 jobs run the full suite.
42
+ - numpy is chosen per Python version (>= 2.3 from 3.11), so Python 3.14 gets a wheel, not a source build.
43
+ - Download tests use a local HTTP server rather than github.com.
44
+ - `test_configuration.py::test_local_config_query` is marked `xfail`: it has never passed, and its intent
45
+ is unknown.
46
+
47
+ # 2026-09-22 [v0.9.9](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.9.9)
48
+ - Requires Python >= 3.10, in step with data-registry v1.2.2.
49
+ - `${{RUN_ID}}` in a `write:` name is left for the language API to fill in at `finalise`.
50
+ - ROR/GRID organisation lookups failed after the ROR API moved to its v2 schema.
51
+
52
+ # Between v0.2.3 and v0.9.8
53
+ Not recorded release by release; see the [releases](https://github.com/FAIRDataPipeline/FAIR-CLI/releases)
54
+ and the git log.
55
+ - Fix virtual environment bug in `fair` binaries where `venv` assumes the main executable is `python`.
56
+ - Registration of `author` and `namespace` objects.
57
+ - Allow specification of tag to install registry from during `fair registry install`.
58
+ - Wildcard '*' parsing introduced for data products.
59
+ - Ability to `push` to a registry added.
60
+ - Added `--dirty` option to `fair run` to allow running with uncommitted changes.
61
+ - Added `config.yaml` file validation.
62
+ - Added initialisation from existing registry.
63
+ - Switch to setting port not local URI during initialisation.
64
+ - Added option to specify port on `fair registry start`.
65
+ - Added `cli-config.yaml` file validation during initialisation.
66
+ - Added `fair reset` to list of commands.
67
+ - Add tab completion for staging data products.
68
+
69
+ # 2021-11-17 [v0.2.3](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.3)
70
+ - Move handling of the user `config.yaml` file to a separate class `JobConfiguration`.
71
+ - Added various fixes to improve functionality within Windows.
72
+ - Move registry installation from script execution to internal function which sets up virtual environment etc.
73
+ - Added a test suite to the project.
74
+ - Added additional recognised identifiers for author setup from an organisation GRID and ROR.
75
+
76
+ # 2021-10-06 [v0.2.2](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.2)
77
+ - Update to package metadata for PyPi
78
+
79
+ # 2021-10-06 [v0.2.1](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.1)
80
+ - Automatic starter `config.yaml` generation.
81
+ - Local and Global CLI configurations.
82
+ - Start/stop local registry either explicitly or during synchronisations.
83
+ - Run logs available via git-like interface.
84
+ - Added ability to add/remove files.
85
+ - Repository style handling, acts like another git-like tool per project.
86
+ - Creation of an interface for `fair` using `click`.
@@ -26,7 +26,7 @@ authors:
26
26
  affiliation: University of Glasgow
27
27
  orcid: https://orcid.org/0000-0002-4424-9890
28
28
 
29
- date-released: 2025-04-08
29
+ date-released: 2026-10-03
30
30
  keywords:
31
31
  - FAIR Data Pipeline
32
32
  - FAIR
@@ -36,4 +36,4 @@ license: BSD-2-Clause
36
36
  message: If you use this software, please cite it using these metadata.
37
37
  repository-code: https://github.com/FAIRDataPipeline/FAIR-CLI/
38
38
  title: "The FAIR Data Pipeline command line tool"
39
- version: 0.9.8
39
+ version: 0.10.0
@@ -1,12 +1,13 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.1
2
2
  Name: fair-cli
3
- Version: 0.9.8
3
+ Version: 0.10.0
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
7
  Keywords: FAIR Data Pipeline,FAIR,Data Management,Provenance
7
8
  Author: Richard Reeve
8
9
  Author-email: richard.reeve@glasgow.ac.uk
9
- Requires-Python: >=3.9.0,<4.0.0
10
+ Requires-Python: >=3.10.0,<4.0.0
10
11
  Classifier: Development Status :: 4 - Beta
11
12
  Classifier: Environment :: Console
12
13
  Classifier: Intended Audience :: Science/Research
@@ -15,7 +16,6 @@ Classifier: Operating System :: MacOS
15
16
  Classifier: Operating System :: OS Independent
16
17
  Classifier: Operating System :: POSIX :: Linux
17
18
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.9
19
19
  Classifier: Programming Language :: Python :: 3.10
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
@@ -38,7 +38,6 @@ Requires-Dist: simplejson (>=3.17.5,<4.0.0)
38
38
  Requires-Dist: toml (>=0.10.2,<0.11.0)
39
39
  Requires-Dist: validators (>=0.18.2,<0.19.0)
40
40
  Project-URL: Documentation, https://www.fairdatapipeline.org/docs/interface/fdp/
41
- Project-URL: Homepage, https://www.fairdatapipeline.org/
42
41
  Project-URL: Issue Tracker, https://github.com/FAIRDataPipeline/FAIR-CLI/issues
43
42
  Project-URL: Repository, https://github.com/FAIRDataPipeline/FAIR-CLI
44
43
  Description-Content-Type: text/markdown
@@ -616,9 +616,16 @@ def remote(ctx, verbose: bool = False, debug: bool = False):
616
616
 
617
617
 
618
618
  @remote.command()
619
- @click.argument("options", nargs=-1)
619
+ @click.argument("options", nargs=-1, required=True)
620
+ @click.option(
621
+ "--token",
622
+ "token_file",
623
+ help="File containing the API token for the remote registry",
624
+ prompt="Remote API Token File",
625
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
626
+ )
620
627
  @click.option("--debug/--no-debug", help="Run in debug mode", default=False)
621
- def add(options: typing.List[str], debug: bool) -> None:
628
+ def add(options: typing.List[str], token_file: str, debug: bool) -> None:
622
629
  """Add a remote registry URL with option to give it a label if multiple
623
630
  remotes may be used.
624
631
 
@@ -628,13 +635,15 @@ def add(options: typing.List[str], debug: bool) -> None:
628
635
  size 1 or 2 list containing either:
629
636
  - label, url
630
637
  - url
638
+ token_file : str
639
+ file containing the API token for the remote registry
631
640
  """
632
641
  _url = options[1] if len(options) > 1 else options[0]
633
642
  _label = options[0] if len(options) > 1 else "origin"
634
643
 
635
644
  try:
636
645
  with fdp_session.FAIR(os.getcwd(), debug=debug) as fair_session:
637
- fair_session.add_remote(_url, _label)
646
+ fair_session.add_remote(_url, token_file, _label)
638
647
  except fdp_exc.FAIRCLIException as e:
639
648
  e.err_print()
640
649
  if e.level.lower() == "error":
@@ -654,7 +663,7 @@ def remove(label: str, debug: bool) -> None:
654
663
  """
655
664
  try:
656
665
  with fdp_session.FAIR(os.getcwd(), debug=debug) as fair_session:
657
- fair_session.remove_remove(label)
666
+ fair_session.remove_remote(label)
658
667
  except fdp_exc.FAIRCLIException as e:
659
668
  e.err_print()
660
669
  if e.level.lower() == "error":
@@ -19,7 +19,7 @@ Members
19
19
  FAIR_FOLDER - name for FAIR local repository directory
20
20
 
21
21
  Functions
22
- -------
22
+ ---------
23
23
  registry_home - returns the location of the local data registry
24
24
  find_fair_root - returns the closest '.fair' directory in the upper hierarchy
25
25
  find_git_root - returns the closest '.git' directory
@@ -192,10 +192,12 @@ def check_ror(ror: str) -> typing.Dict:
192
192
 
193
193
  def check_grid(grid_id: str) -> typing.Dict:
194
194
  """Checks if valid GRID ID using ROR (https://ror.org/) public api
195
+
195
196
  Parameters
196
197
  ----------
197
198
  grid_id : str
198
199
  GRID ID to be checked
200
+
199
201
  Returns
200
202
  -------
201
203
  typing.Dict
@@ -230,11 +232,20 @@ def _check_generic_ror(id: str) -> typing.Dict:
230
232
  logger.debug(f"{_url} Responded with {_response.status_code}")
231
233
  return _result_dict
232
234
 
233
- if _response.json()["number_of_results"] == 0:
235
+ # A query ROR cannot parse (e.g. one containing "!") still returns 200, with
236
+ # "errors" in place of results
237
+ _items = _response.json().get("items")
238
+ if not _items:
239
+ logger.debug(f"{_url} returned no results: {_response.json().get('errors')}")
234
240
  return _result_dict
235
241
 
236
- _id = _response.json()["items"][0]["id"]
237
- _name = _response.json()["items"][0]["name"]
242
+ _id = _items[0]["id"]
243
+ # The ROR API lists every name of an organisation, marking the one to show as "ror_display"
244
+ _names = _items[0]["names"]
245
+ _name = next(
246
+ (n["value"] for n in _names if "ror_display" in n["types"]),
247
+ _names[0]["value"],
248
+ )
238
249
  _result_dict["name"] = _name
239
250
  _result_dict["family_name"] = _name
240
251
  _result_dict["given_names"] = None
@@ -246,7 +257,9 @@ def _check_generic_ror(id: str) -> typing.Dict:
246
257
  def check_id_permitted(identifier: str, retries: int = 5) -> bool:
247
258
  """Check a user provided identifier is permitted
248
259
 
249
- 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.
250
263
 
251
264
  Parameters
252
265
  ----------
@@ -269,9 +282,16 @@ def check_id_permitted(identifier: str, retries: int = 5) -> bool:
269
282
  headers = {}
270
283
  if fake_agent:
271
284
  headers = {"User-Agent": str(UserAgent().chrome)}
272
- requests.get(
285
+ _response = requests.get(
273
286
  identifier, verify=False, allow_redirects=True, headers=headers
274
- ).raise_for_status()
287
+ )
288
+ # Some publishers answer 403 to any automated request whatever
289
+ # the user agent, so a refusal reached through a redirect (e.g.
290
+ # from doi.org) still means the identifier resolved. A 404
291
+ # anywhere is still rejected.
292
+ if _response.history and _response.status_code in (401, 403):
293
+ return True
294
+ _response.raise_for_status()
275
295
  return True
276
296
  except (
277
297
  requests.exceptions.MissingSchema,
@@ -13,7 +13,7 @@ Contents
13
13
  ========
14
14
 
15
15
  Functions
16
- -------
16
+ ---------
17
17
 
18
18
  """
19
19
 
@@ -506,6 +506,11 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
506
506
  -------
507
507
  str
508
508
  path of downloaded temporary file
509
+
510
+ Raises
511
+ ------
512
+ requests.HTTPError
513
+ if the server answers with an error status
509
514
  """
510
515
  # Save the data to a temporary file so we can calculate the hash
511
516
  _file = tempfile.NamedTemporaryFile(delete=False)
@@ -528,12 +533,15 @@ def download_file(url: str, chunk_size: int = 8192) -> str:
528
533
  response = requests.get(
529
534
  url, allow_redirects=True, verify=False, headers=headers
530
535
  )
531
- open(_fname, mode="wb").write(response.content)
532
536
  except Exception as e:
533
537
  raise fdp_exc.FAIRCLIException(
534
538
  f"Failed to download file '{url}'"
535
539
  f" due to connection error: {traceback.format_exc()}"
536
540
  ) from e
541
+ # Outside the try, so that callers can tell an HTTP error from a
542
+ # 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)
537
545
 
538
546
  return _fname
539
547
 
@@ -226,6 +226,35 @@ def sync_dependency_chain(
226
226
  return _new_urls
227
227
 
228
228
 
229
+ # Whether a storage_root record is on the pushing machine's file system
230
+ def _is_local_root(storage_root: typing.Dict) -> bool:
231
+ return storage_root.get("root", "").startswith("file://")
232
+
233
+
234
+ # URL of a registry's own data store: the storage_root whose root is the
235
+ # registry's data/ path, beside its api/, from which it serves stored files.
236
+ # A registry whose site URL differs from its API's address records that root
237
+ # under another name; its first storage_root is then taken, as set_site_info
238
+ # creates the data store first.
239
+ def _remote_data_store_url(registry_uri: str, token: str) -> str:
240
+ _registry_uri = fdp_util.check_trailing_slash(registry_uri)
241
+ _root = urllib.parse.urljoin(_registry_uri, "../data/")
242
+ _data_store = fdp_req.get(
243
+ registry_uri, "storage_root", token, params={"root": _root}
244
+ )
245
+ if _data_store:
246
+ return _data_store[0]["url"]
247
+ _fallback = urllib.parse.urljoin(_registry_uri, "storage_root/1/")
248
+ logger.warning(
249
+ "Registry '%s' has no storage root '%s'; assuming its data store is "
250
+ "'%s'. Its site URL may not match its address.",
251
+ registry_uri,
252
+ _root,
253
+ _fallback,
254
+ )
255
+ return _fallback
256
+
257
+
229
258
  def _get_new_url(
230
259
  origin_uri: str,
231
260
  origin_token: str,
@@ -281,8 +310,9 @@ def _get_new_url(
281
310
  # Make sure that a URL for the component does exist
282
311
  if value not in new_urls:
283
312
  raise fdp_exc.RegistryError(
284
- f"Expected URL from remote '{dest_uri}' for component "
285
- f"'{key}' of local object '{value}' during push."
313
+ f"Expected a URL on '{dest_uri}' for component '{key}', "
314
+ f"whose value '{value}' should have been synchronised "
315
+ "before it."
286
316
  )
287
317
 
288
318
  # Retrieve from the new URLs the correct value and substitute
@@ -293,7 +323,6 @@ def _get_new_url(
293
323
  # (as remote URL will never match local)
294
324
 
295
325
  _obj_type = fdp_req.get_obj_type_from_url(object_url, token=origin_token)
296
- _obj_id = fdp_req.get_obj_id_from_url(object_url)
297
326
 
298
327
  _filters = {
299
328
  k: v
@@ -311,20 +340,21 @@ def _get_new_url(
311
340
  # If Public is true then any files will be uploaded to the remote registry
312
341
  # If local_data_store is set we're pulling to a local registry
313
342
  if public and not local_data_store:
314
- # The remote data_store storage_root URL should
315
- # always be the 1st storage_root
316
- _remote_storage_root_url = urllib.parse.urljoin(dest_uri, "storage_root/1/")
317
- # If the current objects a storage_root and the root
318
- # is the first (data_store) then simply return the remote
319
- if _obj_type == "storage_root" and _obj_id == "1":
320
- return _remote_storage_root_url
343
+ # A local data store is any root on the pusher's own file system,
344
+ # however many projects share the local registry; it is replaced by
345
+ # the remote data store. Other roots (e.g. https://github.com/) are
346
+ # pushed as they are.
347
+ if _obj_type == "storage_root" and _is_local_root(object_data):
348
+ return _remote_data_store_url(dest_uri, dest_token)
321
349
  # If the current object is a storage_location
322
350
  elif _obj_type == "storage_location":
323
- # Again if the storage_root is the data_store (storage_root 1)
324
- if (
325
- fdp_req.get_obj_id_from_url(object_data.get("storage_root", "/2"))
326
- == "1"
351
+ # Again if the storage_root is a local data store
352
+ if _is_local_root(
353
+ fdp_req.url_get(object_data["storage_root"], origin_token)
327
354
  ):
355
+ _remote_storage_root_url = _remote_data_store_url(
356
+ dest_uri, dest_token
357
+ )
328
358
  # Update the new object path and filter path
329
359
  _new_obj_data["path"] = _filters["path"] = _new_obj_data["hash"]
330
360
  # Update the new object storage_root
@@ -522,6 +552,11 @@ def sync_data_products(
522
552
  )
523
553
  result = result[0]
524
554
 
555
+ # Held separately because `result` becomes the external object below,
556
+ # and the file is fetched by the data product's own namespace, name
557
+ # and version
558
+ _data_product = result
559
+
525
560
  result_object = fdp_req.url_get(result["object"], token=origin_token)
526
561
  result_storage_location = fdp_req.url_get(
527
562
  result_object["storage_location"], token=origin_token
@@ -544,7 +579,7 @@ def sync_data_products(
544
579
  # If local_data_store assume we're syncing from remote to local
545
580
  if local_data_store:
546
581
  logger.debug("Retrieving files from remote registry data storage")
547
- fetch_data_product(origin_token, local_data_store, result[0])
582
+ fetch_data_product(origin_token, local_data_store, _data_product)
548
583
  # Else going from local to remote
549
584
  else:
550
585
  # If the storage location is public upload the files to object storage
@@ -1142,7 +1177,7 @@ def fetch_data_product(
1142
1177
  _file_type = ""
1143
1178
 
1144
1179
  _local_dir = os.path.join(
1145
- local_data_store, _namespace["name"], data_product["data_product"]
1180
+ local_data_store, _namespace["name"], data_product["name"]
1146
1181
  )
1147
1182
 
1148
1183
  os.makedirs(_local_dir, exist_ok=True)
@@ -1201,7 +1236,8 @@ def download_from_registry(registry_url: str, root: str, path: str) -> str:
1201
1236
  logger.debug("Downloaded file from '%s' to temporary file", _download_url)
1202
1237
  except requests.HTTPError as r_in:
1203
1238
  raise fdp_exc.UserConfigError(
1204
- f"Failed to fetch item '{_download_url}' with exit code {r_in.response}"
1239
+ f"Failed to fetch item '{_download_url}' with status code "
1240
+ f"{r_in.response.status_code}"
1205
1241
  ) from r_in
1206
1242
 
1207
1243
  return _temp_data_file
@@ -542,7 +542,10 @@ class FAIR:
542
542
 
543
543
  self._session_config.write()
544
544
 
545
- self._session_config.execute()
545
+ # A passive run only prepares the job directory, for the model to be
546
+ # run from it separately
547
+ if not passive:
548
+ self._session_config.execute()
546
549
 
547
550
  self._post_job_breakdown(add_run=True)
548
551
 
@@ -754,11 +757,14 @@ class FAIR:
754
757
  def remove_remote(self, label: str) -> None:
755
758
  """Remove a remote URL from the list of remotes by label"""
756
759
  self.check_is_repo()
757
- if "registries" not in self._local_config or label not in self._local_config:
760
+ if (
761
+ "registries" not in self._local_config
762
+ or label not in self._local_config["registries"]
763
+ ):
758
764
  raise fdp_exc.CLIConfigurationError(
759
765
  f"No such entry '{label}' in available remotes"
760
766
  )
761
- del self._local_config[label]
767
+ del self._local_config["registries"][label]
762
768
 
763
769
  def modify_remote(self, label: str, url: str) -> None:
764
770
  """Update a remote URL for a given remote"""
@@ -1089,7 +1095,8 @@ class FAIR:
1089
1095
  raise fdp_exc.CLIConfigurationError(
1090
1096
  "Cannot generate user 'config.yaml'",
1091
1097
  hint="You need to set the remote URL"
1092
- " by running: \n\n\tfair remote add <url>\n",
1098
+ " by running: \n\n\tfair remote add <url>"
1099
+ " --token <token-file>\n",
1093
1100
  )
1094
1101
 
1095
1102
  with open(output_file_name, encoding="utf-8", mode="w") as f:
@@ -1132,11 +1139,17 @@ class FAIR:
1132
1139
 
1133
1140
  _first_time = not os.path.exists(fdp_com.global_fdpconfig())
1134
1141
 
1142
+ # Also with --ci: the data store lives in .fair/, and the registry
1143
+ # still holds records pointing into it. 'fair purge' starts afresh.
1144
+ if os.path.exists(_fair_dir):
1145
+ if export_as:
1146
+ self._export_cli_configuration(export_as)
1147
+ return
1148
+ else:
1149
+ click.echo("FAIR repository is already initialised.")
1150
+ return
1151
+
1135
1152
  if self._testing:
1136
- if os.path.exists(_fair_dir):
1137
- if platform.system() == "Windows":
1138
- fdp_com.set_file_permissions(_fair_dir)
1139
- shutil.rmtree(_fair_dir, onerror=fdp_com.remove_readonly)
1140
1153
  using = fdp_test.create_configurations(
1141
1154
  registry,
1142
1155
  fdp_com.find_git_root(os.getcwd()),
@@ -1144,15 +1157,9 @@ class FAIR:
1144
1157
  os.path.join(os.getcwd(), ".fair"),
1145
1158
  )
1146
1159
 
1147
- if os.path.exists(_fair_dir) and not self._testing:
1148
- if export_as:
1149
- self._export_cli_configuration(export_as)
1150
- return
1151
- else:
1152
- click.echo("FAIR repository is already initialised.")
1153
- return
1154
-
1155
- if _existing := fdp_com.find_fair_root(self._session_loc) and not self._testing:
1160
+ if (
1161
+ _existing := fdp_com.find_fair_root(self._session_loc)
1162
+ ) and not self._testing:
1156
1163
  click.echo(
1157
1164
  "A FAIR repository was initialised for this location at"
1158
1165
  f" '{_existing}'"
@@ -1256,7 +1263,7 @@ class FAIR:
1256
1263
 
1257
1264
  def close_session(self) -> None:
1258
1265
  """Upon exiting, dump all configurations to file"""
1259
- if not os.path.exists(os.path.join(self._session_loc, fdp_com.FAIR_FOLDER)):
1266
+ if not fdp_com.find_fair_root(self._session_loc):
1260
1267
  return
1261
1268
 
1262
1269
  if self._session_id:
@@ -1266,10 +1273,12 @@ class FAIR:
1266
1273
  )
1267
1274
  os.remove(_cache_addr)
1268
1275
 
1269
- if os.path.exists(fdp_com.global_config_dir()):
1276
+ if os.path.exists(os.path.dirname(fdp_com.global_fdpconfig())):
1270
1277
  with open(fdp_com.global_fdpconfig(), encoding="utf-8", mode="w") as f:
1271
1278
  yaml.dump(self._global_config, f)
1272
- if os.path.exists(os.path.dirname(fdp_com.local_fdpconfig())):
1279
+ if os.path.exists(
1280
+ os.path.dirname(fdp_com.local_fdpconfig(self._session_loc))
1281
+ ):
1273
1282
  with open(
1274
1283
  fdp_com.local_fdpconfig(self._session_loc), encoding="utf-8", mode="w"
1275
1284
  ) as f:
@@ -418,9 +418,8 @@ class Stager:
418
418
  with open(self._staging_file, encoding="utf-8") as f:
419
419
  _staging_dict = yaml.safe_load(f)
420
420
 
421
- result = fdp_req.url_get(
422
- f"{fdp_com.DEFAULT_LOCAL_REGISTRY_URL}data_product",
423
- fdp_req.local_token(),
421
+ result = fdp_req.get(
422
+ fdp_conf.get_local_uri(), "data_product", fdp_req.local_token()
424
423
  )
425
424
 
426
425
  for data_product in result:
@@ -441,9 +440,8 @@ class Stager:
441
440
  with open(self._staging_file, encoding="utf-8") as f:
442
441
  _staging_dict = yaml.safe_load(f)
443
442
 
444
- result = fdp_req.url_get(
445
- f"{fdp_com.DEFAULT_LOCAL_REGISTRY_URL}code_run",
446
- fdp_req.local_token(),
443
+ result = fdp_req.get(
444
+ fdp_conf.get_local_uri(), "code_run", fdp_req.local_token()
447
445
  )
448
446
 
449
447
  for code_run in result:
@@ -93,6 +93,28 @@ SHELLS: typing.Dict[str, str] = {
93
93
  }
94
94
 
95
95
 
96
+ # Apply a function to every string in a configuration's nested dicts and lists,
97
+ # keys included, returning the new structure
98
+ def _map_strings(item: typing.Any, func: typing.Callable) -> typing.Any:
99
+ if isinstance(item, str):
100
+ return func(item)
101
+ if isinstance(item, dict):
102
+ return {
103
+ _map_strings(k, func): _map_strings(v, func)
104
+ for k, v in item.items()
105
+ }
106
+ if isinstance(item, list):
107
+ return [_map_strings(v, func) for v in item]
108
+ return item
109
+
110
+
111
+ # Every string in a configuration's nested dicts and lists, keys included
112
+ def _strings_in(item: typing.Any) -> typing.List[str]:
113
+ _strings: typing.List[str] = []
114
+ _map_strings(item, lambda s: _strings.append(s) or s)
115
+ return _strings
116
+
117
+
96
118
  class JobConfiguration(MutableMapping):
97
119
  _logger = logging.getLogger("FAIRDataPipeline.ConfigYAML")
98
120
  _block_types = ("register", "write", "read")
@@ -408,6 +430,15 @@ class JobConfiguration(MutableMapping):
408
430
  f" wildcard '{block_entry[_obj_type]}'"
409
431
  ) from e
410
432
 
433
+ # The registry's own filter lets '*' match across '/'
434
+ _results_local = [
435
+ result
436
+ for result in _results_local
437
+ if fdp_glob.matches_wildcard(
438
+ block_entry[_obj_type], result[_search_key]
439
+ )
440
+ ]
441
+
411
442
  if _obj_type in ("namespace", "author"):
412
443
  # If the object is a namespace or an author then there is no
413
444
  # additional info in the registry so we can just add the entries
@@ -425,12 +456,26 @@ class JobConfiguration(MutableMapping):
425
456
  )
426
457
 
427
458
  elif _obj_type == "data_product":
428
- _version = block_entry.get("version", None)
459
+ _version = block_entry.get("use", {}).get(
460
+ "version", block_entry.get("version", None)
461
+ )
429
462
 
430
463
  _new_entries = fdp_glob.get_data_product_objects(
431
464
  registry_token, _results_local, block_type, _version
432
465
  )
433
466
 
467
+ # A match is written again as the pattern entry describes it
468
+ # (file_type, description, public), not as its registry row
469
+ if block_type == "write":
470
+ for _new_entry in _new_entries:
471
+ _new_entry.update(
472
+ {
473
+ k: v
474
+ for k, v in block_entry.items()
475
+ if k not in ("data_product", "use")
476
+ }
477
+ )
478
+
434
479
  if block_type == "write":
435
480
  _new_entries.append(block_entry)
436
481
 
@@ -637,7 +682,16 @@ class JobConfiguration(MutableMapping):
637
682
  "not a valid git repository."
638
683
  ) from e
639
684
 
640
- _url = _git_repo.remotes[_remote].url
685
+ try:
686
+ _url = _git_repo.remotes[_remote].url
687
+ except IndexError as e:
688
+ raise fdp_exc.FDPRepositoryError(
689
+ f"Failed to update job configuration from location '{fair_repo_dir}', "
690
+ f"git repository '{_local_repo}' has no remote '{_remote}'.",
691
+ hint="Add the remote to the repository, or give "
692
+ "'run_metadata: remote_repo:' in the configuration.",
693
+ ) from e
694
+
641
695
  self["run_metadata.remote_repo"] = _url
642
696
 
643
697
  def pop(self, key: str, default: typing.Optional[typing.Any] = None) -> typing.Any:
@@ -808,7 +862,13 @@ class JobConfiguration(MutableMapping):
808
862
  # Additional parser for formatted datetime
809
863
  _regex_fmt = re.compile(r"\$\{\{\s*([^}${\s]+)\s*\}\}")
810
864
 
811
- if _unparsed := _regex_fmt.findall(_conf_str):
865
+ # RUN_ID is the code run's UUID, which does not exist until the API
866
+ # creates the code run, so the API substitutes it at finalise
867
+ _unparsed = [
868
+ var for var in _regex_fmt.findall(_conf_str) if var != "RUN_ID"
869
+ ]
870
+
871
+ if _unparsed:
812
872
  raise fdp_exc.InternalError(f"Failed to parse variables '{_unparsed}'")
813
873
 
814
874
  def _subst_cli_vars(self, job_time: datetime.datetime) -> str:
@@ -822,11 +882,14 @@ class JobConfiguration(MutableMapping):
822
882
 
823
883
  def _tag_check():
824
884
  _repo = git.Repo(fdp_conf.local_git_repo(self.local_repository))
825
- if len(_repo.tags) < 1:
826
- fdp_exc.UserConfigError(
827
- "Cannot use GIT_TAG variable, no git tags found."
828
- )
829
- return _repo.tags[-1].name
885
+ # The nearest tag in the history of HEAD, not the last by name
886
+ try:
887
+ return _repo.git.describe("--tags", "--abbrev=0")
888
+ except git.GitCommandError as e:
889
+ raise fdp_exc.UserConfigError(
890
+ "Cannot use GIT_TAG variable, no git tags found "
891
+ "in the history of the current commit."
892
+ ) from e
830
893
 
831
894
  _substitutes: typing.Dict[str, typing.Callable] = {
832
895
  "DATE": lambda: job_time.strftime("%Y%m%d"),
@@ -842,32 +905,20 @@ class JobConfiguration(MutableMapping):
842
905
  "GIT_TAG": _tag_check,
843
906
  }
844
907
 
908
+ # Substitutions are made in the parsed configuration, never in its
909
+ # YAML text, so that a value is inserted as it is: a Windows path's
910
+ # backslashes are neither regular-expression nor YAML escapes
911
+
845
912
  # Additional parser for formatted datetime
846
- _regex_dt_fmt = re.compile(r"\$\{\{\s*DATETIME\-[^}${\s]]+\s*\}\}")
847
913
  _regex_fmt = re.compile(r"\$\{\{\s*DATETIME\-([^}${\s]+)\s*\}\}")
848
914
 
849
- _config_str: str = yaml.dump(self._config)
850
-
851
- _dt_fmt_res: typing.Optional[typing.List[str]] = _regex_dt_fmt.findall(
852
- _config_str
853
- )
854
- _fmt_res: typing.Optional[typing.List[str]] = _regex_fmt.findall(_config_str)
855
-
856
- self._logger.debug(
857
- "Found datetime substitutions: %s %s",
858
- _dt_fmt_res or "",
859
- _fmt_res or "",
915
+ self._config = _map_strings(
916
+ self._config,
917
+ lambda s: _regex_fmt.sub(
918
+ lambda m: job_time.strftime(m.group(1).strip()), s
919
+ ),
860
920
  )
861
921
 
862
- # The two regex searches should match lengths
863
- if len(_dt_fmt_res) != len(_fmt_res):
864
- raise fdp_exc.UserConfigError("Failed to parse formatted datetime variable")
865
-
866
- if _dt_fmt_res:
867
- for i, _ in enumerate(_dt_fmt_res):
868
- _time_str = job_time.strftime(_fmt_res[i].strip())
869
- _config_str = _config_str.replace(_dt_fmt_res[i], _time_str)
870
-
871
922
  _regex_dict = {
872
923
  var: r"\$\{\{\s*" + f"{var}" + r"\s*\}\}" for var in _substitutes
873
924
  }
@@ -875,16 +926,16 @@ class JobConfiguration(MutableMapping):
875
926
  # Perform string substitutions
876
927
  for var, subst in _regex_dict.items():
877
928
  # Only execute functions in var substitutions that are required
878
- if re.findall(subst, _config_str):
879
- _value = _substitutes[var]()
929
+ if any(re.search(subst, s) for s in _strings_in(self._config)):
930
+ _value = str(_substitutes[var]() or "")
880
931
  if not _value:
881
932
  raise fdp_exc.InternalError(
882
933
  f"Expected value for substitution of '{var}' but returned None",
883
934
  )
884
- _config_str = re.sub(subst, str(_value), _config_str)
885
- self._logger.debug("Substituting %s: %s", var, str(_value))
886
-
887
- self._config = yaml.safe_load(_config_str)
935
+ self._config = _map_strings(
936
+ self._config, lambda s: re.sub(subst, lambda _: _value, s)
937
+ )
938
+ self._logger.debug("Substituting %s: %s", var, _value)
888
939
 
889
940
  def _register_to_read(
890
941
  self, register_block: typing.List[typing.Dict]
@@ -1010,16 +1061,18 @@ class JobConfiguration(MutableMapping):
1010
1061
 
1011
1062
  _version = item["use"]["version"]
1012
1063
 
1013
- if "data_product" not in item["use"]:
1014
- if "external_object" in item and "*" in item["external_object"]:
1015
- _name = item["external_object"]
1016
- elif "data_product" in item and "*" in item["data_product"]:
1017
- _name = item["data_product"]
1018
- else:
1019
- self._logger.warning(f"Missing use:data_product in {item}")
1064
+ if "data_product" in item["use"]:
1065
+ _name = item["use"]["data_product"]
1066
+ elif "external_object" in item and "*" in item["external_object"]:
1067
+ _name = item["external_object"]
1068
+ elif "data_product" in item and "*" in item["data_product"]:
1069
+ _name = item["data_product"]
1070
+ else:
1071
+ self._logger.warning(f"Missing use:data_product in {item}")
1020
1072
  continue
1021
-
1022
- _name = item["use"]["data_product"]
1073
+ # Only a wildcard entry lacks this: kept in the write block as the
1074
+ # template for names the model writes that match it
1075
+ _new_item["use"]["data_product"] = _name
1023
1076
  _namespace = item["use"]["namespace"]
1024
1077
 
1025
1078
  # If no ID exists for the namespace then this object has not yet
@@ -1055,6 +1108,13 @@ class JobConfiguration(MutableMapping):
1055
1108
  except fdp_exc.RegistryError:
1056
1109
  _results = []
1057
1110
 
1111
+ if "*" in _name:
1112
+ _results = [
1113
+ result
1114
+ for result in _results
1115
+ if fdp_glob.matches_wildcard(_name, result["name"])
1116
+ ]
1117
+
1058
1118
  try:
1059
1119
  _version = fdp_ver.get_correct_version(
1060
1120
  version=_version,
@@ -1180,7 +1240,7 @@ class JobConfiguration(MutableMapping):
1180
1240
  def shell(self) -> str:
1181
1241
  """Retrieve the shell choice"""
1182
1242
  _shell_default = "batch" if platform.system() == "Windows" else "sh"
1183
- return self.get("shell", _shell_default)
1243
+ return self.get("run_metadata.shell", _shell_default)
1184
1244
 
1185
1245
  @property
1186
1246
  def local_repository(self) -> str:
@@ -1312,11 +1372,18 @@ class JobConfiguration(MutableMapping):
1312
1372
  encoding="utf-8",
1313
1373
  )
1314
1374
 
1375
+ # The job log is UTF-8, but the console may not be (cp1252 on a
1376
+ # Windows runner), and one unencodable character must not stop the run
1377
+ _encoding = sys.stdout.encoding or "utf-8"
1378
+
1315
1379
  # Write any stdout to the job log
1316
1380
  for line in iter(_process.stdout.readline, ""):
1317
1381
  self._log_file.writelines([line])
1318
1382
  _log_tail.append(line)
1319
- click.echo(line, nl=False)
1383
+ click.echo(
1384
+ line.encode(_encoding, errors="replace").decode(_encoding),
1385
+ nl=False,
1386
+ )
1320
1387
  sys.stdout.flush()
1321
1388
 
1322
1389
  _process.wait()
@@ -14,6 +14,7 @@ Constants
14
14
  - DISPOSABLES: tuple of keys to be removed before adding to config.yaml
15
15
 
16
16
  """
17
+ import re
17
18
  import typing
18
19
 
19
20
  import fair.exceptions as fdp_exc
@@ -38,6 +39,29 @@ DISPOSABLES = (
38
39
  )
39
40
 
40
41
 
42
+ def matches_wildcard(pattern: str, name: str) -> bool:
43
+ """Return whether a name matches a wildcard pattern
44
+
45
+ Each '*' matches one segment of the name: one or more characters other
46
+ than '/'. The pattern is anchored at both ends, and every other character
47
+ matches itself.
48
+
49
+ Parameters
50
+ ----------
51
+ pattern : str
52
+ name containing one or more '*' wildcards
53
+ name : str
54
+ name to test against the pattern
55
+
56
+ Returns
57
+ -------
58
+ bool
59
+ whether the name matches
60
+ """
61
+ _regex = "[^/]+".join(re.escape(part) for part in pattern.split("*"))
62
+ return re.fullmatch(_regex, name) is not None
63
+
64
+
41
65
  def get_single_layer_objects(
42
66
  results_list: typing.List[typing.Dict], object_type: str
43
67
  ) -> typing.List[typing.Dict]:
@@ -202,9 +202,19 @@ def is_api_url(uri: str, string: str) -> bool:
202
202
  _uri = urllib.parse.urlparse(uri)
203
203
 
204
204
  logger.debug(
205
- "Checking if '%s' is a valid API URL against net location '%s'",
205
+ "Checking if '%s' is a valid API URL against endpoint '%s'",
206
206
  string,
207
- _uri.netloc,
207
+ uri,
208
208
  )
209
209
 
210
- return _url.netloc == _uri.netloc
210
+ if _url.netloc != _uri.netloc:
211
+ return False
212
+
213
+ # The API is served under a path, and a registry serves other things from
214
+ # the same host - a remote registry's own data store is <domain>/data/ -
215
+ # so the path has to match too, or a StorageRoot's root is taken for a
216
+ # reference to another object. An endpoint given as a bare host has no
217
+ # path to compare, and then the net location is all there is to go on.
218
+ _api_path = check_trailing_slash(_uri.path) if _uri.path else "/"
219
+
220
+ return _url.path.startswith(_api_path)
@@ -10,7 +10,6 @@ authors = [
10
10
  classifiers = [
11
11
  "License :: OSI Approved :: BSD License",
12
12
  "Intended Audience :: Science/Research",
13
- "Programming Language :: Python :: 3.9",
14
13
  "Programming Language :: Python :: 3.10",
15
14
  "Programming Language :: Python :: 3.11",
16
15
  "Programming Language :: Python :: 3.12",
@@ -24,7 +23,7 @@ classifiers = [
24
23
  ]
25
24
  description = "Synchronization interface for the SCRC FAIR Data Pipeline registry"
26
25
  name = "fair-cli"
27
- version = "0.9.8"
26
+ version = "0.10.0"
28
27
 
29
28
  homepage = "https://www.fairdatapipeline.org/"
30
29
 
@@ -56,7 +55,7 @@ click = "^8.0.0"
56
55
  email-validator = "^1.1.3"
57
56
  pre-commit = "^3"
58
57
  pydantic = {extras = ["email"], version = "^1.9.0"}
59
- python = "^3.9.0,<4.0"
58
+ python = "^3.10.0,<4.0"
60
59
  requests = "^2.23.0"
61
60
  rich = ">=10.2.3,<12.0.0"
62
61
  semver = "^2.13.0"
@@ -65,7 +64,7 @@ toml = "^0.10.2"
65
64
  validators = "^0.18.2"
66
65
  fake-useragent = "^1"
67
66
 
68
- [tool.poetry.dev-dependencies]
67
+ [tool.poetry.group.dev.dependencies]
69
68
  bandit = "*"
70
69
  black = "*"
71
70
  coverage = "*"
@@ -77,7 +76,6 @@ poetry = "^1.1.12"
77
76
  pre-commit = "*"
78
77
  pycodestyle = "^2.7.0"
79
78
  pydocstyle = "^6.0.0"
80
- pylama = "^8"
81
79
  pytest = "*"
82
80
  pytest-shutil = "*"
83
81
  pytest-cov = "*"
@@ -89,6 +87,13 @@ moto = "^5"
89
87
  Flask = "^3"
90
88
  Flask-Cors = "^4"
91
89
  matplotlib = "*"
90
+ # Brought in by matplotlib. Without a split, one numpy must serve the whole
91
+ # Python range, and numpy >= 2.3 needs Python >= 3.11, so every version got
92
+ # 2.2.6, which has no wheels for 3.14. Delete when the floor reaches 3.11.
93
+ numpy = [
94
+ { version = ">=2.3", python = ">=3.11" },
95
+ { version = "*", python = "<3.11" },
96
+ ]
92
97
 
93
98
  [tool.poetry.scripts]
94
99
  fair = 'fair.cli:cli'
@@ -1,33 +0,0 @@
1
- # Unreleased
2
- - Fix virtual environment bug in `fair` binaries where `venv` assumes the main executable is `python`.
3
- - Registration of `author` and `namespace` objects.
4
- - Allow specification of tag to install registry from during `fair registry install`.
5
- - Wildcard '*' parsing introduced for data products.
6
- - Ability to `push` to a registry added.
7
- - Added `--dirty` option to `fair run` to allow running with uncommitted changes.
8
- - Added `config.yaml` file validation.
9
- - Added initialisation from existing registry.
10
- - Switch to setting port not local URI during initialisation.
11
- - Added option to specify port on `fair registry start`.
12
- - Added `cli-config.yaml` file validation during initialisation.
13
- - Added `fair reset` to list of commands.
14
- - Add tab completion for staging data products.
15
-
16
- # 2021-11-17 [v0.2.3](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.3)
17
- - Move handling of the user `config.yaml` file to a separate class `JobConfiguration`.
18
- - Added various fixes to improve functionality within Windows.
19
- - Move registry installation from script execution to internal function which sets up virtual environment etc.
20
- - Added a test suite to the project.
21
- - Added additional recognised identifiers for author setup from an organisation GRID and ROR.
22
-
23
- # 2021-10-06 [v0.2.2](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.2)
24
- - Update to package metadata for PyPi
25
-
26
- # 2021-10-06 [v0.2.1](https://github.com/FAIRDataPipeline/FAIR-CLI/releases/tag/v0.2.1)
27
- - Automatic starter `config.yaml` generation.
28
- - Local and Global CLI configurations.
29
- - Start/stop local registry either explicitly or during synchronisations.
30
- - Run logs available via git-like interface.
31
- - Added ability to add/remove files.
32
- - Repository style handling, acts like another git-like tool per project.
33
- - Creation of an interface for `fair` using `click`.
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