grz-cli 0.1.0__py3-none-any.whl

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.
grz_cli/upload.py ADDED
@@ -0,0 +1,243 @@
1
+ """Module for uploading encrypted submissions to a remote storage"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import logging
7
+ import math
8
+ import re
9
+ from os import PathLike
10
+ from os.path import getsize
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, override
13
+
14
+ import boto3 # type: ignore[import-untyped]
15
+ import botocore.handlers # type: ignore[import-untyped]
16
+ from boto3 import client as boto3_client # type: ignore[import-untyped]
17
+ from boto3.s3.transfer import S3Transfer, TransferConfig # type: ignore[import-untyped]
18
+ from botocore.config import Config as Boto3Config # type: ignore[import-untyped]
19
+ from tqdm.auto import tqdm
20
+
21
+ from .models.config import ConfigModel
22
+ from .progress_logging import FileProgressLogger
23
+
24
+ MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8MiB, boto3 default
25
+ MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8MiB, boto3 default
26
+ MULTIPART_MAX_CHUNKS = 1000 # CEPH S3 limit, AWS limit is 10000
27
+
28
+ if TYPE_CHECKING:
29
+ from .parser import EncryptedSubmission
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ # see discussion: https://github.com/boto/boto3/discussions/4251 for acception bucketnames with : in the name
34
+ botocore.handlers.VALID_BUCKET = re.compile(r"^[:a-zA-Z0-9.\-_]{1,255}$") # type: ignore[import-untyped]
35
+
36
+
37
+ class UploadError(Exception):
38
+ """Exception raised when an upload fails"""
39
+
40
+ pass
41
+
42
+
43
+ class UploadWorker(metaclass=abc.ABCMeta):
44
+ """Worker baseclass for uploading encrypted submissions"""
45
+
46
+ @abc.abstractmethod
47
+ def upload(self, encrypted_submission: EncryptedSubmission):
48
+ """
49
+ Upload an encrypted submission
50
+
51
+ :param encrypted_submission: The encrypted submission to upload
52
+ :raises UploadError: when the upload failed
53
+ """
54
+ raise NotImplementedError()
55
+
56
+ @abc.abstractmethod
57
+ def upload_file(self, local_file_path: str | PathLike, s3_object_id: str):
58
+ """
59
+ Upload a single file to the specified object ID
60
+ :param local_file_path: Path to the file to upload
61
+ :param s3_object_id: Remote S3 object ID under which the file should be stored
62
+ """
63
+ raise NotImplementedError()
64
+
65
+
66
+ class S3BotoUploadWorker(UploadWorker):
67
+ """Implementation of an upload worker using boto3 for S3"""
68
+
69
+ __log = log.getChild("S3BotoUploadWorker")
70
+
71
+ def __init__(
72
+ self,
73
+ config: ConfigModel,
74
+ status_file_path: str | PathLike,
75
+ threads: int = 1,
76
+ ):
77
+ """
78
+ An upload manager for S3 storage
79
+
80
+ :param config: instance of `ConfigModel`
81
+ :param status_file_path: file for storing upload state. Can be used for e.g. resumable uploads.
82
+ """
83
+ super().__init__()
84
+
85
+ self._status_file_path = Path(status_file_path)
86
+ self._config = config
87
+ self._threads = threads
88
+
89
+ self._init_s3_client()
90
+
91
+ def _init_s3_client(self):
92
+ # if user specifies empty strings, this might be an issue
93
+ def empty_str_to_none(string: str | None) -> str | None:
94
+ if string == "" or string is None:
95
+ return None
96
+ else:
97
+ return string
98
+
99
+ # configure proxies if proxy_url is defined
100
+ proxy_url = empty_str_to_none(self._config.s3_options.proxy_url)
101
+ if proxy_url is not None:
102
+ config = Boto3Config(proxies={"http": proxy_url, "https": proxy_url})
103
+ else:
104
+ config = None
105
+
106
+ # Initialize S3 client for uploading
107
+ self._s3_client: boto3.session.Session.client = boto3_client(
108
+ service_name="s3",
109
+ region_name=empty_str_to_none(self._config.s3_options.region_name),
110
+ api_version=empty_str_to_none(self._config.s3_options.api_version),
111
+ use_ssl=self._config.s3_options.use_ssl,
112
+ endpoint_url=empty_str_to_none(str(self._config.s3_options.endpoint_url)),
113
+ aws_access_key_id=empty_str_to_none(self._config.s3_options.access_key),
114
+ aws_secret_access_key=empty_str_to_none(self._config.s3_options.secret),
115
+ aws_session_token=empty_str_to_none(self._config.s3_options.session_token),
116
+ config=config,
117
+ )
118
+
119
+ @override
120
+ def upload_file(self, local_file_path, s3_object_id):
121
+ """
122
+ Upload a single file to the specified object ID
123
+ :param local_file_path: Path to the file to upload
124
+ :param s3_object_id: Remote S3 object ID under which the file should be stored
125
+ """
126
+ self.__log.info(f"Uploading {local_file_path} to {s3_object_id}...")
127
+
128
+ filesize = getsize(local_file_path)
129
+
130
+ chunksize = (
131
+ math.ceil(filesize / MULTIPART_MAX_CHUNKS)
132
+ if filesize / MULTIPART_CHUNKSIZE > MULTIPART_MAX_CHUNKS
133
+ else MULTIPART_CHUNKSIZE
134
+ )
135
+ self.__log.debug(
136
+ f"Using a chunksize of: {chunksize / 1024**2}MiB, results in {filesize/chunksize} chunks"
137
+ )
138
+
139
+ config = TransferConfig(
140
+ multipart_threshold=MULTIPART_THRESHOLD,
141
+ multipart_chunksize=chunksize,
142
+ max_concurrency=self._threads,
143
+ )
144
+
145
+ transfer = S3Transfer(self._s3_client, config)
146
+ progress_bar = tqdm(
147
+ total=filesize, unit="B", unit_scale=True, unit_divisor=1024
148
+ )
149
+ transfer.upload_file(
150
+ local_file_path,
151
+ self._config.s3_options.bucket,
152
+ s3_object_id,
153
+ callback=lambda bytes_transferred: progress_bar.update(bytes_transferred),
154
+ )
155
+
156
+ @override
157
+ def upload(self, encrypted_submission: EncryptedSubmission):
158
+ """
159
+ Upload an encrypted submission
160
+ :param encrypted_submission: The encrypted submission to upload
161
+ """
162
+ progress_logger = FileProgressLogger(self._status_file_path)
163
+ metadata_file_path, metadata_s3_object_id = (
164
+ encrypted_submission.get_metadata_file_path_and_object_id()
165
+ )
166
+ files_to_upload = encrypted_submission.get_encrypted_files_and_object_id()
167
+ files_to_upload[metadata_file_path] = metadata_s3_object_id
168
+
169
+ for file_path in files_to_upload:
170
+ if not Path(file_path).exists():
171
+ raise UploadError(f"File {file_path} does not exist")
172
+
173
+ for file_path, file_metadata in encrypted_submission.encrypted_files.items():
174
+ logged_state = progress_logger.get_state(file_path, file_metadata)
175
+ self.__log.debug("state for %s: %s", file_path, logged_state)
176
+
177
+ s3_object_id = files_to_upload[file_path]
178
+
179
+ if (logged_state is None) or not logged_state.get(
180
+ "upload_successful", False
181
+ ):
182
+ self.__log.info(
183
+ "Uploading file: '%s' -> '%s'",
184
+ str(file_path),
185
+ str(s3_object_id),
186
+ )
187
+
188
+ try:
189
+ self.upload_file(file_path, s3_object_id)
190
+
191
+ self.__log.info(f"Upload complete for {str(file_path)}. ")
192
+ progress_logger.set_state(
193
+ file_path, file_metadata, state={"upload_successful": True}
194
+ )
195
+ except Exception as e:
196
+ self.__log.error("Upload failed for '%s'", str(file_path))
197
+
198
+ progress_logger.set_state(
199
+ file_path,
200
+ file_metadata,
201
+ state={"upload_successful": False, "error": str(e)},
202
+ )
203
+
204
+ raise e
205
+ else:
206
+ self.__log.info(
207
+ "File '%s' already uploaded (at '%s')",
208
+ str(file_path),
209
+ str(s3_object_id),
210
+ )
211
+
212
+ # finally upload the metadata.json file, unconditionally:
213
+ try:
214
+ self.upload_file(metadata_file_path, metadata_s3_object_id)
215
+ self.__log.info(f"Upload complete for {str(metadata_file_path)}. ")
216
+ except Exception as e:
217
+ self.__log.error("Upload failed for '%s'", str(metadata_file_path))
218
+ raise e
219
+
220
+ def _check_for_completed_submission(self, s3_object_id: str) -> bool:
221
+ try:
222
+ return s3_object_id in self._list_keys(
223
+ self._config.s3_options.bucket, prefix=str(Path(s3_object_id).parent)
224
+ )
225
+ except Exception as e:
226
+ self.__log.warning(
227
+ "Exception occured during check for completed submission; assuming submission is incomplete.",
228
+ exc_info=e,
229
+ )
230
+ return False
231
+
232
+ # https://stackoverflow.com/a/54014862
233
+ def _list_keys(self, bucket_name, prefix="/", delimiter="/", start_after=""):
234
+ s3_paginator = self._s3_client.get_paginator("list_objects_v2")
235
+ prefix = prefix.lstrip(delimiter)
236
+ start_after = (
237
+ (start_after or prefix) if prefix.endswith(delimiter) else start_after
238
+ )
239
+ for page in s3_paginator.paginate(
240
+ Bucket=bucket_name, Prefix=prefix, StartAfter=start_after
241
+ ):
242
+ for content in page.get("Contents", ()):
243
+ yield content["Key"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 GRZ/GHGA
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,240 @@
1
+ Metadata-Version: 2.1
2
+ Name: grz-cli
3
+ Version: 0.1.0
4
+ Summary: Tool for validation, encryption and upload of MV submissions to GDCs.
5
+ Author-email: Koray Kirli <koraykirli@gmail.com>, Mathias Lesche <mathias.lesche@tu-dresden.de>, "Florian R. Hölzlwimmer" <git.ich@frhoelzlwimmer.de>, Till Hartmann <till.hartmann@bih-charite.de>, Thomas Sell <thomas.sell@bih-charite.de>
6
+ License: MIT
7
+ Project-URL: Homepage, https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli
8
+ Keywords: GRZ,GDC,S3
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: boto3~=1.35.39
16
+ Requires-Dist: click~=8.1.7
17
+ Requires-Dist: crypt4gh~=1.7
18
+ Requires-Dist: jsonschema~=4.23.0
19
+ Requires-Dist: pyyaml~=6.0.2
20
+ Requires-Dist: tqdm~=4.66.5
21
+ Requires-Dist: pydantic~=2.9.2
22
+ Requires-Dist: platformdirs~=4.3.6
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest~=8.3.3; extra == "test"
25
+ Requires-Dist: pytest-cov~=5.0.0; extra == "test"
26
+ Requires-Dist: pytest-mock~=3.14.0; extra == "test"
27
+ Requires-Dist: moto[s3]~=5.0.16; extra == "test"
28
+ Requires-Dist: pytest-sugar~=1.0.0; extra == "test"
29
+ Requires-Dist: ruff~=0.7.0; extra == "test"
30
+ Requires-Dist: numpy~=2.1.2; extra == "test"
31
+
32
+ # GRZ CLI
33
+
34
+ A command-line tool for validating, encrypting, uploading and downloading submissions to/from a GDC/GRZ (Genomrechenzentrum).
35
+
36
+ ## Table of Contents
37
+
38
+ - [Introduction](#introduction)
39
+ - [Features](#features)
40
+ - [Installation](#installation)
41
+ - [Usage](#usage)
42
+ - [Configuration](#configuration)
43
+ - [Exemplary submission procedure](#exemplary-submission-procedure)
44
+ - [Command-Line Interface](#command-line-interface)
45
+ - [Testing](#testing)
46
+ - [Contributing](#contributing)
47
+ - [License](#license)
48
+ - [Acknowledgements](#acknowledgements)
49
+
50
+ ## Introduction
51
+
52
+ This tool provides a way to validate files, encrypt/decrypt files using the [crypt4gh](https://crypt4gh.readthedocs.io/en/latest/) library and upload/download the encrypted files to an S3 bucket of a GDC/GRZ. It also logs the progress and outcomes of these operations in a metadata file.
53
+
54
+ It is recommended to have the following folder structure for a single submission:
55
+
56
+ ```
57
+ EXAMPLE_SUBMISSION
58
+ ├── files
59
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_normal.read1.fastq.gz
60
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_normal.read2.fastq.gz
61
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_normal.vcf
62
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_tumor.read1.fastq.gz
63
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_tumor.read2.fastq.gz
64
+ │ ├── aaaaaaaa00000000aaaaaaaa00000000_blood_tumor.vcf
65
+ │ ├── target_regions.bed
66
+ └── metadata
67
+ └── metadata.json
68
+ ```
69
+
70
+ The current version of the tool requires the `working_dir` to have at least as much free disk space as the total size of the data being submitted.
71
+ ## Features
72
+
73
+ - **Validation**: Validate file checksums, basic file metadata and BfArM requirements.
74
+ - **Encryption**: Encrypt files using `crypt4gh`.
75
+ - **Decryption**: Encrypt files using `crypt4gh`.
76
+ - **Upload**: Upload encrypted files directly to a GRZ either (via built-in `boto3`).
77
+ - **Download**: Download encrypted files from a GRZ (via built-in `boto3`).
78
+ - **Logging**: Log progress and results of operations
79
+
80
+ ## Installation
81
+
82
+ ### End-user
83
+ This tool uses the `conda` package manager.
84
+ If `conda` is not yet available on your system, you can install the [Miniforge conda distribution](https://github.com/conda-forge/miniforge) by running the following commands:
85
+ ```bash
86
+ curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
87
+ bash Miniforge3-$(uname)-$(uname -m).sh
88
+ ```
89
+ Next, install the `grz-cli` tool:
90
+ ```bash
91
+ # TODO update: This requires membership in the repository
92
+ # download environment.yaml from the git repository:
93
+ git archive --remote=ssh://git@codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli.git dev environment.yaml | tar -xv
94
+ # TODO update: This will work once the repository is publicly accessible:
95
+ # download environment.yaml from the git repository:
96
+ curl -L "https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli/-/raw/v0.1.0/environment.yaml?ref_type=heads" > environment.yaml
97
+
98
+ # create conda environment and activate it
99
+ conda env create -f environment.yaml -n grz-tools
100
+ conda activate grz-tools
101
+
102
+ # install the grz-cli tool
103
+ # TODO update: This requires membership in the repository
104
+ pip install "git+ssh://git@codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli.git@dev"
105
+ # TODO update: This will work once the repository is publicly accessible:
106
+ pip install "git+https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli@v0.1.0"
107
+ ```
108
+
109
+ ### Development setup
110
+ For development purposes, you can clone the repository and install the package in editable mode:
111
+
112
+ ```bash
113
+ git clone https://codebase.helmholtz.cloud/grz-mv-genomseq/grz-cli
114
+ # create conda environment and activate it
115
+ conda env create -f grz-cli/environment-dev.yaml -n grz-tools-dev
116
+ conda activate grz-tools-dev
117
+ # install the grz-cli tool
118
+ pip install -e grz-cli/
119
+ ```
120
+
121
+ ## Usage
122
+
123
+ ### Configuration
124
+ **The configuration file will be provided by your associated GRZ, please place it into `~/.config/grz-cli/config.yaml`.**
125
+
126
+ The tool requires a configuration file in YAML format to specify the S3 bucket and other options.
127
+ For an exemplary configuration, see [resources/config.yaml](resources/config.yaml).
128
+
129
+ S3 access and secret key can be listed either in the config file or as environment variable (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`).
130
+
131
+ ### Exemplary submission procedure
132
+ After preparing your submission as outlined above, you can use the following commands to validate, encrypt and upload the submission:
133
+ ```sh
134
+ # Validate the submission
135
+ grz-cli validate --submission-dir EXAMPLE_SUBMISSION
136
+
137
+ # Encrypt the submission
138
+ grz-cli encrypt --submission-dir EXAMPLE_SUBMISSION
139
+
140
+ # Upload the submission
141
+ grz-cli upload --submission-dir EXAMPLE_SUBMISSION
142
+ ```
143
+
144
+ ### Troubleshooting
145
+ **In case of issues, please re-run your commands with `grz-cli --log-level DEBUG --log-file <your-log-file.log> [...]` and submit the log file to the GRZ data steward!**
146
+
147
+ ## Command-Line Interface
148
+
149
+ `grz-cli` provides a command-line interface with the following subcommands:
150
+
151
+ ### validate
152
+
153
+ It is recommended to run this command before continuing with encryption and upload.
154
+ Progress files are stored relative to the submission directory.
155
+
156
+ - `--submission-dir`: Path to the submission directory containing both 'metadata/' and 'files/' directories [**Required**]
157
+
158
+ Example usage:
159
+
160
+ ```bash
161
+ grz_cli validate --submission-dir foo
162
+ ```
163
+
164
+ Option is for the usage at a hospital (Leistungserbringer) and GDC/GRZ.
165
+
166
+ ### encrypt
167
+
168
+ If a working directory is not provided, then the current directory is used automatically. The log-files are going to be stored in the sub-folder of the working directory.
169
+ Files are stored in a folder named `encrypted_files` as a sub-folder of the working directory.
170
+
171
+ - `-s, --submission-dir`: Path to the submission directory containing both 'metadata/' and 'files/' directories [**Required**]
172
+ - `-c, --config-file`: Path to config file [_optional_]
173
+
174
+ ```bash
175
+ grz-cli encrypt --submission-dir foo
176
+ ```
177
+
178
+ Option is for the usage at a hospital (Leistungserbringer). Please approach your GDC/GRZ for a valid config file.
179
+
180
+ ### decrypt
181
+
182
+ Decrypt a submission using the GRZ private key.
183
+
184
+ - `-s, --submission-dir`: Path to the submission directory containing both 'metadata/' and 'encrypted_files/' directories [**Required**]
185
+ - `-c,--config-file`: Path to config file [_optional_]
186
+
187
+ ```bash
188
+ grz-cli decrypt --submission-dir foo
189
+ ```
190
+
191
+ Option is for the usage at a GDC/GRZ.
192
+
193
+ ### upload
194
+
195
+ Upload the submission into a S3 structure of a GRZ.
196
+
197
+ - `-s, --submission-dir`: Path to the submission directory containing both 'metadata/' and 'encrypted_files/' directories [**Required**]
198
+ - `-c, --config-file`: Path to config file [_optional_]
199
+
200
+ Example usage:
201
+
202
+ ```bash
203
+ grz-cli upload --submission-dir foo
204
+ ```
205
+
206
+ Option is for the usage at a hospital (Leistungserbringer). Please approach your GDC/GRZ for a valid config file.
207
+
208
+ ### download
209
+
210
+ Download a submission from a GRZ
211
+
212
+ - `-s, --submission-id`: S3 submission prefix [**Required**]
213
+ - `-o, --output-dir`: Path to the target submission output directory [**Required**]
214
+ - `-c, --config-file`: Path to config file [_optional_]
215
+
216
+ Example usage:
217
+
218
+ ```bash
219
+ grz-cli download --submission-id foo --output-dir bar
220
+ ```
221
+
222
+ Option is for the usage at a GDC/GRZ.
223
+
224
+ ## Testing
225
+
226
+ To run the tests, navigate to the root directory of your project and invoke `pytest`.
227
+ Alternatively, install `uv` and `tox` and run `uv run tox`.
228
+
229
+ ## Contributing
230
+
231
+ <!-- Add details about how others can contribute to the project -->
232
+
233
+ ## License
234
+
235
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
236
+
237
+ ## Acknowledgements
238
+
239
+ Parts of `cryp4gh` code is used in modified form
240
+
@@ -0,0 +1,22 @@
1
+ __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ grz_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ grz_cli/cli.py,sha256=hByePV42Vc5QFjXEdbxQrEc0-ShZKqmRmxRVsZp9yks,8403
4
+ grz_cli/constants.py,sha256=Tyc_eZNDJAdpFiok8V4N2XHX4W0SV0FcBCar1pTA7xw,1080
5
+ grz_cli/download.py,sha256=1XkjhLdG0qCAORENBPD73NnXki5dffMSSXmfzXkc3_U,9001
6
+ grz_cli/fastq_validation.py,sha256=4muUZMwB-HK5LuhfECJMY_R3OXDH0sBZi81-63uX_XI,3882
7
+ grz_cli/file_operations.py,sha256=scbjpIoUIDcqeMXkSVAQlwZvaB6MK8oRmsrB7saQZyg,12356
8
+ grz_cli/logging_setup.py,sha256=1x67mg9DATOkIRR0NDBBC_I2TICpZPF1Ntz1ImPMfZM,1817
9
+ grz_cli/parser.py,sha256=RfmxySijdm53h21AYZJdr9G8Ag_1SvrH5FqsUaesxJ8,28343
10
+ grz_cli/progress_logging.py,sha256=GMUsPPtLYSk8rlI0uLvVEOr4_nEe0cHiSqKlO84esWA,6393
11
+ grz_cli/upload.py,sha256=OOvhMi-V3bV1bq0y7qgh_xJgTFujpa7VrrnguKf01m8,9074
12
+ grz_cli/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ grz_cli/models/config.py,sha256=-ZH_R1kGMHwGGdP1trclmj9PXX73CIPJmeJ7uVyPMtM,2689
14
+ grz_cli/models/v1_0_0/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ grz_cli/models/v1_0_0/metadata.py,sha256=k2wbO0A2Pupo0RwDyYJkP6GtQspF2pVvW7WAj8V-P08,22079
16
+ grz_cli/resources/grz-schema.json,sha256=S3iaEdRXP_4ZaSxR1ivVPT4pYW7E5674YYRqtxqt6Bw,22104
17
+ grz_cli-0.1.0.dist-info/LICENSE,sha256=3hMrOST0E1zpP4Y_uwI07jQdkCdcWMVdpkgewbx8cd4,1065
18
+ grz_cli-0.1.0.dist-info/METADATA,sha256=trhOfr7j-tQeMWnGx-Uy60l9Wup2LRkLkzzXR0q4nTQ,8977
19
+ grz_cli-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
20
+ grz_cli-0.1.0.dist-info/entry_points.txt,sha256=I60_QhAOC5qg07EKxqjlP5eSu_VWl-KEx-7Bh8BUPyk,44
21
+ grz_cli-0.1.0.dist-info/top_level.txt,sha256=oVoiozAelI4VOGy9JLygMnPAkwNw4ry13cwuzMb8I-A,17
22
+ grz_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grz-cli = grz_cli.cli:cli
@@ -0,0 +1,2 @@
1
+ __init__
2
+ grz_cli