ebrains-bucket-sync 0.1.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.
- ebrains_bucket_sync-0.1.0/.github/workflows/publish.yml +32 -0
- ebrains_bucket_sync-0.1.0/.github/workflows/tests.yml +34 -0
- ebrains_bucket_sync-0.1.0/.gitignore +9 -0
- ebrains_bucket_sync-0.1.0/LICENSE +21 -0
- ebrains_bucket_sync-0.1.0/PKG-INFO +97 -0
- ebrains_bucket_sync-0.1.0/README.md +75 -0
- ebrains_bucket_sync-0.1.0/pyproject.toml +59 -0
- ebrains_bucket_sync-0.1.0/spec/README.md +63 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/exclude/double-star-crosses-folders.json +6 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/exclude/names-at-any-depth-and-anchored-paths.json +6 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/exclude/regular-expression-characters-are-literal.json +6 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/exclude/trailing-slash-like-gitignore.json +6 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/by-checksum-compares-content-of-same-size.json +19 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/by-size-ignores-time.json +7 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/copies-file-changed-after-target.json +19 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/copies-new-and-changed-keeps-extraneous.json +20 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/deletes-extraneous-when-asked.json +13 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/empty-sides.json +7 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/leaves-file-of-unknown-time.json +16 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/plan/size-decides-before-checksum-and-time.json +7 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/refusal/deletions-within-limit.json +11 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/refusal/empty-source-would-empty-target.json +10 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/refusal/more-deletions-than-allowed.json +11 -0
- ebrains_bucket_sync-0.1.0/spec/fixtures/refusal/no-deletions-planned.json +7 -0
- ebrains_bucket_sync-0.1.0/spec/sync-plan.schema.json +42 -0
- ebrains_bucket_sync-0.1.0/spec/sync-policy.schema.json +29 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/__init__.py +44 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/auth.py +291 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/cli.py +257 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/engine.py +284 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/exclude.py +69 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/local.py +57 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/model.py +147 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/paths.py +53 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/plan.py +137 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/planfile.py +50 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/remote.py +93 -0
- ebrains_bucket_sync-0.1.0/src/ebrains_bucket_sync/storage.py +228 -0
- ebrains_bucket_sync-0.1.0/tests/__init__.py +0 -0
- ebrains_bucket_sync-0.1.0/tests/conftest.py +108 -0
- ebrains_bucket_sync-0.1.0/tests/live/__init__.py +0 -0
- ebrains_bucket_sync-0.1.0/tests/live/test_push_live.py +90 -0
- ebrains_bucket_sync-0.1.0/tests/test_auth.py +337 -0
- ebrains_bucket_sync-0.1.0/tests/test_cli.py +166 -0
- ebrains_bucket_sync-0.1.0/tests/test_conformance.py +58 -0
- ebrains_bucket_sync-0.1.0/tests/test_engine.py +299 -0
- ebrains_bucket_sync-0.1.0/tests/test_exclude.py +55 -0
- ebrains_bucket_sync-0.1.0/tests/test_local.py +35 -0
- ebrains_bucket_sync-0.1.0/tests/test_plan.py +143 -0
- ebrains_bucket_sync-0.1.0/tests/test_remote.py +81 -0
- ebrains_bucket_sync-0.1.0/tests/test_storage.py +267 -0
- ebrains_bucket_sync-0.1.0/uv.lock +784 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI when a GitHub release is published, through PyPI trusted
|
|
4
|
+
# publishing: PyPI accepts the OIDC token of this workflow, so no API token is
|
|
5
|
+
# stored. The release tag must be "v" followed by the version in pyproject.toml.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment:
|
|
14
|
+
name: pypi
|
|
15
|
+
url: https://pypi.org/p/ebrains-bucket-sync
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
id-token: write
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: astral-sh/setup-uv@v6
|
|
22
|
+
- name: Check that the tag matches the package version
|
|
23
|
+
run: |
|
|
24
|
+
version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
|
|
25
|
+
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
|
|
26
|
+
echo "::error::Release tag $GITHUB_REF_NAME does not match version $version in pyproject.toml (expected v$version)."
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
- run: uv sync --dev
|
|
30
|
+
- run: uv run pytest
|
|
31
|
+
- run: uv build
|
|
32
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: uv sync --dev
|
|
21
|
+
- run: uv run ruff check .
|
|
22
|
+
- run: uv run ruff format --check .
|
|
23
|
+
- run: uv run pytest
|
|
24
|
+
|
|
25
|
+
# The one check the ruleset requires, so that adding a Python version to the
|
|
26
|
+
# matrix does not need a ruleset change. It must run even when a test job
|
|
27
|
+
# fails, because a required check that is skipped counts as passed.
|
|
28
|
+
tests-passed:
|
|
29
|
+
if: always()
|
|
30
|
+
needs: test
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- if: needs.test.result != 'success'
|
|
34
|
+
run: exit 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 University of Oslo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ebrains-bucket-sync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sync a local folder to an EBRAINS Data Proxy bucket, sending only new and changed files
|
|
5
|
+
Project-URL: Repository, https://github.com/ehennestad/ebrains-bucket-sync
|
|
6
|
+
Author-email: ehennestad <ehennestad@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: EBRAINS,bucket,data-proxy,sync
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: click>=8.1
|
|
18
|
+
Requires-Dist: ebrains-drive<0.7.1,>=0.7.0
|
|
19
|
+
Requires-Dist: platformdirs>=3.0
|
|
20
|
+
Requires-Dist: requests>=2.28
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ebrains-bucket-sync
|
|
24
|
+
|
|
25
|
+
Sync a local folder to an EBRAINS Data Proxy bucket, the way rsync does: only new and changed files are uploaded, so a sync that is interrupted picks up where it stopped when run again.
|
|
26
|
+
|
|
27
|
+
The sync rules are shared with the `ebrains.bucket.sync` functions of the [EBRAINS MATLAB toolbox](https://github.com/ehennestad/EBRAINS-MATLAB). Both follow the contract in [spec/README.md](https://github.com/ehennestad/ebrains-bucket-sync/blob/main/spec/README.md) and pass the same fixtures in `spec/fixtures`, so a plan is the same whichever tool makes it.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv tool install ebrains-bucket-sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or with pipx:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install ebrains-bucket-sync
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
or, for development:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/ehennestad/ebrains-bucket-sync
|
|
45
|
+
cd ebrains-bucket-sync
|
|
46
|
+
uv sync --dev
|
|
47
|
+
uv run pytest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
Log in once. A link opens the EBRAINS login in the browser, and the login is kept for later runs:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ebrains-bucket-sync login
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See what a sync would do, then run it:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ebrains-bucket-sync push results my-bucket --prefix results --dry-run
|
|
62
|
+
ebrains-bucket-sync push results my-bucket --prefix results
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Object names are the paths relative to the local folder. Files the bucket already has, with the same size and uploaded after the local file was last changed, are not sent again. `--comparison Size` ignores the times, and `--comparison Checksum` compares the MD5 of every file of the same size.
|
|
66
|
+
|
|
67
|
+
`--delete` also deletes the objects that the local folder does not have, which makes the bucket (or the folder of it) an exact mirror. A sync refuses to empty a bucket from an empty folder, and `--max-delete N` stops it before it deletes more than N objects. Nothing is deleted after an upload failed.
|
|
68
|
+
|
|
69
|
+
`--exclude PATTERN` leaves out paths that match a wildcard pattern, with the rules of a `.gitignore` file: `*.tmp` in every folder, `.git/` for that folder wherever it is, `raw/scratch` for that path from the root. `--plan-file plan.json` writes the plan and the outcome as JSON.
|
|
70
|
+
|
|
71
|
+
An upload that fails does not stop the sync. The other files are uploaded, nothing is deleted, the command exits with status 1, and running it again retries the failed files.
|
|
72
|
+
|
|
73
|
+
From Python:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from ebrains_bucket_sync import (
|
|
77
|
+
DeviceFlowAuthenticator,
|
|
78
|
+
EbrainsDriveStorage,
|
|
79
|
+
SyncOptions,
|
|
80
|
+
sync_to_bucket,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
storage = EbrainsDriveStorage(DeviceFlowAuthenticator())
|
|
84
|
+
results = sync_to_bucket(
|
|
85
|
+
"results", "my-bucket", storage, SyncOptions(prefix="results", delete=True)
|
|
86
|
+
)
|
|
87
|
+
for result in results:
|
|
88
|
+
print(result.path, result.action, result.reason, result.status)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Authentication
|
|
92
|
+
|
|
93
|
+
The login uses the OAuth device flow with the same OIDC client as the MATLAB toolbox, so both tools show up as one application in your EBRAINS account. The tokens are kept in the user's configuration folder, in a file only the user can read, and the access token is renewed from the refresh token without a new login for as long as the refresh token lasts. In CI, set `EBRAINS_BUCKET_SYNC_TOKEN` to an access token instead.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
The live tests in `tests/live` run against a real bucket and are skipped unless `EBRAINS_BUCKET_SYNC_TEST_BUCKET` names one. Everything else runs offline against an in-memory bucket.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# ebrains-bucket-sync
|
|
2
|
+
|
|
3
|
+
Sync a local folder to an EBRAINS Data Proxy bucket, the way rsync does: only new and changed files are uploaded, so a sync that is interrupted picks up where it stopped when run again.
|
|
4
|
+
|
|
5
|
+
The sync rules are shared with the `ebrains.bucket.sync` functions of the [EBRAINS MATLAB toolbox](https://github.com/ehennestad/EBRAINS-MATLAB). Both follow the contract in [spec/README.md](https://github.com/ehennestad/ebrains-bucket-sync/blob/main/spec/README.md) and pass the same fixtures in `spec/fixtures`, so a plan is the same whichever tool makes it.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install ebrains-bucket-sync
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
or with pipx:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx install ebrains-bucket-sync
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or, for development:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone https://github.com/ehennestad/ebrains-bucket-sync
|
|
23
|
+
cd ebrains-bucket-sync
|
|
24
|
+
uv sync --dev
|
|
25
|
+
uv run pytest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
Log in once. A link opens the EBRAINS login in the browser, and the login is kept for later runs:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
ebrains-bucket-sync login
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See what a sync would do, then run it:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ebrains-bucket-sync push results my-bucket --prefix results --dry-run
|
|
40
|
+
ebrains-bucket-sync push results my-bucket --prefix results
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Object names are the paths relative to the local folder. Files the bucket already has, with the same size and uploaded after the local file was last changed, are not sent again. `--comparison Size` ignores the times, and `--comparison Checksum` compares the MD5 of every file of the same size.
|
|
44
|
+
|
|
45
|
+
`--delete` also deletes the objects that the local folder does not have, which makes the bucket (or the folder of it) an exact mirror. A sync refuses to empty a bucket from an empty folder, and `--max-delete N` stops it before it deletes more than N objects. Nothing is deleted after an upload failed.
|
|
46
|
+
|
|
47
|
+
`--exclude PATTERN` leaves out paths that match a wildcard pattern, with the rules of a `.gitignore` file: `*.tmp` in every folder, `.git/` for that folder wherever it is, `raw/scratch` for that path from the root. `--plan-file plan.json` writes the plan and the outcome as JSON.
|
|
48
|
+
|
|
49
|
+
An upload that fails does not stop the sync. The other files are uploaded, nothing is deleted, the command exits with status 1, and running it again retries the failed files.
|
|
50
|
+
|
|
51
|
+
From Python:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from ebrains_bucket_sync import (
|
|
55
|
+
DeviceFlowAuthenticator,
|
|
56
|
+
EbrainsDriveStorage,
|
|
57
|
+
SyncOptions,
|
|
58
|
+
sync_to_bucket,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
storage = EbrainsDriveStorage(DeviceFlowAuthenticator())
|
|
62
|
+
results = sync_to_bucket(
|
|
63
|
+
"results", "my-bucket", storage, SyncOptions(prefix="results", delete=True)
|
|
64
|
+
)
|
|
65
|
+
for result in results:
|
|
66
|
+
print(result.path, result.action, result.reason, result.status)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Authentication
|
|
70
|
+
|
|
71
|
+
The login uses the OAuth device flow with the same OIDC client as the MATLAB toolbox, so both tools show up as one application in your EBRAINS account. The tokens are kept in the user's configuration folder, in a file only the user can read, and the access token is renewed from the refresh token without a new login for as long as the refresh token lasts. In CI, set `EBRAINS_BUCKET_SYNC_TOKEN` to an access token instead.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
The live tests in `tests/live` run against a real bucket and are skipped unless `EBRAINS_BUCKET_SYNC_TEST_BUCKET` names one. Everything else runs offline against an in-memory bucket.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ebrains-bucket-sync"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sync a local folder to an EBRAINS Data Proxy bucket, sending only new and changed files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
authors = [{ name = "ehennestad", email = "ehennestad@gmail.com" }]
|
|
10
|
+
keywords = ["EBRAINS", "data-proxy", "sync", "bucket"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Topic :: System :: Archiving :: Mirroring",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
# storage.object_url_path passes ebrains_drive percent-encoded object names,
|
|
21
|
+
# which relies on ebrains_drive putting them into URLs as given. A release
|
|
22
|
+
# that encodes them itself (HumanBrainProject/ebrains-storage#60) would encode
|
|
23
|
+
# them twice, so newer versions need that encoding removed first.
|
|
24
|
+
"ebrains-drive>=0.7.0,<0.7.1",
|
|
25
|
+
"requests>=2.28",
|
|
26
|
+
"click>=8.1",
|
|
27
|
+
"platformdirs>=3.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
ebrains-bucket-sync = "ebrains_bucket_sync.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Repository = "https://github.com/ehennestad/ebrains-bucket-sync"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.0",
|
|
39
|
+
"ruff>=0.5",
|
|
40
|
+
"jsonschema>=4.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["hatchling"]
|
|
45
|
+
build-backend = "hatchling.build"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/ebrains_bucket_sync"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py310"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
markers = ["live: needs an EBRAINS login and a test bucket (EBRAINS_BUCKET_SYNC_TEST_BUCKET)"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Sync contract
|
|
2
|
+
|
|
3
|
+
The rules that decide what a sync does, shared by this package and the `ebrains.bucket.sync` functions of the EBRAINS MATLAB toolbox. The Python planner in `src/ebrains_bucket_sync/plan.py` is the reference implementation. Both implementations must pass the fixtures in `fixtures/`, which is what keeps them in step: a change to the rules is a change to the fixtures first.
|
|
4
|
+
|
|
5
|
+
## File entries
|
|
6
|
+
|
|
7
|
+
Each side of a sync is a list of file entries:
|
|
8
|
+
|
|
9
|
+
| field | meaning |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `path` | Path relative to the synced folder, with `/` separators. |
|
|
12
|
+
| `bytes` | Size of the file. |
|
|
13
|
+
| `modified_time` | Time of the last change, in UTC. Unknown where it cannot be read. For an object, the time it was uploaded. |
|
|
14
|
+
| `hash` | Lowercase MD5 checksum. Unknown (`""`) where it is not reported, or where it cannot be the checksum of the content: a multipart upload (`<md5>-<parts>`) or an object above 5 GiB. |
|
|
15
|
+
|
|
16
|
+
Folder placeholders are not entries: an object whose name ends with `/`, whose content type starts with `application/directory`, or below which other objects are named. An object whose name has an empty, `.` or `..` segment, or a backslash, is left out with a warning.
|
|
17
|
+
|
|
18
|
+
## Plan
|
|
19
|
+
|
|
20
|
+
`plan(source, target, comparison, delete)` gives one row per path on either side, sorted by path, with `action` (`copy`, `delete`, `none`), `reason` and `bytes`.
|
|
21
|
+
|
|
22
|
+
For a path of the source:
|
|
23
|
+
|
|
24
|
+
1. Not in the target: `copy`, reason `new`.
|
|
25
|
+
2. Sizes differ: `copy`, reason `size`.
|
|
26
|
+
3. `comparison` is `Checksum` and both checksums are known: `copy` with reason `checksum` when they differ, else `none` with reason `unchanged`.
|
|
27
|
+
4. `comparison` is `Size`: `none`, `unchanged`.
|
|
28
|
+
5. Either time unknown: `none`, `unchanged`.
|
|
29
|
+
6. Source time later than target time by more than 2 seconds: `copy`, reason `newer`.
|
|
30
|
+
7. Otherwise `none`, `unchanged`.
|
|
31
|
+
|
|
32
|
+
For a path only the target has: reason `extraneous`, action `delete` when `delete` is set and `none` otherwise. `bytes` is the size of the source file, or of the target file for an extraneous path.
|
|
33
|
+
|
|
34
|
+
The 2-second tolerance covers the time resolution of FAT file systems and the fraction of a second the listing times lose.
|
|
35
|
+
|
|
36
|
+
## Refusal
|
|
37
|
+
|
|
38
|
+
Before anything is changed, a run with planned deletions stops when:
|
|
39
|
+
|
|
40
|
+
- the source has no files (`EmptySource`): the sync would empty the target, which is more often a wrong folder or prefix than what is wanted;
|
|
41
|
+
- the plan deletes more than `max_delete` files (`TooManyDeletions`).
|
|
42
|
+
|
|
43
|
+
A dry run reports the refusal instead of stopping.
|
|
44
|
+
|
|
45
|
+
## Execution
|
|
46
|
+
|
|
47
|
+
Copies run first. Nothing is deleted after a copy failed, since a failure may mean the source was listed wrongly or the connection is lost. A failed copy does not stop the other copies, and is reported with its reason. Running the sync again retries it.
|
|
48
|
+
|
|
49
|
+
## Exclude patterns
|
|
50
|
+
|
|
51
|
+
Patterns are applied to both sides before planning, so excluded files are neither transferred nor deleted. `*` matches any characters except `/`, `**` any characters, `?` one character except `/`. A pattern without `/` matches a path segment at any depth, and a matched folder excludes everything below it. A pattern with `/` is matched from the root of the synced folder, and also excludes everything below a matched folder. Leading and trailing `/` are ignored.
|
|
52
|
+
|
|
53
|
+
## Fixtures
|
|
54
|
+
|
|
55
|
+
`fixtures/plan/*.json`: `policy` (`comparison`, `delete`), `source` and `target` entry lists, and the `expected` plan rows. `modified_time` is ISO 8601 UTC or null; `hash` may be omitted.
|
|
56
|
+
|
|
57
|
+
`fixtures/exclude/*.json`: `patterns`, `paths`, and `expected_kept`, in order.
|
|
58
|
+
|
|
59
|
+
`fixtures/refusal/*.json`: a `plan`, `n_source_files`, `max_delete` (null for no limit) and `expected_code` (null for no refusal).
|
|
60
|
+
|
|
61
|
+
## Plan file
|
|
62
|
+
|
|
63
|
+
`sync-plan.schema.json` describes the JSON a command writes with `--plan-file`: the policy, and each action with its outcome. `sync-policy.schema.json` describes the policy part on its own.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "** matches any characters, \"/\" included, where * stops at a folder separator. A ** between two slashes stands for at least one folder, unlike in a .gitignore file, so \"logs/**/*.log\" leaves \"logs/a.log\" in.",
|
|
3
|
+
"patterns": ["logs/**/*.log"],
|
|
4
|
+
"paths": ["logs/a.log", "logs/x/y/b.log", "logs/c.txt", "other/logs/d.log"],
|
|
5
|
+
"expected_kept": ["logs/a.log", "logs/c.txt", "other/logs/d.log"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A pattern without a slash matches a name at any depth and all below it; a pattern with a slash is matched from the root.",
|
|
3
|
+
"patterns": ["*.tmp", ".git", "/raw/scratch"],
|
|
4
|
+
"paths": ["a.tmp", "sub/b.tmp", "sub/b.tmpx", ".git/config", "src/.git/HEAD", "raw/scratch/x.dat", "other/raw/scratch/y.dat", "keep.txt"],
|
|
5
|
+
"expected_kept": ["sub/b.tmpx", "other/raw/scratch/y.dat", "keep.txt"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A trailing slash is ignored, so \".git/\" and \"build/\" leave out those folders wherever they are.",
|
|
3
|
+
"patterns": [".git/", "build/", "/raw/scratch/"],
|
|
4
|
+
"paths": [".git/config", "src/.git/HEAD", "build/a.o", "raw/scratch/x.dat", "other/raw/scratch/y.dat", "keep.txt"],
|
|
5
|
+
"expected_kept": ["other/raw/scratch/y.dat", "keep.txt"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "With the Checksum comparison, the checksum decides where it is known on both sides, even against the time, and the time decides where it is not. Checksums compare without regard to case.",
|
|
3
|
+
"policy": { "comparison": "Checksum", "delete": false },
|
|
4
|
+
"source": [
|
|
5
|
+
{ "path": "edited.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z", "hash": "aaa" },
|
|
6
|
+
{ "path": "same.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z", "hash": "bbb" },
|
|
7
|
+
{ "path": "unknown.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z", "hash": "" }
|
|
8
|
+
],
|
|
9
|
+
"target": [
|
|
10
|
+
{ "path": "edited.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z", "hash": "ccc" },
|
|
11
|
+
{ "path": "same.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z", "hash": "BBB" },
|
|
12
|
+
{ "path": "unknown.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z", "hash": "ddd" }
|
|
13
|
+
],
|
|
14
|
+
"expected": [
|
|
15
|
+
{ "path": "edited.txt", "action": "copy", "reason": "checksum", "bytes": 1 },
|
|
16
|
+
{ "path": "same.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
17
|
+
{ "path": "unknown.txt", "action": "copy", "reason": "newer", "bytes": 1 }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "With the Size comparison, a newer source file of the same size is unchanged.",
|
|
3
|
+
"policy": { "comparison": "Size", "delete": false },
|
|
4
|
+
"source": [{ "path": "a.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z" }],
|
|
5
|
+
"target": [{ "path": "a.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z" }],
|
|
6
|
+
"expected": [{ "path": "a.txt", "action": "none", "reason": "unchanged", "bytes": 1 }]
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A source file changed after the target was written is copied. A difference within 2 seconds, or a target newer than the source, is unchanged.",
|
|
3
|
+
"policy": { "comparison": "SizeAndTime", "delete": false },
|
|
4
|
+
"source": [
|
|
5
|
+
{ "path": "edited.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z" },
|
|
6
|
+
{ "path": "within-tolerance.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:01Z" },
|
|
7
|
+
{ "path": "target-newer.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z" }
|
|
8
|
+
],
|
|
9
|
+
"target": [
|
|
10
|
+
{ "path": "edited.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z" },
|
|
11
|
+
{ "path": "within-tolerance.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z" },
|
|
12
|
+
{ "path": "target-newer.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z" }
|
|
13
|
+
],
|
|
14
|
+
"expected": [
|
|
15
|
+
{ "path": "edited.txt", "action": "copy", "reason": "newer", "bytes": 1 },
|
|
16
|
+
{ "path": "target-newer.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
17
|
+
{ "path": "within-tolerance.txt", "action": "none", "reason": "unchanged", "bytes": 1 }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A new file and a file of another size are copied; a file of the same size is unchanged; a file only the target has is kept.",
|
|
3
|
+
"policy": { "comparison": "SizeAndTime", "delete": false },
|
|
4
|
+
"source": [
|
|
5
|
+
{ "path": "new.txt", "bytes": 1 },
|
|
6
|
+
{ "path": "same.txt", "bytes": 2 },
|
|
7
|
+
{ "path": "grown.txt", "bytes": 30 }
|
|
8
|
+
],
|
|
9
|
+
"target": [
|
|
10
|
+
{ "path": "same.txt", "bytes": 2 },
|
|
11
|
+
{ "path": "grown.txt", "bytes": 3 },
|
|
12
|
+
{ "path": "extra.txt", "bytes": 4 }
|
|
13
|
+
],
|
|
14
|
+
"expected": [
|
|
15
|
+
{ "path": "extra.txt", "action": "none", "reason": "extraneous", "bytes": 4 },
|
|
16
|
+
{ "path": "grown.txt", "action": "copy", "reason": "size", "bytes": 30 },
|
|
17
|
+
{ "path": "new.txt", "action": "copy", "reason": "new", "bytes": 1 },
|
|
18
|
+
{ "path": "same.txt", "action": "none", "reason": "unchanged", "bytes": 2 }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "With delete, a file only the target has is deleted.",
|
|
3
|
+
"policy": { "comparison": "SizeAndTime", "delete": true },
|
|
4
|
+
"source": [{ "path": "a.txt", "bytes": 1 }],
|
|
5
|
+
"target": [
|
|
6
|
+
{ "path": "a.txt", "bytes": 1 },
|
|
7
|
+
{ "path": "extra.txt", "bytes": 4 }
|
|
8
|
+
],
|
|
9
|
+
"expected": [
|
|
10
|
+
{ "path": "a.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
11
|
+
{ "path": "extra.txt", "action": "delete", "reason": "extraneous", "bytes": 4 }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A file whose time is unknown on either side is left as it is.",
|
|
3
|
+
"policy": { "comparison": "SizeAndTime", "delete": false },
|
|
4
|
+
"source": [
|
|
5
|
+
{ "path": "a.txt", "bytes": 1, "modified_time": "2024-01-01T00:00:00Z" },
|
|
6
|
+
{ "path": "b.txt", "bytes": 1, "modified_time": null }
|
|
7
|
+
],
|
|
8
|
+
"target": [
|
|
9
|
+
{ "path": "a.txt", "bytes": 1, "modified_time": null },
|
|
10
|
+
{ "path": "b.txt", "bytes": 1, "modified_time": "2000-01-01T00:00:00Z" }
|
|
11
|
+
],
|
|
12
|
+
"expected": [
|
|
13
|
+
{ "path": "a.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
14
|
+
{ "path": "b.txt", "action": "none", "reason": "unchanged", "bytes": 1 }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A size difference is reported as such, before a checksum or time difference.",
|
|
3
|
+
"policy": { "comparison": "Checksum", "delete": false },
|
|
4
|
+
"source": [{ "path": "a.txt", "bytes": 1, "modified_time": "2024-01-02T00:00:00Z", "hash": "aaa" }],
|
|
5
|
+
"target": [{ "path": "a.txt", "bytes": 2, "modified_time": "2024-01-01T00:00:00Z", "hash": "bbb" }],
|
|
6
|
+
"expected": [{ "path": "a.txt", "action": "copy", "reason": "size", "bytes": 1 }]
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Deletions up to max_delete are allowed.",
|
|
3
|
+
"plan": [
|
|
4
|
+
{ "path": "keep.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
5
|
+
{ "path": "a.txt", "action": "delete", "reason": "extraneous", "bytes": 1 },
|
|
6
|
+
{ "path": "b.txt", "action": "delete", "reason": "extraneous", "bytes": 1 }
|
|
7
|
+
],
|
|
8
|
+
"n_source_files": 1,
|
|
9
|
+
"max_delete": 2,
|
|
10
|
+
"expected_code": null
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "An empty source with deletions is refused.",
|
|
3
|
+
"plan": [
|
|
4
|
+
{ "path": "a.txt", "action": "delete", "reason": "extraneous", "bytes": 1 },
|
|
5
|
+
{ "path": "b.txt", "action": "delete", "reason": "extraneous", "bytes": 1 }
|
|
6
|
+
],
|
|
7
|
+
"n_source_files": 0,
|
|
8
|
+
"max_delete": null,
|
|
9
|
+
"expected_code": "EmptySource"
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "More deletions than max_delete are refused; as many are allowed.",
|
|
3
|
+
"plan": [
|
|
4
|
+
{ "path": "keep.txt", "action": "none", "reason": "unchanged", "bytes": 1 },
|
|
5
|
+
{ "path": "a.txt", "action": "delete", "reason": "extraneous", "bytes": 1 },
|
|
6
|
+
{ "path": "b.txt", "action": "delete", "reason": "extraneous", "bytes": 1 }
|
|
7
|
+
],
|
|
8
|
+
"n_source_files": 1,
|
|
9
|
+
"max_delete": 1,
|
|
10
|
+
"expected_code": "TooManyDeletions"
|
|
11
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/ehennestad/ebrains-bucket-sync/spec/sync-plan.schema.json",
|
|
4
|
+
"title": "Sync plan",
|
|
5
|
+
"description": "The plan of a sync and, after a run, the outcome of each action.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"format": { "const": "ebrains-bucket-sync-plan" },
|
|
9
|
+
"version": { "const": 1 },
|
|
10
|
+
"direction": { "enum": ["to_bucket", "from_bucket"] },
|
|
11
|
+
"bucket": { "type": "string" },
|
|
12
|
+
"prefix": {
|
|
13
|
+
"description": "Folder of the bucket, ending with \"/\", or \"\" for the root.",
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"local_folder": { "type": "string" },
|
|
17
|
+
"policy": { "$ref": "sync-policy.schema.json" },
|
|
18
|
+
"dry_run": { "type": "boolean" },
|
|
19
|
+
"refusal": {
|
|
20
|
+
"description": "Why the run stopped, or would stop, before changing anything; \"\" otherwise.",
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"actions": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"path": { "type": "string" },
|
|
29
|
+
"action": { "enum": ["upload", "download", "delete", "none"] },
|
|
30
|
+
"reason": { "enum": ["new", "size", "newer", "checksum", "unchanged", "extraneous"] },
|
|
31
|
+
"bytes": { "type": "integer", "minimum": 0 },
|
|
32
|
+
"status": { "enum": ["done", "failed", "skipped", "planned", ""] },
|
|
33
|
+
"message": { "type": "string" }
|
|
34
|
+
},
|
|
35
|
+
"required": ["path", "action", "reason", "bytes", "status", "message"],
|
|
36
|
+
"additionalProperties": false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"required": ["format", "version", "direction", "bucket", "prefix", "local_folder", "policy", "dry_run", "refusal", "actions"],
|
|
41
|
+
"additionalProperties": false
|
|
42
|
+
}
|