grzctl 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.
grzctl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ GRZ Control CLI for GRZ administrators.
3
+ """
4
+
5
+ __version__ = "0.1.0"
grzctl/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ CLI module for handling command-line interface operations for GRZ administrators.
3
+ """
4
+
5
+ PACKAGE_ROOT = "grzctl"
6
+
7
+ import logging
8
+ import logging.config
9
+ from importlib.metadata import version
10
+
11
+ import click
12
+ import grz_pydantic_models.submission.metadata
13
+ from grz_cli.commands.encrypt import encrypt
14
+ from grz_cli.commands.submit import submit
15
+ from grz_cli.commands.upload import upload
16
+ from grz_cli.commands.validate import validate
17
+ from grz_common.logging_setup import add_filelogger
18
+
19
+ from .commands.archive import archive
20
+ from .commands.clean import clean
21
+ from .commands.consent import consent
22
+ from .commands.db import db
23
+ from .commands.decrypt import decrypt
24
+ from .commands.download import download
25
+ from .commands.list_submissions import list_submissions
26
+ from .commands.pruefbericht import pruefbericht
27
+
28
+ log = logging.getLogger(PACKAGE_ROOT + ".cli")
29
+
30
+
31
+ class OrderedGroup(click.Group):
32
+ """
33
+ A click Group that keeps track of the order in which commands are added.
34
+ """
35
+
36
+ def list_commands(self, ctx):
37
+ """Return the list of commands in the order they were added."""
38
+ return list(self.commands.keys())
39
+
40
+
41
+ def build_cli():
42
+ """
43
+ Factory for building the CLI application.
44
+ """
45
+
46
+ @click.group(
47
+ cls=OrderedGroup,
48
+ help="GRZ Control CLI for GRZ administrators.",
49
+ )
50
+ @click.version_option(
51
+ version=version("grzctl"),
52
+ prog_name="grzctl",
53
+ message=f"%(prog)s v%(version)s (metadata schema versions: {', '.join(grz_pydantic_models.submission.metadata.get_supported_versions())})",
54
+ )
55
+ @click.option("--log-file", metavar="FILE", type=str, help="Path to log file")
56
+ @click.option(
57
+ "--log-level",
58
+ default="INFO",
59
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
60
+ help="Set the log level (default: INFO)",
61
+ )
62
+ def cli(log_file: str | None = None, log_level: str = "INFO"):
63
+ """
64
+ Command-line interface function for setting up logging.
65
+
66
+ :param log_file: Path to the log file. If provided, a file logger will be added.
67
+ :param log_level: Log level for the logger. It should be one of the following:
68
+ DEBUG, INFO, WARNING, ERROR, CRITICAL.
69
+ """
70
+ if log_file:
71
+ add_filelogger(
72
+ log_file,
73
+ log_level.upper(),
74
+ ) # Add file logger
75
+
76
+ # show only time and log level in STDOUT
77
+ logging.basicConfig(
78
+ format="%(asctime)s - %(levelname)s - %(message)s",
79
+ )
80
+
81
+ # set the log level for this package
82
+ logging.getLogger(PACKAGE_ROOT).setLevel(log_level.upper())
83
+
84
+ log.debug("Logging setup complete.")
85
+
86
+ # For convenience, include grz-cli commands as well.
87
+ cli.add_command(validate)
88
+ cli.add_command(encrypt)
89
+ cli.add_command(upload)
90
+ cli.add_command(submit)
91
+
92
+ cli.add_command(list_submissions, name="list")
93
+ cli.add_command(download)
94
+ cli.add_command(decrypt)
95
+ cli.add_command(archive)
96
+ cli.add_command(clean)
97
+ cli.add_command(consent)
98
+ cli.add_command(pruefbericht)
99
+ cli.add_command(db)
100
+
101
+ return cli
102
+
103
+
104
+ def main():
105
+ """
106
+ Main entry point for the CLI application.
107
+ """
108
+ cli = build_cli()
109
+ cli()
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -0,0 +1,3 @@
1
+ """
2
+ Command modules for the grzctl package.
3
+ """
@@ -0,0 +1,43 @@
1
+ """Command for archiving a submission."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from grz_common.workers.worker import Worker
8
+
9
+ from ..models.config import ArchiveConfig
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ from grz_common.cli import config_file, submission_dir, threads
14
+
15
+
16
+ @click.command()
17
+ @submission_dir
18
+ @config_file
19
+ @threads
20
+ def archive(
21
+ submission_dir,
22
+ config_file,
23
+ threads,
24
+ ):
25
+ """
26
+ Archive a submission within a GRZ/GDC.
27
+ """
28
+ config = ArchiveConfig.from_path(config_file)
29
+
30
+ log.info("Starting archival...")
31
+
32
+ submission_dir = Path(submission_dir)
33
+
34
+ worker_inst = Worker(
35
+ metadata_dir=submission_dir / "metadata",
36
+ files_dir=submission_dir / "files",
37
+ log_dir=submission_dir / "logs",
38
+ encrypted_files_dir=submission_dir / "encrypted_files",
39
+ threads=threads,
40
+ )
41
+ worker_inst.archive(config.s3)
42
+
43
+ log.info("Archival finished!")
@@ -0,0 +1,77 @@
1
+ """Command for cleaning a submission from the S3 inbox."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ import click
7
+ from grz_common.cli import config_file, submission_id
8
+ from grz_common.transfer import init_s3_resource
9
+
10
+ from ..models.config import CleanConfig
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ @click.command()
16
+ @submission_id
17
+ @config_file
18
+ @click.option("--yes-i-really-mean-it", is_flag=True)
19
+ def clean(submission_id, config_file, yes_i_really_mean_it: bool):
20
+ """
21
+ Remove all files of a submission from the S3 inbox.
22
+ """
23
+ config = CleanConfig.from_path(config_file)
24
+ bucket_name = config.s3.bucket
25
+
26
+ if yes_i_really_mean_it or click.confirm(
27
+ f"Are you SURE you want to delete the submission from the inbox bucket ({bucket_name})?",
28
+ default=False,
29
+ show_default=True,
30
+ ):
31
+ prefix = submission_id
32
+ prefix = prefix + "/" if not prefix.endswith("/") else prefix
33
+
34
+ resource = init_s3_resource(config.s3)
35
+ bucket = resource.Bucket(bucket_name)
36
+ log.info(f"Deleting {prefix} from {bucket_name} …")
37
+
38
+ responses = bucket.objects.filter(Prefix=prefix).delete()
39
+ if not responses:
40
+ sys.exit(f"No objects with prefix '{prefix}' in bucket '{bucket_name}' found for deletion.")
41
+
42
+ successfully_deleted_keys = []
43
+ errors_encountered = []
44
+ objects_found = 0
45
+
46
+ # responses is a list of dicts reporting the result of the deletion API call
47
+ # because the API does things in batches
48
+ for response in responses:
49
+ deleted_batch = response.get("Deleted", [])
50
+ for deleted_obj in deleted_batch:
51
+ key = deleted_obj.get("Key")
52
+ if key:
53
+ successfully_deleted_keys.append(key)
54
+
55
+ errors_batch = response.get("Errors", [])
56
+ for error_obj in errors_batch:
57
+ errors_encountered.append(
58
+ {
59
+ "Key": error_obj.get("Key", "N/A"),
60
+ "Code": error_obj.get("Code", "N/A"),
61
+ "Message": error_obj.get("Message", "N/A"),
62
+ }
63
+ )
64
+
65
+ objects_found += len(deleted_batch) + len(errors_batch)
66
+
67
+ log.info(f"Total objects attempted to delete: {objects_found}")
68
+ log.info(f"Successfully deleted: {len(successfully_deleted_keys)} objects.")
69
+ log.info(f"Failed to delete: {len(errors_encountered)} objects.")
70
+
71
+ for error in errors_encountered:
72
+ log.error(f" - Key: {error['Key']}, Code: {error['Code']}, Message: {error['Message']}")
73
+
74
+ if errors_encountered:
75
+ sys.exit(f"Errors encountered while deleting objects from bucket '{bucket_name}'. See log for details.")
76
+
77
+ log.info(f"Deleted '{prefix}' from '{bucket_name}'.")
@@ -0,0 +1,116 @@
1
+ """Command for determining whether a submission is consented for research."""
2
+
3
+ import enum
4
+ import json
5
+ import logging
6
+ import sys
7
+ import typing
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ import rich.console
13
+ import rich.table
14
+ import rich.text
15
+ from grz_common.cli import output_json, show_details, submission_dir
16
+ from grz_common.workers.submission import GrzSubmissionMetadata, SubmissionMetadata
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU = "2.16.840.1.113883.3.1937.777.24.5.3.8"
21
+
22
+
23
+ class FhirProvision(enum.StrEnum):
24
+ """Possible FHIR Provision options."""
25
+
26
+ PERMIT = "permit"
27
+ DENY = "deny"
28
+
29
+
30
+ @click.command()
31
+ @submission_dir
32
+ @output_json
33
+ @show_details
34
+ def consent(submission_dir, output_json, show_details):
35
+ """
36
+ Check if a submission is consented for research.
37
+
38
+ Returns 'true' if consented, 'false' if not.
39
+ A submission is considered consented if all donors have consented for research, that is
40
+ the FHIR MII IG Consent profiles all have a "permit" provision for code 2.16.840.1.113883.3.1937.777.24.5.3.8
41
+ """
42
+ metadata = SubmissionMetadata(Path(submission_dir) / "metadata" / "metadata.json").content
43
+
44
+ consents = _gather_consent_information(metadata)
45
+ overall_consent = _submission_has_research_consent(consents)
46
+
47
+ match output_json, show_details:
48
+ case True, True:
49
+ json.dump(consents, sys.stdout)
50
+ case True, False:
51
+ json.dump(overall_consent, sys.stdout)
52
+ case False, True:
53
+ _print_rich_table(consents)
54
+ case False, False:
55
+ click.echo(str(overall_consent).lower())
56
+
57
+
58
+ def _submission_has_research_consent(consents):
59
+ return all(consents.values())
60
+
61
+
62
+ def _print_rich_table(consents: dict[str, bool]):
63
+ console = rich.console.Console()
64
+ table = rich.table.Table()
65
+ table.add_column("Donor", no_wrap=True)
66
+ table.add_column("Research Consent", no_wrap=True)
67
+ for donor_pseudonym, consent_value in consents.items():
68
+ research_consent = rich.text.Text(
69
+ "True" if consent_value else "False",
70
+ style="green" if consent_value else "red",
71
+ )
72
+ table.add_row(
73
+ donor_pseudonym,
74
+ research_consent,
75
+ )
76
+ console.print(table)
77
+
78
+
79
+ def _gather_consent_information(metadata: GrzSubmissionMetadata) -> dict[str, bool]:
80
+ consents = {donor.donor_pseudonym: False for donor in metadata.donors}
81
+ for donor in metadata.donors:
82
+ for research_consent in donor.research_consents:
83
+ mii_consent = research_consent.scope
84
+ if isinstance(mii_consent, str):
85
+ mii_consent = json.loads(mii_consent)
86
+ mii_consent = typing.cast(dict[str, Any], mii_consent)
87
+
88
+ if top_level_provision := mii_consent.get("provision"):
89
+ if top_level_provision.get("type") != FhirProvision.DENY:
90
+ sys.exit(
91
+ f"The root provision type must be deny, not {top_level_provision.get('type')}, "
92
+ f"since the profile follows an opt-in consent scheme. "
93
+ f"Explicit opt-in consents must be made via nested provisions."
94
+ )
95
+ else:
96
+ nested_provisions = top_level_provision.get("provision")
97
+ consents[donor.donor_pseudonym] = _check_nested_provisions(nested_provisions)
98
+
99
+ return consents
100
+
101
+
102
+ def _check_nested_provisions(provisions: list[dict[str, Any]]) -> bool:
103
+ for provision in provisions:
104
+ if provision.get("type") == FhirProvision.PERMIT:
105
+ for codeable_concept in provision.get("code", []):
106
+ for coding in codeable_concept.get("coding", []):
107
+ code = coding.get("code")
108
+ if isinstance(code, str):
109
+ if code == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
110
+ return True
111
+ elif isinstance(code, dict):
112
+ if (value := code.get("value")) and value == MDAT_WISSENSCHAFTLICH_NUTZEN_EU_DSGVO_NIVEAU:
113
+ return True
114
+ else:
115
+ raise ValueError(code, f"Expected str or dict, got {type(code)}")
116
+ return False
grzctl/commands/db.py ADDED
@@ -0,0 +1,371 @@
1
+ """Command for managing a submission database"""
2
+
3
+ import enum
4
+ import json
5
+ import logging
6
+ import sys
7
+ import traceback
8
+ from collections import namedtuple
9
+ from typing import Any
10
+
11
+ import click
12
+ import rich.console
13
+ import rich.table
14
+ from grz_common.cli import config_file, output_json
15
+ from grz_db import (
16
+ Author,
17
+ DatabaseConfigurationError,
18
+ DuplicateSubmissionError,
19
+ DuplicateTanGError,
20
+ Submission,
21
+ SubmissionDb,
22
+ SubmissionNotFoundError,
23
+ SubmissionStateEnum,
24
+ SubmissionStateLog,
25
+ )
26
+
27
+ from ..models.config import DbConfig
28
+
29
+ console = rich.console.Console()
30
+ log = logging.getLogger(__name__)
31
+
32
+ DATABASE_URL = "sqlite:///test.sqlite"
33
+
34
+
35
+ def get_submission_db_instance(db_url: str | None, author: Author | None = None) -> SubmissionDb:
36
+ """Creates and returns an instance of SubmissionDb."""
37
+ db_url = db_url or DATABASE_URL
38
+ return SubmissionDb(db_url=db_url, author=author)
39
+
40
+
41
+ @click.group(help="Database operations")
42
+ @config_file
43
+ @click.pass_context
44
+ def db(ctx: click.Context, config_file: str):
45
+ """Database operations"""
46
+ config = DbConfig.from_path(config_file)
47
+ db_config = config.db
48
+ if not db_config:
49
+ raise ValueError("DB config not found")
50
+ author_name = db_config.author.name
51
+
52
+ if path := db_config.author.private_key_path:
53
+ with open(path, "rb") as f:
54
+ private_key_bytes = f.read()
55
+ elif key := db_config.author.private_key:
56
+ private_key_bytes = key.encode("utf-8")
57
+ else:
58
+ raise ValueError("Either private_key or private_key_path must be provided.")
59
+
60
+ from cryptography.hazmat.primitives.serialization import load_ssh_public_key
61
+
62
+ log.info("Reading known public keys")
63
+ KnownKeyEntry = namedtuple("KnownKeyEntry", ["key_format", "public_key_base64", "author_name"])
64
+ with open(db_config.known_public_keys) as f:
65
+ public_key_list = list(map(lambda v: KnownKeyEntry(*v), map(lambda s: s.strip().split(), f.readlines())))
66
+ public_keys = {
67
+ author: load_ssh_public_key(f"{fmt}\t{key}\t{author}".encode()) for fmt, key, author in public_key_list
68
+ }
69
+ for author in public_keys:
70
+ log.debug(f"Found public key for {author}")
71
+
72
+ author = Author(name=author_name, private_key_bytes=private_key_bytes)
73
+ ctx.obj = {"author": author, "public_keys": public_keys, "db_url": db_config.database_url}
74
+
75
+
76
+ @db.group()
77
+ @click.pass_context
78
+ def submission(ctx: click.Context):
79
+ """Submission operations"""
80
+ pass
81
+
82
+
83
+ @db.command()
84
+ @click.pass_context
85
+ def init(ctx: click.Context):
86
+ """Initializes or upgrades the database schema using Alembic."""
87
+ db = ctx.obj["db_url"]
88
+ submission_db = get_submission_db_instance(db, author=ctx.obj["author"])
89
+ console.print(f"[cyan]Initializing database {db}[/cyan]")
90
+ submission_db.initialize_schema()
91
+
92
+
93
+ @db.command()
94
+ @click.option("--revision", default="head", help="Alembic revision to upgrade to (default: 'head').")
95
+ @click.option(
96
+ "--alembic-ini",
97
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
98
+ default="alembic.ini",
99
+ help="Override path to alembic.ini file.",
100
+ )
101
+ @click.pass_context
102
+ def upgrade(
103
+ ctx: click.Context,
104
+ revision: str,
105
+ alembic_ini: str,
106
+ ):
107
+ """
108
+ Upgrades the database schema using Alembic.
109
+ """
110
+ db = ctx.obj["db_url"]
111
+ submission_db = get_submission_db_instance(db, author=ctx.obj["author"])
112
+
113
+ console.print(f"[cyan]Using alembic configuration: {alembic_ini}[/cyan]")
114
+
115
+ try:
116
+ console.print(f"[cyan]Attempting to upgrade database to revision: {revision}...[/cyan]")
117
+ _ = submission_db.upgrade_schema(
118
+ alembic_ini_path=alembic_ini,
119
+ revision=revision,
120
+ )
121
+
122
+ except (DatabaseConfigurationError, RuntimeError) as e:
123
+ console.print(f"[red]Error during schema initialization: {e}[/red]")
124
+ if isinstance(e, RuntimeError):
125
+ console.print(
126
+ "[yellow]Ensure your database is running and accessible, and alembic.ini is configured correctly.[/yellow]"
127
+ )
128
+ console.print(
129
+ "[yellow]You might need to create an initial migration if this is the first time: 'alembic revision -m \"initial\" --autogenerate'[/yellow]"
130
+ )
131
+ raise click.ClickException(str(e)) from e
132
+ except Exception as e:
133
+ console.print(f"[red]An unexpected error occurred during 'db init': {type(e).__name__} - {e}[/red]")
134
+ raise click.ClickException(str(e)) from e
135
+
136
+
137
+ @db.command("list")
138
+ @output_json
139
+ @click.pass_context
140
+ def list_submissions(ctx: click.Context, output_json: bool = False):
141
+ """Lists all submissions in the database with their latest state."""
142
+ db = ctx.obj["db_url"]
143
+ db_service = get_submission_db_instance(db)
144
+ submissions = db_service.list_submissions()
145
+
146
+ if not submissions:
147
+ console.print("[yellow]No submissions found in the database.[/yellow]")
148
+ return
149
+
150
+ table = rich.table.Table(title="All Submissions")
151
+ table.add_column("ID", style="dim", width=12)
152
+ table.add_column("tanG", style="cyan")
153
+ table.add_column("Pseudonym", style="magenta")
154
+ table.add_column("Latest State", style="green")
155
+ table.add_column("Last State Timestamp (UTC)", style="yellow")
156
+ table.add_column("Data Steward")
157
+ table.add_column("Signature Status")
158
+
159
+ submission_dicts = []
160
+
161
+ for submission in submissions:
162
+ latest_state_obj: SubmissionStateLog | None = None
163
+ if submission.states:
164
+ latest_state_obj = max(submission.states, key=lambda s: s.timestamp)
165
+
166
+ latest_state_str = "N/A"
167
+ latest_timestamp_str = "N/A"
168
+ author_name_str = "N/A"
169
+ signature_status = SignatureStatus.UNKNOWN
170
+
171
+ if latest_state_obj:
172
+ latest_state_str = latest_state_obj.state.value
173
+ latest_state_str = (
174
+ f"[red]{latest_state_str}[/red]" if latest_state_str == SubmissionStateEnum.ERROR else latest_state_str
175
+ )
176
+ latest_timestamp_str = latest_state_obj.timestamp.isoformat()
177
+ author_name_str = latest_state_obj.author_name
178
+
179
+ author_public_key = ctx.obj["public_keys"].get(author_name_str)
180
+ signature_status = _verify_signature(author_public_key, latest_state_obj)
181
+
182
+ if output_json:
183
+ submission_dict = _build_submission_dict_from(latest_state_obj, submission, signature_status)
184
+ submission_dicts.append(submission_dict)
185
+ else:
186
+ table.add_row(
187
+ submission.id,
188
+ submission.tan_g if submission.tan_g is not None else "N/A",
189
+ submission.pseudonym if submission.pseudonym is not None else "N/A",
190
+ latest_state_str,
191
+ latest_timestamp_str,
192
+ author_name_str,
193
+ signature_status.rich_display(),
194
+ )
195
+
196
+ if output_json:
197
+ json.dump(submission_dicts, sys.stdout)
198
+ else:
199
+ console.print(table)
200
+
201
+
202
+ class SignatureStatus(enum.StrEnum):
203
+ """Enum for signature status."""
204
+
205
+ VERIFIED = "Verified"
206
+ FAILED = "Failed"
207
+ ERROR = "Error"
208
+ UNKNOWN = "Unknown"
209
+
210
+ def rich_display(self) -> str:
211
+ """Displays the signature status in rich format."""
212
+ match self:
213
+ case "Verified":
214
+ return "[green]Verified[/green]"
215
+ case "Failed":
216
+ return "[red]Failed[/red]"
217
+ case "Error":
218
+ return "[red]Error[/red]"
219
+ case "Unknown" | _:
220
+ return "[yellow]Unknown[/yellow]"
221
+
222
+
223
+ def _verify_signature(author_public_key, latest_state_obj: SubmissionStateLog) -> SignatureStatus:
224
+ signature_status = SignatureStatus.UNKNOWN
225
+ if author_public_key:
226
+ try:
227
+ if latest_state_obj.verify(author_public_key):
228
+ signature_status = SignatureStatus.VERIFIED
229
+ else:
230
+ signature_status = SignatureStatus.FAILED
231
+ except Exception as e:
232
+ signature_status = SignatureStatus.ERROR
233
+ log.error(e)
234
+ return signature_status
235
+
236
+
237
+ def _build_submission_dict_from(
238
+ latest_state_obj: SubmissionStateLog | None, submission: Submission, signature_status: SignatureStatus
239
+ ) -> dict[str, Any]:
240
+ submission_dict: dict[str, Any] = {
241
+ "id": submission.id,
242
+ "tan_g": submission.tan_g,
243
+ "pseudonym": submission.pseudonym,
244
+ "latest_state": None,
245
+ }
246
+ if latest_state_obj:
247
+ submission_dict["latest_state"] = {
248
+ "state": latest_state_obj.state.value,
249
+ "timestamp": latest_state_obj.timestamp.isoformat(),
250
+ "data": latest_state_obj.data,
251
+ "data_steward": latest_state_obj.author_name,
252
+ "data_steward_signature": signature_status,
253
+ }
254
+ return submission_dict
255
+
256
+
257
+ @submission.command()
258
+ @click.argument("submission_id", type=str)
259
+ @click.option("--tan-g", "tan_g", type=str, default=None, help="The tanG for the submission.")
260
+ @click.option("--pseudonym", type=str, default=None, help="The pseudonym for the submission.")
261
+ @click.pass_context
262
+ def add(ctx: click.Context, submission_id: str, tan_g: str | None, pseudonym: str | None):
263
+ """
264
+ Add a submission to the database.
265
+ """
266
+ db = ctx.obj["db_url"]
267
+ db_service = get_submission_db_instance(db)
268
+ try:
269
+ db_submission = db_service.add_submission(submission_id, tan_g, pseudonym)
270
+ console.print(f"[green]Submission '{db_submission.id}' added successfully.[/green]")
271
+ console.print(f" tanG: {db_submission.tan_g}, Pseudonym: {db_submission.pseudonym}")
272
+ except (DuplicateSubmissionError, DuplicateTanGError) as e:
273
+ console.print(f"[red]Error: {e}[/red]")
274
+ raise click.Abort() from e
275
+ except Exception as e:
276
+ console.print(f"[red]An unexpected error occurred: {e}[/red]")
277
+ raise click.ClickException(f"Failed to add submission: {e}") from e
278
+
279
+
280
+ @submission.command()
281
+ @click.argument("submission_id", type=str)
282
+ @click.argument("state_str", metavar="STATE", type=click.Choice(SubmissionStateEnum.list(), case_sensitive=False))
283
+ @click.option("--data", "data_json", type=str, default=None, help='Additional JSON data (e.g., \'{"k":"v"}\').')
284
+ @click.pass_context
285
+ def update(ctx: click.Context, submission_id: str, state_str: str, data_json: str | None):
286
+ """Update a submission to the given state. Optionally accepts additional JSON data to associate with the log entry."""
287
+ db = ctx.obj["db_url"]
288
+ db_service = get_submission_db_instance(db, author=ctx.obj["author"])
289
+ try:
290
+ state_enum = SubmissionStateEnum(state_str)
291
+ except ValueError as e:
292
+ console.print(f"[red]Error: Invalid state value '{state_str}'.[/red]")
293
+ raise click.Abort() from e
294
+
295
+ parsed_data = None
296
+ if data_json:
297
+ try:
298
+ parsed_data = json.loads(data_json)
299
+ except json.JSONDecodeError as e:
300
+ console.print(f"[red]Error: Invalid JSON string for --data: {data_json}[/red]")
301
+ raise click.Abort() from e
302
+ try:
303
+ new_state_log = db_service.update_submission_state(submission_id, state_enum, parsed_data)
304
+ console.print(
305
+ f"[green]Submission '{submission_id}' updated to state '{new_state_log.state.value}'. Log ID: {new_state_log.id}[/green]"
306
+ )
307
+ if new_state_log.data:
308
+ console.print(f" Data: {new_state_log.data}")
309
+
310
+ if state_enum == SubmissionStateEnum.REPORTED:
311
+ updated_submission = db_service.get_submission(submission_id)
312
+ if updated_submission:
313
+ console.print(f" Submission tanG is now: {updated_submission.tan_g}")
314
+
315
+ except SubmissionNotFoundError as e:
316
+ console.print(f"[red]Error: {e}[/red]")
317
+ console.print(f"You might need to add it first: grz-cli db add-submission {submission_id}")
318
+ raise click.Abort() from e
319
+ except Exception as e:
320
+ console.print(f"[red]An unexpected error occurred: {e}[/red]")
321
+ traceback.print_exc()
322
+ raise click.ClickException(f"Failed to update submission state: {e}") from e
323
+
324
+
325
+ @submission.command("show")
326
+ @click.argument("submission_id", type=str)
327
+ @click.pass_context
328
+ def show(ctx: click.Context, submission_id: str):
329
+ """
330
+ Show details of a submission.
331
+ """
332
+ db = ctx.obj["db_url"]
333
+ db_service = get_submission_db_instance(db)
334
+ submission = db_service.get_submission(submission_id)
335
+ if not submission:
336
+ console.print(f"[red]Error: Submission with ID '{submission_id}' not found.[/red]")
337
+ raise click.Abort()
338
+
339
+ console.print(f"\n[bold blue]Submission Details for ID: {submission.id}[/bold blue]")
340
+ console.print(f" tanG: {submission.tan_g if submission.tan_g is not None else 'N/A'}")
341
+ console.print(f" Pseudonym: {submission.pseudonym if submission.pseudonym is not None else 'N/A'}")
342
+
343
+ if submission.states:
344
+ table = rich.table.Table(title=f"State History for Submission {submission.id}")
345
+ table.add_column("Log ID", style="dim", width=12)
346
+ table.add_column("Timestamp (UTC)", style="yellow")
347
+ table.add_column("State", style="green")
348
+ table.add_column("Data", style="cyan", overflow="ellipsis")
349
+ table.add_column("Data Steward", style="magenta")
350
+ table.add_column("Signature Status")
351
+
352
+ sorted_states = sorted(submission.states, key=lambda s: s.timestamp)
353
+ for state_log in sorted_states:
354
+ data_str = json.dumps(state_log.data) if state_log.data else ""
355
+ state = state_log.state.value
356
+ state_str = f"[red]{state}[/red]" if state == SubmissionStateEnum.ERROR else state
357
+ data_steward_str = state_log.author_name
358
+ author_public_key = ctx.obj["public_keys"].get(data_steward_str)
359
+ signature_status_str = _verify_signature(author_public_key, state_log).rich_display()
360
+
361
+ table.add_row(
362
+ str(state_log.id),
363
+ state_log.timestamp.isoformat(),
364
+ state_str,
365
+ data_str,
366
+ data_steward_str,
367
+ signature_status_str,
368
+ )
369
+ console.print(table)
370
+ else:
371
+ console.print("[yellow]No state history found for this submission.[/yellow]")
@@ -0,0 +1,45 @@
1
+ """Command for decrypting a submission."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from grz_common.cli import config_file, force, submission_dir
9
+ from grz_common.workers.worker import Worker
10
+
11
+ from ..models.config import DecryptConfig
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ @click.command()
17
+ @submission_dir
18
+ @config_file
19
+ @force
20
+ def decrypt(submission_dir, config_file, force):
21
+ """
22
+ Decrypt a submission.
23
+
24
+ Decrypting a submission requires the _private_ key of the original recipient.
25
+ """
26
+ config = DecryptConfig.from_path(config_file)
27
+
28
+ grz_privkey_path = config.keys.grz_private_key_path
29
+ if not grz_privkey_path:
30
+ log.error("GRZ private key path is required for decryption.")
31
+ sys.exit(1)
32
+
33
+ log.info("Starting decryption...")
34
+
35
+ submission_dir = Path(submission_dir)
36
+
37
+ worker_inst = Worker(
38
+ metadata_dir=submission_dir / "metadata",
39
+ files_dir=submission_dir / "files",
40
+ log_dir=submission_dir / "logs",
41
+ encrypted_files_dir=submission_dir / "encrypted_files",
42
+ )
43
+ worker_inst.decrypt(grz_privkey_path, force=force)
44
+
45
+ log.info("Decryption successful!")
@@ -0,0 +1,46 @@
1
+ """Command for downloading a submission."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from grz_common.cli import config_file, force, output_dir, submission_id, threads
8
+ from grz_common.workers.worker import Worker
9
+
10
+ from ..models.config import DownloadConfig
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ @click.command()
16
+ @submission_id
17
+ @output_dir
18
+ @config_file
19
+ @threads
20
+ @force
21
+ def download(submission_id, output_dir, config_file, threads, force):
22
+ """
23
+ Download a submission from a GRZ.
24
+
25
+ Downloaded metadata is stored within the `metadata` sub-folder of the submission output directory.
26
+ Downloaded files are stored within the `encrypted_files` sub-folder of the submission output directory.
27
+ """
28
+ config = DownloadConfig.from_path(config_file)
29
+
30
+ log.info("Starting download...")
31
+
32
+ submission_dir_path = Path(output_dir)
33
+ if not submission_dir_path.is_dir():
34
+ log.debug("Creating submission directory %s", submission_dir_path)
35
+ submission_dir_path.mkdir(mode=0o770, parents=False, exist_ok=False)
36
+
37
+ worker_inst = Worker(
38
+ metadata_dir=submission_dir_path / "metadata",
39
+ files_dir=submission_dir_path / "files",
40
+ log_dir=submission_dir_path / "logs",
41
+ encrypted_files_dir=submission_dir_path / "encrypted_files",
42
+ threads=threads,
43
+ )
44
+ worker_inst.download(config.s3, submission_id, force=force)
45
+
46
+ log.info("Download finished!")
@@ -0,0 +1,50 @@
1
+ """Command for listing submissions."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+
7
+ import click
8
+ import rich.console
9
+ import rich.table
10
+ import rich.text
11
+ from grz_common.cli import config_file, output_json
12
+ from grz_common.workers.download import query_submissions
13
+ from pydantic_core import to_jsonable_python
14
+
15
+ from ..models.config import ListConfig
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ @click.command()
21
+ @config_file
22
+ @output_json
23
+ def list_submissions(config_file, output_json):
24
+ """
25
+ List submissions within an inbox from oldest to newest.
26
+ """
27
+ config = ListConfig.from_path(config_file)
28
+ submissions = query_submissions(config.s3)
29
+
30
+ if output_json:
31
+ json.dump(to_jsonable_python(submissions), sys.stdout)
32
+ else:
33
+ console = rich.console.Console()
34
+ table = rich.table.Table()
35
+ table.add_column("ID", no_wrap=True)
36
+ table.add_column("Status", no_wrap=True)
37
+ table.add_column("Oldest Upload", overflow="fold")
38
+ table.add_column("Newest Upload", overflow="fold")
39
+ for submission in submissions:
40
+ status_text = rich.text.Text(
41
+ "Complete" if submission.complete else "Incomplete",
42
+ style="green" if submission.complete else "yellow",
43
+ )
44
+ table.add_row(
45
+ submission.submission_id,
46
+ status_text,
47
+ submission.oldest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
48
+ submission.newest_upload.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
49
+ )
50
+ console.print(table)
@@ -0,0 +1,181 @@
1
+ """Command for submitting Prüfberichte."""
2
+
3
+ import datetime
4
+ import json
5
+ import logging
6
+ import sys
7
+
8
+ import click
9
+ import requests
10
+ from grz_common.cli import config_file, output_json, submission_dir
11
+ from grz_common.workers.submission import Submission
12
+
13
+ # pyrefly: ignore
14
+ from grz_pydantic_models.pruefbericht import LibraryType, Pruefbericht, SubmittedCase
15
+
16
+ # pyrefly: ignore
17
+ from grz_pydantic_models.submission.metadata.v1 import (
18
+ GenomicStudySubtype,
19
+ GrzSubmissionMetadata,
20
+ Relation,
21
+ SequenceSubtype,
22
+ )
23
+ from pydantic_core import to_jsonable_python
24
+
25
+ from ..models.config import PruefberichtConfig
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ def _get_new_token(auth_url: str, client_id: str, client_secret: str) -> tuple[str, datetime.datetime]:
31
+ log.info("Refreshing access token...")
32
+
33
+ response = requests.post(
34
+ auth_url,
35
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
36
+ data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
37
+ timeout=60,
38
+ )
39
+
40
+ if response.status_code != requests.codes.ok:
41
+ log.error("There was a problem refreshing the access token")
42
+ response.raise_for_status()
43
+
44
+ response_json = response.json()
45
+ token = response_json["access_token"]
46
+ expires_in = response_json["expires_in"]
47
+ # take off a second to provide at least a minimal safety margin
48
+ expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in - 1)
49
+
50
+ log.info("Successfully obtained a new access token.")
51
+ return token, expires_at
52
+
53
+
54
+ def _submit_pruefbericht(base_url: str, token: str, pruefbericht: Pruefbericht):
55
+ log.info("Submitting Prüfbericht...")
56
+
57
+ response = requests.post(
58
+ base_url.rstrip("/") + "/upload",
59
+ headers={"Authorization": f"bearer {token}"},
60
+ json=to_jsonable_python(pruefbericht),
61
+ timeout=60,
62
+ )
63
+
64
+ if response.status_code != requests.codes.ok:
65
+ log.warning("There was a problem submitting the Prüfbericht.")
66
+ response.raise_for_status()
67
+
68
+
69
+ def _get_library_type(metadata: GrzSubmissionMetadata) -> LibraryType:
70
+ index_patients = [donor for donor in metadata.donors if donor.relation == Relation.index_]
71
+ if len(index_patients) != 1:
72
+ # TODO handle in grz-pydantic-model validation?
73
+ raise ValueError("Multiple index patients in one submission")
74
+ index_patient = index_patients[0]
75
+
76
+ # assume somatic is tumor in context of genomDE
77
+ data_tumor = filter(lambda datum: datum.sequence_subtype == SequenceSubtype.somatic, index_patient.lab_data)
78
+ data_germline = filter(lambda datum: datum.sequence_subtype == SequenceSubtype.germline, index_patient.lab_data)
79
+
80
+ unique_tumor_library_types = {datum.library_type for datum in data_tumor}
81
+ if len(unique_tumor_library_types) > 1:
82
+ raise ValueError("Multiple different library types detected for tumor data")
83
+
84
+ unique_germline_library_types = {datum.library_type for datum in data_germline}
85
+ if len(unique_germline_library_types) > 1:
86
+ raise ValueError("Multiple different library types detected for germline data")
87
+
88
+ match metadata.submission.genomic_study_subtype:
89
+ case GenomicStudySubtype.tumor_only:
90
+ if not unique_tumor_library_types:
91
+ raise ValueError("No somatic library types detected in tumor submission")
92
+ library_type = LibraryType(str(next(iter(unique_tumor_library_types))))
93
+ case GenomicStudySubtype.germline_only:
94
+ if not unique_germline_library_types:
95
+ raise ValueError("No germline library types detected in germline submission")
96
+ library_type = LibraryType(str(next(iter(unique_germline_library_types))))
97
+ case GenomicStudySubtype.tumor_germline:
98
+ unique_library_types = unique_tumor_library_types | unique_germline_library_types
99
+ if len(unique_library_types) != 1:
100
+ # TODO: is there a better solution to this?
101
+ raise ValueError("None or multiple different library types detected for tumor+germline sample")
102
+ library_type = LibraryType(str(next(iter(unique_library_types))))
103
+ case _:
104
+ raise ValueError(f"Unknown genomic study subtype: {metadata.submission.genomic_study_subtype}")
105
+
106
+ return library_type
107
+
108
+
109
+ @click.command()
110
+ @config_file
111
+ @submission_dir
112
+ @output_json
113
+ @click.option("--fail/--pass", "failed", help="Fail an otherwise valid submission (e.g. failed internal QC)")
114
+ @click.option(
115
+ "--token", help="Access token to try instead of requesting a new one.", envvar="GRZ_PRUEFBERICHT_ACCESS_TOKEN"
116
+ )
117
+ def pruefbericht(config_file, submission_dir, output_json, failed, token):
118
+ """
119
+ Submit a Prüfbericht to BfArM.
120
+ """
121
+ config = PruefberichtConfig.from_path(config_file)
122
+
123
+ if config.pruefbericht.authorization_url is None:
124
+ raise ValueError("pruefbericht.auth_url must be provided to submit Prüfberichte")
125
+ if config.pruefbericht.client_id is None:
126
+ raise ValueError("pruefbericht.client_id must be provided to submit Prüfberichte")
127
+ if config.pruefbericht.client_secret is None:
128
+ raise ValueError("pruefbericht.client_secret must be provided to submit Prüfberichte")
129
+
130
+ submission = Submission(metadata_dir=f"{submission_dir}/metadata", files_dir=f"{submission_dir}/files")
131
+
132
+ metadata = submission.metadata.content
133
+ pruefbericht = Pruefbericht(
134
+ SubmittedCase=SubmittedCase(
135
+ submissionDate=metadata.submission.submission_date,
136
+ submissionType=metadata.submission.submission_type,
137
+ tan=metadata.submission.tan_g,
138
+ submitterId=metadata.submission.submitter_id,
139
+ dataNodeId=metadata.submission.genomic_data_center_id,
140
+ diseaseType=metadata.submission.disease_type,
141
+ dataCategory="genomic",
142
+ libraryType=_get_library_type(metadata),
143
+ coverageType=metadata.submission.coverage_type,
144
+ dataQualityCheckPassed=not failed,
145
+ )
146
+ )
147
+
148
+ if token:
149
+ # replace newlines in token if accidentally present from pasting
150
+ token = token.replace("\n", "")
151
+ expiry = None
152
+ else:
153
+ token, expiry = _get_new_token(
154
+ auth_url=str(config.pruefbericht.authorization_url),
155
+ client_id=config.pruefbericht.client_id,
156
+ client_secret=config.pruefbericht.client_secret,
157
+ )
158
+
159
+ try:
160
+ _submit_pruefbericht(base_url=str(config.pruefbericht.api_base_url), token=token, pruefbericht=pruefbericht)
161
+ except requests.HTTPError as error:
162
+ if error.response.status_code == requests.codes.unauthorized:
163
+ # get a new token and try again
164
+ log.warning("Provided token has expired. Attempting to refresh.")
165
+ token, expiry = _get_new_token(
166
+ auth_url=str(config.pruefbericht.authorization_url),
167
+ client_id=config.pruefbericht.client_id,
168
+ client_secret=config.pruefbericht.client_secret,
169
+ )
170
+ _submit_pruefbericht(base_url=str(config.pruefbericht.api_base_url), token=token, pruefbericht=pruefbericht)
171
+ else:
172
+ log.error("Encountered an irrecoverable error while submitting the Prüfbericht!")
173
+ raise error
174
+
175
+ log.info("Prüfbericht submitted successfully.")
176
+
177
+ if output_json and expiry:
178
+ json.dump({"token": token, "expires": expiry.isoformat()}, sys.stdout)
179
+ elif expiry:
180
+ log.info(f"New token expires at {expiry.isoformat()}")
181
+ print(token)
File without changes
@@ -0,0 +1,34 @@
1
+ from grz_common.models.base import IgnoringBaseSettings
2
+ from grz_common.models.keys import KeyConfigModel
3
+ from grz_common.models.s3 import S3ConfigModel
4
+
5
+ from .db import DbModel
6
+ from .pruefbericht import PruefberichtModel
7
+
8
+
9
+ class ArchiveConfig(S3ConfigModel):
10
+ pass
11
+
12
+
13
+ class DownloadConfig(S3ConfigModel):
14
+ pass
15
+
16
+
17
+ class DecryptConfig(KeyConfigModel):
18
+ pass
19
+
20
+
21
+ class CleanConfig(S3ConfigModel, KeyConfigModel):
22
+ pass
23
+
24
+
25
+ class PruefberichtConfig(IgnoringBaseSettings):
26
+ pruefbericht: PruefberichtModel
27
+
28
+
29
+ class DbConfig(IgnoringBaseSettings):
30
+ db: DbModel
31
+
32
+
33
+ class ListConfig(S3ConfigModel):
34
+ pass
grzctl/models/db.py ADDED
@@ -0,0 +1,37 @@
1
+ from typing import Annotated, Self
2
+
3
+ from grz_common.models.base import IgnoringBaseSettings
4
+ from pydantic import Field, FilePath, model_validator
5
+
6
+
7
+ class Author(IgnoringBaseSettings):
8
+ name: str
9
+ """Name of the author"""
10
+
11
+ private_key: str | None = None
12
+ """Author's private key (needed to sign DB modifications)."""
13
+
14
+ private_key_path: FilePath | None = None
15
+ """Path to the author's private key (needed to sign DB modifications)."""
16
+
17
+ @model_validator(mode="after")
18
+ def validate_private_key(self) -> Self:
19
+ if self.private_key is not None and self.private_key_path is not None:
20
+ raise ValueError("Only one of private_key or private_key_path must be set.")
21
+ return self
22
+
23
+
24
+ class DbModel(IgnoringBaseSettings):
25
+ """Submission database related configuration."""
26
+
27
+ database_url: Annotated[str, Field(examples=["sqlite:///submission.sqlite"])]
28
+ """URL to a database."""
29
+
30
+ author: Author
31
+ """Author information for submission database."""
32
+
33
+ known_public_keys: FilePath | str = "~/.config/grz-cli/known_public_keys"
34
+ """
35
+ File listing public keys. Used for DB verification.
36
+
37
+ Format: key_format public_key_base64 author_name"""
@@ -0,0 +1,28 @@
1
+ from typing import Annotated
2
+
3
+ from grz_common.models.base import IgnoringBaseModel
4
+ from pydantic import AnyHttpUrl, UrlConstraints
5
+
6
+
7
+ class PruefberichtModel(IgnoringBaseModel):
8
+ authorization_url: Annotated[AnyHttpUrl, UrlConstraints(allowed_schemes=["https"], host_required=True)] | None = (
9
+ None
10
+ )
11
+ """
12
+ URL from which to request a new Prüfbericht submission token
13
+ """
14
+
15
+ client_id: str | None = None
16
+ """
17
+ Client ID used to obtain new Prüfbericht submission tokens
18
+ """
19
+
20
+ client_secret: str | None = None
21
+ """
22
+ Client secret used to obtain new Prüfbericht submission tokens
23
+ """
24
+
25
+ api_base_url: Annotated[AnyHttpUrl, UrlConstraints(allowed_schemes=["https"], host_required=True)] | None = None
26
+ """
27
+ Base URL to BfArM Submission (Prüfbericht) API
28
+ """
grzctl/py.typed ADDED
File without changes
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: grzctl
3
+ Version: 0.1.0
4
+ Summary: Control CLI for GRZ administrators.
5
+ Project-URL: Homepage, https://github.com/BfArM-MVH/grz-tools
6
+ Project-URL: Repository, https://github.com/BfArM-MVH/grz-tools
7
+ Project-URL: Documentation, https://github.com/BfArM-MVH/grz-tools/tree/main/packages/grzctl
8
+ Project-URL: Issues, https://github.com/BfArM-MVH/grz-tools/issues
9
+ 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>, Travis Wrightsman <travis.wrightsman@uni-tuebingen.de>
10
+ License-Expression: MIT
11
+ Keywords: GDC,GRZ,S3
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: boto3<2,>=1.36
16
+ Requires-Dist: click<9,>=8.1.7
17
+ Requires-Dist: crypt4gh<2,>=1.7
18
+ Requires-Dist: grz-common
19
+ Requires-Dist: grz-db
20
+ Requires-Dist: grz-pydantic-models>=1.3.0
21
+ Requires-Dist: jsonschema<5,>=4.23.0
22
+ Requires-Dist: platformdirs<5,>=4.3.6
23
+ Requires-Dist: pydantic-settings<2.10,>=2.9.0
24
+ Requires-Dist: pydantic<2.10,>=2.9.2
25
+ Requires-Dist: pysam==0.23.*
26
+ Requires-Dist: pyyaml<7,>=6.0.2
27
+ Requires-Dist: requests<3,>=2.32.3
28
+ Requires-Dist: rich==13.*
29
+ Requires-Dist: tqdm<5,>=4.66.5
30
+ Description-Content-Type: text/markdown
31
+
32
+ GRZ internal tooling.
@@ -0,0 +1,20 @@
1
+ grzctl/__init__.py,sha256=XdIBbKjf_hXVHi3q1dIdbAdTpDaheUuEzFnIQYn3G3Q,71
2
+ grzctl/cli.py,sha256=qX5EcQbVMn9SMADVnQWoNPKOJtJKbw09mFqtU9d_Y1A,3289
3
+ grzctl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ grzctl/commands/__init__.py,sha256=_ZczLaPd0w1uEgRbSNe1J7w1zW1ct424pM9fp6qTw0g,48
5
+ grzctl/commands/archive.py,sha256=MT8XUHPWdRtniRzdgJPpEP2ja1OBevvKNf-w3oJuRz4,926
6
+ grzctl/commands/clean.py,sha256=3TMYc2p8O6INMFi0-2g_oJ-kjSCe5nIAX48LD6OUzq8,2806
7
+ grzctl/commands/consent.py,sha256=z9TirrWVmKzbcX0ONyPKU8ZNT5aznA1JmABN2TSGXxQ,4280
8
+ grzctl/commands/db.py,sha256=e947DKbwA-oVCzd_Jh9hC_kJF2vO7Ba1SAibSBnzUy8,14166
9
+ grzctl/commands/decrypt.py,sha256=dK6FhR_06JLdrkBfq11U2eBzbeJrUGjpC9wCNF4c9d8,1159
10
+ grzctl/commands/download.py,sha256=J7x1fx4zzXLtKv65oEmliCap5SlvU_gXEW_1AtVQcso,1419
11
+ grzctl/commands/list_submissions.py,sha256=bbTOza6ij8tOAw-sWv-lHBfDN-nS_8IaIdl7eZiNUFw,1565
12
+ grzctl/commands/pruefbericht.py,sha256=ksN-MSAQxbPPrTOxK5UkyZZi7M59iaotAcW-la8n56k,7542
13
+ grzctl/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ grzctl/models/config.py,sha256=aK5vIA_lREy9t6nTBiyPtqV4oj55HB8zJsw5AQXKXKo,612
15
+ grzctl/models/db.py,sha256=DCImTKjxIjzOCR38EDePO73GTNsrTGrIL8bh5RySYGc,1199
16
+ grzctl/models/pruefbericht.py,sha256=qeOlqxlehFV7QS23byQGOJc6sNFLkOtT_yKLC9PYmr4,819
17
+ grzctl-0.1.0.dist-info/METADATA,sha256=gUfUokVthxJZNPHarmoCJ2GYPWkQdncZBWffoInzIBo,1396
18
+ grzctl-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ grzctl-0.1.0.dist-info/entry_points.txt,sha256=jL-UHdg_9PxXMA_g_B4atB3XSw62-H3qZsjV-HNHV5g,43
20
+ grzctl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grzctl = grzctl.cli:main