dmtri 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.
dmtri/__init__.py ADDED
File without changes
dmtri/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ import argparse
2
+ import sys
3
+ import os
4
+ from datetime import datetime, timezone
5
+ import logging
6
+
7
+ from dmtri.execution_plan import print_execution_plan
8
+ from dmtri.utils import get_version, validate_and_normalize_args
9
+ from dmtri.hooks.hooks import run_hook
10
+ from dmtri.paths import COMMAND_PLAYBOOKS
11
+
12
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
13
+ logger = logging.getLogger(__name__)
14
+
15
+ PROJECT_URL = "https://github.com/EIDA/dmtri/issues"
16
+
17
+ def main():
18
+ global_options = argparse.ArgumentParser(add_help=False)
19
+
20
+ parser = argparse.ArgumentParser(
21
+ description="Trigger data/metadata refresh or cleaning for multiple EIDA endpoints.",
22
+ parents=[global_options]
23
+ )
24
+ parser.add_argument(
25
+ "--version",
26
+ action="version",
27
+ version=f"%(prog)s version {get_version()} โ€” See {PROJECT_URL} for updates or to report issues."
28
+ )
29
+
30
+ subparsers = parser.add_subparsers(dest="command")
31
+
32
+ shared = argparse.ArgumentParser(add_help=False, parents=[global_options])
33
+ shared.add_argument("-n", "--network", nargs="+", default=["*"], help="FDSN network code(s), supports wildcards")
34
+ shared.add_argument("-s", "--station", nargs="+", default=["*"], help="FDSN station code(s), supports wildcards")
35
+ shared.add_argument("-l", "--location", nargs="+", default=["*"], help="FDSN location code(s), supports wildcards")
36
+ shared.add_argument("-c", "--channel", nargs="+", default=["*"], help="FDSN channel code(s), supports wildcards")
37
+ shared.add_argument("-S", "--starttime", required=True, help="Start time (ISO 8601 format)")
38
+
39
+ now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
40
+ shared.add_argument("-E", "--endtime", default=now_str, help="End time (default: now in UTC)")
41
+ shared.add_argument("--type", default="data,metadata", help="Comma-separated types to process (data, metadata)")
42
+ shared.add_argument("--no-confirm", action="store_true", help="Skip confirmation prompt before executing")
43
+ shared.add_argument("--debug", action="store_true", help="Show verbose output from Ansible")
44
+
45
+ subparsers.add_parser("refresh", parents=[shared], help="Refresh data/metadata using configured playbooks.")
46
+ subparsers.add_parser("clean", parents=[shared], help="Clean outdated data/metadata using configured playbooks.")
47
+ subparsers.add_parser("track", help="Track job status via tracking playbook.")
48
+ doctor_parser = subparsers.add_parser("doctor", help="Check SSH connectivity to all inventory hosts")
49
+ doctor_parser.add_argument("-v", "--verbose", action="store_true", help="Show full Ansible output per host")
50
+
51
+ args = parser.parse_args()
52
+ if args.command == "track":
53
+ cfg_file = "ansible_verbose.cfg"
54
+ else:
55
+ cfg_file = "ansible_verbose.cfg" if getattr(args, "debug", False) else "ansible_quiet.cfg"
56
+
57
+ cfg_path = os.path.abspath(cfg_file)
58
+ os.environ["ANSIBLE_CONFIG"] = cfg_path
59
+
60
+ if args.command == "doctor":
61
+ from dmtri.doctor import run_diagnostics
62
+ return run_diagnostics(verbose=args.verbose)
63
+
64
+ elif args.command in ("refresh", "clean"):
65
+ start_dt, end_dt, types = validate_and_normalize_args(args, parser)
66
+
67
+ vars_to_pass = {
68
+ "network": args.network,
69
+ "station": args.station,
70
+ "location": args.location,
71
+ "channel": args.channel,
72
+ "starttime": args.starttime,
73
+ "endtime": args.endtime,
74
+ "type": [t.strip() for t in args.type.split(",")],
75
+ "debug": args.debug,
76
+ }
77
+
78
+ if args.command == "refresh":
79
+ selected_types = vars_to_pass["type"]
80
+ valid_types = set(COMMAND_PLAYBOOKS["refresh"].keys())
81
+
82
+ invalid = set(selected_types) - valid_types
83
+ if invalid:
84
+ print(f" Invalid --type: {', '.join(invalid)}. Valid types are: {', '.join(valid_types)}.")
85
+ sys.exit(1)
86
+
87
+ playbooks = []
88
+ for t in selected_types:
89
+ playbooks.extend(COMMAND_PLAYBOOKS["refresh"][t])
90
+ else:
91
+ playbooks = COMMAND_PLAYBOOKS.get("clean", [])
92
+
93
+ if not playbooks:
94
+ print(f"No playbooks configured for command '{args.command}'.")
95
+ sys.exit(1)
96
+
97
+ if not args.no_confirm:
98
+ for pb in playbooks:
99
+ print_execution_plan(args.command, vars_to_pass, pb)
100
+ proceed = input("\nDo you want to proceed with executing all playbooks? (y/N): ").strip().lower()
101
+ if proceed != "y":
102
+ print("Aborted by user.")
103
+ sys.exit(0)
104
+
105
+ for pb in playbooks:
106
+ run_hook(pb, vars_to_pass)
107
+
108
+ elif args.command == "track":
109
+ playbooks = COMMAND_PLAYBOOKS.get("track", [])
110
+ if not playbooks:
111
+ print("No playbooks configured for command 'track'.")
112
+ sys.exit(1)
113
+ for pb in playbooks:
114
+ run_hook(pb, {})
115
+
116
+ else:
117
+ parser.print_help()
118
+ sys.exit(1)
dmtri/doctor.py ADDED
@@ -0,0 +1,52 @@
1
+ import logging
2
+ import ansible_runner
3
+ from dmtri.paths import INVENTORY_FILE
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def run_diagnostics(verbose=False, retries=1):
8
+ logger.info(f"Running connectivity check using inventory: {INVENTORY_FILE}")
9
+
10
+ ok_hosts = set()
11
+ unreachable_hosts = set()
12
+
13
+ for attempt in range(retries):
14
+ logger.info(f"Attempt {attempt + 1} of {retries}")
15
+
16
+ r = ansible_runner.run(
17
+ private_data_dir=".",
18
+ inventory=str(INVENTORY_FILE.resolve()),
19
+ module="ping",
20
+ host_pattern="all",
21
+ quiet=not verbose,
22
+ envvars={
23
+ "ANSIBLE_CACHE_PLUGIN": "memory" # Disable caching to avoid plugin warnings
24
+ }
25
+ )
26
+
27
+ for event in r.events:
28
+ if not isinstance(event, dict):
29
+ continue
30
+ data = event.get("event_data", {})
31
+ host = data.get("host")
32
+ if not host:
33
+ continue
34
+ if event["event"] == "runner_on_ok":
35
+ ok_hosts.add(host)
36
+ elif event["event"] == "runner_on_unreachable":
37
+ unreachable_hosts.add(host)
38
+
39
+ # Break early if all hosts responded
40
+ if ok_hosts and not unreachable_hosts:
41
+ break
42
+
43
+ logger.info("\nResults:")
44
+
45
+ if not ok_hosts and not unreachable_hosts:
46
+ logger.warning("No responses received. Are you connected to the VPN?")
47
+ return
48
+
49
+ for host in sorted(ok_hosts):
50
+ logger.info(f"[OK] {host}")
51
+ for host in sorted(unreachable_hosts):
52
+ logger.error(f"[UNREACHABLE] {host}")
@@ -0,0 +1,59 @@
1
+ import sys
2
+ import yaml
3
+ from pathlib import Path
4
+
5
+
6
+ def get_playbook_metadata(playbook_path: str):
7
+ try:
8
+ with open(playbook_path) as f:
9
+ playbook = yaml.safe_load(f)
10
+
11
+ if not playbook or not isinstance(playbook, list) or not playbook[0]:
12
+ return {"error": "Empty playbook"}
13
+
14
+ metadata = {
15
+ "hosts": playbook[0].get("hosts", "unknown"),
16
+ "description": playbook[0].get("vars", {}).get("description", ""),
17
+ "tasks": []
18
+ }
19
+
20
+ for task in playbook[0].get("tasks", []):
21
+ desc = task.get("vars", {}).get("description", task.get("name", "Unnamed Task"))
22
+ metadata["tasks"].append(desc)
23
+
24
+ return metadata
25
+
26
+ except Exception as e:
27
+ return {"error": str(e)}
28
+
29
+
30
+
31
+
32
+
33
+ def print_execution_plan(command: str, variables: dict, playbook_path: str, metadata=None):
34
+ if metadata is None:
35
+ metadata = get_playbook_metadata(playbook_path)
36
+
37
+ print("\nExecution Plan")
38
+ print("-" * 40)
39
+ print(f"Command : {command}")
40
+ print(f"Playbook : {playbook_path}")
41
+
42
+ if "error" in metadata:
43
+ print(f"Warning : {metadata['error']}")
44
+ else:
45
+ print(f"Hosts : {metadata['hosts']}")
46
+ print(f"Description : {metadata['description']}")
47
+ print("Steps :")
48
+ for idx, desc in enumerate(metadata['tasks'], start=1):
49
+ print(f" {idx}. {desc}")
50
+
51
+ if variables:
52
+ print("\nVariables:")
53
+ for k, v in variables.items():
54
+ print(f" {k:10}: {v}")
55
+
56
+ confirm = input("\nProceed with this execution plan? [y/N]: ").strip().lower()
57
+ if confirm != "y":
58
+ print("Aborted by user.")
59
+ sys.exit(0)
File without changes
dmtri/hooks/hooks.py ADDED
@@ -0,0 +1,45 @@
1
+ import ansible_runner
2
+ import sys
3
+ from pathlib import Path
4
+ import logging
5
+ from dmtri.paths import INVENTORY_FILE
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+ def run_hook(playbook_name: Path, vars: dict,inventory: Path = INVENTORY_FILE) -> None:
10
+ """
11
+ Run an Ansible playbook using ansible_runner.run_command, bypassing the project layout.
12
+ """
13
+
14
+ # Build the extra vars string manually
15
+ extra_vars = []
16
+ for key, value in vars.items():
17
+ if isinstance(value, list):
18
+ value = ",".join(value)
19
+ extra_vars.extend(["-e", f"{key}='{value}'"])
20
+
21
+ cmd = [
22
+ "ansible-playbook",
23
+ str(playbook_name.resolve()),
24
+ "-i",
25
+ str(inventory.resolve()),
26
+
27
+ ] + extra_vars
28
+
29
+
30
+ logger.info(f"Running: {' '.join(str(x) for x in cmd)}")
31
+
32
+
33
+ out, err, rc = ansible_runner.run_command(
34
+ executable_cmd="ansible-playbook",
35
+ cmdline_args=cmd[1:],
36
+ input_fd=sys.stdin,
37
+ output_fd=sys.stdout,
38
+ error_fd=sys.stderr,
39
+ )
40
+
41
+ if rc != 0:
42
+ logger.error(f"Playbook {playbook_name} failed with return code {rc}")
43
+ sys.exit(rc)
44
+
45
+ logger.info(f"Playbook {playbook_name} completed successfully")
dmtri/paths.py ADDED
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+
3
+ MODULE_DIR = Path(__file__).resolve().parent
4
+ SRC_DIR = MODULE_DIR.parent
5
+
6
+ # Directories
7
+ PLAYBOOKS_DIR = SRC_DIR / "playbooks"
8
+ INVENTORY_DIR = SRC_DIR / "inventory"
9
+
10
+ # Inventory file
11
+ INVENTORY_FILE = INVENTORY_DIR / "hosts.ini"
12
+
13
+ # Specific playbooks
14
+ PLAYBOOK_TRACK = PLAYBOOKS_DIR / "track.yml"
15
+ PLAYBOOK_LIST_SDS = PLAYBOOKS_DIR / "list_sds_files.yml"
16
+
17
+ # Refresh Data playbooks
18
+ PLAYBOOK_SEEDPSD_REFRESH = PLAYBOOKS_DIR / "refresh" / "seedpsd_refresh.yml"
19
+ PLAYBOOK_WFCATALOG_REFRESH = PLAYBOOKS_DIR / "refresh" / "wfcatalog_refresh.yml"
20
+ PLAYBOOK_AVAILABILITY_REFRESH = PLAYBOOKS_DIR / "refresh" / "availability_refresh.yml"
21
+ # Refresh metadata playbooks
22
+ PLAYBOOK_METADATA_REFRESH = PLAYBOOKS_DIR / "refresh" / "seedpsd_metadata_refresh.yml"
23
+
24
+ # Clean playbooks
25
+ PLAYBOOK_SEEDPSD_CLEAN = PLAYBOOKS_DIR / "clean" /"seedpsd_clean.yml"
26
+ PLAYBOOK_WFCATALOG_CLEAN = PLAYBOOKS_DIR / "clean" / "wfcatalog_clean.yml"
27
+ PLAYBOOK_AVAILABILITY_CLEAN = PLAYBOOKS_DIR / "clean" / "availability_clean.yml"
28
+
29
+ # Group playbooks by command
30
+ COMMAND_PLAYBOOKS = {
31
+ "refresh": {
32
+ "data": [
33
+ PLAYBOOK_SEEDPSD_REFRESH,
34
+ PLAYBOOK_WFCATALOG_REFRESH,
35
+ PLAYBOOK_AVAILABILITY_REFRESH,
36
+ ],
37
+ "metadata": [
38
+ PLAYBOOK_METADATA_REFRESH,
39
+ ]
40
+ },
41
+ "clean": [
42
+ PLAYBOOK_SEEDPSD_CLEAN,
43
+ PLAYBOOK_WFCATALOG_CLEAN,
44
+ PLAYBOOK_AVAILABILITY_CLEAN,
45
+ ],
46
+ "track": [
47
+ PLAYBOOK_TRACK
48
+ ]
49
+ }
dmtri/utils.py ADDED
@@ -0,0 +1,85 @@
1
+ import os
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+ def get_version():
8
+ root = Path(__file__).resolve().parents[2]
9
+ pyproject_path = root / "pyproject.toml"
10
+ with open(pyproject_path, "rb") as f:
11
+ data = tomllib.load(f)
12
+ return data["project"]["version"]
13
+
14
+ def maybe_warn_shell_globbing(args):
15
+ cwd_files = set(os.listdir("."))
16
+
17
+ def looks_globbed(value_list):
18
+ return any(v in cwd_files for v in value_list)
19
+
20
+ if looks_globbed(args.station):
21
+ print("โš ๏ธ Error: --station appears to be expanded by your shell. Use --station '*'.", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+ if looks_globbed(args.network):
25
+ print("โš ๏ธ Error: --network appears to be expanded by your shell. Use --network '*'.", file=sys.stderr)
26
+ sys.exit(1)
27
+
28
+ def validate_datetime_range(start_dt, end_dt):
29
+ now = datetime.now(timezone.utc)
30
+
31
+
32
+ if start_dt > now:
33
+ print("Error: --starttime cannot be in the future.", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ if end_dt > now:
37
+ print("Error: --endtime cannot be in the future.", file=sys.stderr)
38
+ sys.exit(1)
39
+
40
+ if end_dt <= start_dt:
41
+ print("Error: --endtime must be after --starttime.", file=sys.stderr)
42
+ sys.exit(1)
43
+
44
+ duration_days = (end_dt - start_dt).days
45
+ if duration_days > 7:
46
+ print(f"Warning: You are querying more than 7 days of data ({duration_days} days).")
47
+ proceed = input("Are you sure you want to continue? (y/N): ").strip().lower()
48
+ if proceed != "y":
49
+ print("Aborted by user.")
50
+ sys.exit(0)
51
+
52
+ def parse_datetime_args(args):
53
+ try:
54
+ start_dt = datetime.fromisoformat(args.starttime)
55
+ end_dt = datetime.fromisoformat(args.endtime)
56
+
57
+ if start_dt.tzinfo is None:
58
+ start_dt = start_dt.replace(tzinfo=timezone.utc)
59
+ if end_dt.tzinfo is None:
60
+ end_dt = end_dt.replace(tzinfo=timezone.utc)
61
+
62
+ return start_dt, end_dt
63
+
64
+ except ValueError:
65
+ print("Error: Invalid date format. Use ISO 8601 (e.g. 2025-01-01T00:00:00)", file=sys.stderr)
66
+ sys.exit(1)
67
+
68
+ def validate_types(args):
69
+ types = [t.strip() for t in args.type.split(",") if t.strip() in ("data", "metadata")]
70
+ if not types:
71
+ print("Error: --type must contain at least one of 'data' or 'metadata'", file=sys.stderr)
72
+ sys.exit(1)
73
+ return types
74
+
75
+ def validate_and_normalize_args(args, parser):
76
+ if args.command is None:
77
+ parser.print_help()
78
+ sys.exit(1)
79
+
80
+ maybe_warn_shell_globbing(args)
81
+ start_dt, end_dt = parse_datetime_args(args)
82
+ validate_datetime_range(start_dt, end_dt)
83
+ types = validate_types(args)
84
+
85
+ return start_dt, end_dt, types
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: dmtri
3
+ Version: 0.1.0
4
+ Summary: CLI tool for triggering datacenter metadata updates
5
+ Author-email: Nikos Sokos <nsokos@noa.com>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: ansible>=6.7.0
9
+ Requires-Dist: ansible-runner>=2.3.6
10
+ Requires-Dist: appdirs>=1.4.4
11
+ Requires-Dist: pytest>=8.3.5
12
+ Requires-Dist: pytest-cov>=5.0.0
13
+ Requires-Dist: requests
14
+ Requires-Dist: tomli>=2.2.1
15
+
16
+ # dmtri
17
+
18
+ [![Run Tests](https://github.com/EIDA/dmtri/actions/workflows/pytest.yml/badge.svg)](https://github.com/EIDA/dmtri/actions/workflows/pytest.yml)
19
+ ![Coverage](badges/coverage.svg)
20
+
21
+ `dmtri` is a command-line tool designed to trigger metadata and data refresh simulations across EIDA nodes using Ansible automation.
22
+
23
+ ---
24
+ - `seedpsd`
25
+ - `wfcatalog`
26
+ - `availability`
27
+
28
+ It uses `.dmtri_jobs.json` per host to track async job state and integrates with `uv` and `ansible` for CLI automation.
29
+
30
+ ---
31
+
32
+ ## Quick Start
33
+
34
+ You can use `dmtri` in two ways, depending on your preference:
35
+
36
+ ### Option 1: Using `uvx`
37
+
38
+ You can run commands using:
39
+
40
+ ```bash
41
+ uvx dmtri <command> [options]
42
+
43
+ ```
44
+
45
+ ---
46
+
47
+ ### Option 2: Global (Tool-based) โ€” Ideal for global CLI use
48
+
49
+ ```bash
50
+ uv tool install dmtri
51
+ ```
52
+
53
+ This makes `dmtri` available globally:
54
+
55
+ ```bash
56
+ dmtri <command> [options]
57
+
58
+ ```
59
+
60
+ You can update it any time using:
61
+
62
+ ```bash
63
+ uv tool upgrade dmtri
64
+ ```
65
+
66
+ ---
67
+
68
+ ### 2. Configure inventory
69
+
70
+ Edit the file: `src/inventory/hosts.ini`
71
+
72
+ Example:
73
+
74
+ ```ini
75
+ [seedpsd_nodes]
76
+ <SEEDPSD_HOST> ansible_user=<USER> ansible_ssh_pass="<PASSWORD>" ansible_connection=ssh ansible_become=false ansible_ssh_common_args='-o StrictHostKeyChecking=no'
77
+
78
+ [seedpsd_nodes:vars]
79
+ seedpsd_dir=/home/<USER>/seedpsd
80
+
81
+ [wf_catalogue]
82
+ <WFCATALOG_HOST> ansible_user=<USER> ansible_ssh_pass="<PASSWORD>" ansible_connection=ssh ansible_become=false ansible_ssh_common_args='-o StrictHostKeyChecking=no'
83
+
84
+ [wf_catalogue:vars]
85
+ collector_dir=/home/<USER>/Programs/wfcatalogue<VERSION>/wfcatalog/collector2
86
+
87
+ [ws_availability]
88
+ <AVAILABILITY_HOST> ansible_user=<USER> ansible_ssh_pass="<PASSWORD>" ansible_connection=ssh ansible_become=false ansible_ssh_common_args='-o StrictHostKeyChecking=no'
89
+
90
+ [ws_availability:vars]
91
+ availability_dir=/home/<USER>/Programs/ws-availability<VERSION>/views
92
+ mongo_user=<MONGO_USERNAME>
93
+ mongo_pass=<MONGO_PASSWORD>
94
+ mongo_authdb=<MONGO_DATABASE>
95
+
96
+ ```
97
+
98
+ ---
99
+
100
+ ### 3. Run example jobs
101
+
102
+ By default, this command runs **all refresh playbooks** (seedpsd, wfcatalog, and availability):
103
+
104
+ ```bash
105
+ uv run dmtri refresh -e "network=HL station=ATH starttime=2024-01-01T00:00:00 endtime=2024-01-02T00:00:00"
106
+ ```
107
+
108
+ ---
109
+
110
+ ### 4. Track jobs across hosts
111
+
112
+ ```bash
113
+ uv run dmtri track
114
+ ```
115
+
116
+ Shows per-host job status:
117
+ ```
118
+ Job ID: j123... Type: seedpsd Station: ATH Status: โœ… Finished
119
+ Job ID: manual-... Type: availability Status: โœ… Manual (not tracked)
120
+ ```
121
+
122
+ ---
123
+
124
+ ### 5. Troubleshooting with `dmtri doctor`
125
+
126
+ Check SSH connectivity to your configured hosts using:
127
+
128
+ ```bash
129
+ uv run dmtri doctor
130
+ ```
131
+
132
+ This uses the Ansible `ping` module to test access to all servers in your inventory.
133
+
134
+ #### Options
135
+
136
+ - `--verbose` โ€” Show full output from each host
137
+
138
+ #### Example
139
+
140
+ ```bash
141
+ uv run dmtri doctor --verbose
142
+ ```
143
+
144
+ If no response is received, ensure:
145
+
146
+ - You're connected to the VPN (if applicable)
147
+ - SSH access is configured correctly in your inventory file
148
+ - Host IPs and credentials are reachable and valid
149
+
150
+ ### Operation Details
151
+
152
+ `dmtri` supports two main operations for both metadata and data: `refresh` and `clean`. These affect the following components:
153
+
154
+ #### ๐Ÿ”„ Refresh
155
+
156
+ - **Metadata**
157
+ - `seedpsd`: Scans for new metadata.
158
+ - `availability`: Refreshes the view in MongoDB.
159
+
160
+ - **Data**
161
+ - Identifies data files in the archive for the specified epoch (by NSLC and time range).
162
+ - `seedpsd`: Recalculates seedPSD for new files.
163
+ - `wfcatalog`: Runs the collector on new data files.
164
+
165
+ #### ๐Ÿงน Clean
166
+
167
+ - **Metadata**
168
+ - `seedpsd`: Removes the metadata entries.
169
+ - `availability`: Refreshes the view in MongoDB.
170
+
171
+ - **Data**
172
+ - Identifies data files in the archive for the specified epoch.
173
+ - `seedpsd`: Removes associated seedPSD data (via CLI).
174
+ - `wfcatalog`: Removes files using the `--delete` option in the collector.
@@ -0,0 +1,13 @@
1
+ dmtri/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dmtri/cli.py,sha256=W8prY3t-1S2nBfgdrU3UkqFE321GtMwWQjh9E-Cudxc,4977
3
+ dmtri/doctor.py,sha256=jZKJPKQEciPCvwAQu2WWpa92jcGq7QN7JE1VxSB-5SQ,1605
4
+ dmtri/execution_plan.py,sha256=hVHwQ_nPUAicoAVIvl1sQBE56cTBYo7dBYnNwzqAtBs,1711
5
+ dmtri/paths.py,sha256=UxR_TaFsdqfEdzD3uy1ox45gKf0ADhOhvzCcDJ5OM38,1477
6
+ dmtri/utils.py,sha256=Z31wh0fOngyrDoqandkj1nwOVflSPUqMrWXF6-kO0nY,2750
7
+ dmtri/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dmtri/hooks/hooks.py,sha256=513anKlKihLaFen56qRGqj17pUfc_j6mEAJ_SQGkNgc,1197
9
+ dmtri-0.1.0.dist-info/METADATA,sha256=dRdOjkNR0ML1_to5i5SwQCBdZsoSen_fceJhP1rtcjk,4206
10
+ dmtri-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ dmtri-0.1.0.dist-info/entry_points.txt,sha256=GkiQuWx49WWaF2Lk838r-PYy9xib3YHTnU0avJWW80Y,41
12
+ dmtri-0.1.0.dist-info/top_level.txt,sha256=qsH9klgTnBBHWZSzKqzLd8uIPNno_X-Uq34cBkqVyuE,6
13
+ dmtri-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dmtri = dmtri.cli:main
@@ -0,0 +1 @@
1
+ dmtri