datavo-cli 0.22.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.
datavo_cli/render.py ADDED
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+ from tabulate import tabulate
8
+
9
+
10
+ def _to_payload(value: Any) -> Any:
11
+ if isinstance(value, BaseModel):
12
+ return value.model_dump(mode="json", by_alias=True)
13
+ return value
14
+
15
+
16
+ def _compact_keys(keys: list[str] | None, limit: int = 4) -> str:
17
+ keys = keys or []
18
+ if len(keys) <= limit:
19
+ return ", ".join(keys)
20
+ return ", ".join(keys[:limit]) + f" (+{len(keys) - limit})"
21
+
22
+
23
+ def _owner_cell(item: dict) -> str:
24
+ """The owning team as a name, marking a personal team as such.
25
+
26
+ Prefers the name the server resolved and falls back to the id only when there is
27
+ none — an older server, or a person whose claims were never captured. The
28
+ ``personal:`` prefix is never stripped to manufacture a name: the fragment behind it
29
+ is the identity provider's object id, which is exactly what this column existed to
30
+ stop printing.
31
+ """
32
+ owner = item.get("owner_team_id")
33
+ if not owner:
34
+ return "-"
35
+ shown = item.get("owner_team_display_name") or owner
36
+ return f"{shown} (personal)" if str(owner).startswith("personal:") else str(shown)
37
+
38
+
39
+ def render_output(value: Any, *, output_format: str = "plain") -> None:
40
+ payload = _to_payload(value)
41
+ if output_format == "json":
42
+ print(json.dumps(payload, indent=2))
43
+ return
44
+ _render_plain(payload)
45
+
46
+
47
+ def _render_plain(payload: Any) -> None:
48
+ if isinstance(payload, dict):
49
+ if "candidate_count" in payload and "exclusive_group_count" in payload:
50
+ print(f"dataset_name: {payload.get('dataset_name')}")
51
+ print(f"candidate_count: {payload.get('candidate_count')}")
52
+ if payload.get("total_hours") is not None:
53
+ print(f"total_hours: {payload.get('total_hours')}")
54
+ print(f"exclusive_group_count: {payload.get('exclusive_group_count')}")
55
+ rows = [
56
+ [
57
+ item.get("name"),
58
+ item.get("selector_kind"),
59
+ item.get("target_ratio"),
60
+ item.get("assigned_count"),
61
+ round(item["assigned_ratio"], 4) if item.get("assigned_ratio") is not None else None,
62
+ ]
63
+ for item in payload.get("splits", [])
64
+ ]
65
+ if rows:
66
+ print("splits:")
67
+ print(tabulate(rows, headers=["split", "selector", "target_ratio", "assigned", "assigned_ratio"]))
68
+ for summary in payload.get("rule_summaries", []):
69
+ print(f"rule: {summary}")
70
+ for warning in payload.get("warnings", []):
71
+ print(f"warning: {warning}")
72
+ return
73
+ if "parent_dataset" in payload and "definition" in payload:
74
+ print(f"name: {payload.get('name')}")
75
+ print(f"parent_dataset: {payload.get('parent_dataset')}")
76
+ print(f"lifecycle_state: {payload.get('lifecycle_state')}")
77
+ if payload.get("error_message"):
78
+ print(f"error_message: {payload.get('error_message')}")
79
+ rows = [
80
+ [
81
+ item.get("split_name"),
82
+ item.get("dataset_name"),
83
+ item.get("sample_count"),
84
+ item.get("shard_count"),
85
+ ]
86
+ for item in payload.get("datasets", [])
87
+ ]
88
+ if rows:
89
+ print("datasets:")
90
+ print(tabulate(rows, headers=["split", "dataset", "samples", "shards"]))
91
+ for summary in payload.get("rule_summaries", []):
92
+ print(f"rule: {summary}")
93
+ return
94
+ if "items" in payload and "dataset_name" in payload and "total" in payload:
95
+ print(f"dataset_name: {payload.get('dataset_name')}")
96
+ print(f"total: {payload.get('total')}")
97
+ rows = [
98
+ [
99
+ item.get("name"),
100
+ item.get("lifecycle_state"),
101
+ ",".join(item.get("split_names", [])),
102
+ item.get("sample_count"),
103
+ _owner_cell(item),
104
+ item.get("sharing") or "-",
105
+ ]
106
+ for item in payload["items"]
107
+ ]
108
+ if rows:
109
+ print(
110
+ tabulate(
111
+ rows,
112
+ headers=["split_set", "state", "splits", "samples", "owner", "sharing"],
113
+ )
114
+ )
115
+ return
116
+ if (
117
+ "items" in payload
118
+ and "total" in payload
119
+ and "offset" in payload
120
+ and "dataset_name" not in payload
121
+ and "dataset" not in payload
122
+ ):
123
+ # DatasetListResponse: items + total + paging, no parent dataset.
124
+ print(f"total: {payload.get('total')}")
125
+ rows = [
126
+ [
127
+ item.get("name"),
128
+ item.get("lifecycle_state"),
129
+ item.get("sample_store"),
130
+ item.get("sample_count"),
131
+ item.get("shard_count"),
132
+ _compact_keys(item.get("keys")),
133
+ _owner_cell(item),
134
+ item.get("sharing") or "-",
135
+ item.get("created_at"),
136
+ ]
137
+ for item in payload["items"]
138
+ ]
139
+ if rows:
140
+ print(
141
+ tabulate(
142
+ rows,
143
+ headers=[
144
+ "name", "state", "store", "samples", "shards", "keys",
145
+ "owner", "sharing", "created_at",
146
+ ],
147
+ )
148
+ )
149
+ else:
150
+ print("datasets: (none)")
151
+ return
152
+ if "operations" in payload and "sample_store" in payload:
153
+ # Store operation log (list_store_operations) or a single-op revert summary.
154
+ print(f"sample_store: {payload.get('sample_store')}")
155
+ rows = [
156
+ [
157
+ op.get("id"),
158
+ op.get("kind"),
159
+ op.get("state"),
160
+ op.get("operation_id"),
161
+ op.get("summary"),
162
+ op.get("created_at"),
163
+ ]
164
+ for op in payload.get("operations", [])
165
+ ]
166
+ if rows:
167
+ print(tabulate(rows, headers=["id", "kind", "state", "operation_id", "summary", "created_at"]))
168
+ else:
169
+ print("operations: (none)")
170
+ return
171
+ if "split_datasets" in payload:
172
+ if payload.get("parent_dataset") is not None:
173
+ print(f"parent_dataset: {payload.get('parent_dataset')}")
174
+ if payload.get("split_set"):
175
+ print(f"split_set: {payload.get('split_set')}")
176
+ rows = [
177
+ [
178
+ item.get("split_set"),
179
+ item.get("split_name"),
180
+ item.get("dataset_name"),
181
+ item.get("sample_count"),
182
+ item.get("shard_count"),
183
+ ]
184
+ for item in payload.get("split_datasets", [])
185
+ ]
186
+ if rows:
187
+ print(tabulate(rows, headers=["split_set", "split", "dataset", "samples", "shards"]))
188
+ else:
189
+ print("split_datasets: (none)")
190
+ return
191
+ if "sample_stores" in payload and isinstance(payload.get("sample_stores"), list):
192
+ rows = [
193
+ [
194
+ item.get("name"),
195
+ item.get("sample_count"),
196
+ item.get("catalog_visibility", "visible"),
197
+ _owner_cell(item),
198
+ item.get("sharing") or "-",
199
+ item.get("created_at"),
200
+ ]
201
+ for item in payload["sample_stores"]
202
+ ]
203
+ # `visibility` and `sharing` are different things and both are shown: the first
204
+ # is the catalog listing hint a store was created with, the second is who can
205
+ # actually read it. Collapsing them would be the drift this design avoids.
206
+ print(
207
+ tabulate(
208
+ rows,
209
+ headers=["name", "sample_count", "visibility", "owner", "sharing", "created_at"],
210
+ )
211
+ )
212
+ return
213
+ if "state" in payload and "coverage" in payload and "missing_sample_count" in payload:
214
+ print(f"sample_store: {payload.get('sample_store')}")
215
+ print(f"producer_id: {payload.get('producer_id')}")
216
+ print(f"state: {payload.get('state')}")
217
+ print(f"missing_sample_count: {payload.get('missing_sample_count')}")
218
+ if payload.get("workset"):
219
+ workset = payload["workset"]
220
+ print(f"workset_id: {workset.get('workset_id')}")
221
+ print(f"workset_samples: {workset.get('sample_count')}")
222
+ if payload.get("processing_token"):
223
+ token = payload["processing_token"]
224
+ print(f"processing_token_id: {token.get('token_id')}")
225
+ print(f"expires_at: {token.get('expires_at')}")
226
+ return
227
+ if "producers" in payload and "sample_store" in payload:
228
+ print(f"sample_store: {payload.get('sample_store')}")
229
+ rows = [
230
+ [
231
+ item.get("producer_id"),
232
+ item.get("producer_kind"),
233
+ item.get("maker"),
234
+ item.get("producer_run_system"),
235
+ item.get("producer_run_ref"),
236
+ item.get("checkpoint_artifact_id"),
237
+ ",".join(item.get("produced_keys", [])),
238
+ ]
239
+ for item in payload.get("producers", [])
240
+ ]
241
+ if rows:
242
+ print(tabulate(rows, headers=["producer", "kind", "maker", "run_system", "run_ref", "checkpoint", "keys"]))
243
+ else:
244
+ print("producers: (none)")
245
+ return
246
+ if "producer_id" in payload and "produced_keys" in payload and "producer_kind" in payload:
247
+ print(f"sample_store: {payload.get('sample_store')}")
248
+ print(f"producer_id: {payload.get('producer_id')}")
249
+ print(f"producer_kind: {payload.get('producer_kind')}")
250
+ print(f"maker: {payload.get('maker')}")
251
+ if payload.get("producer_run_system") or payload.get("producer_run_ref"):
252
+ print(f"producer_run: {payload.get('producer_run_system')}:{payload.get('producer_run_ref')}")
253
+ if payload.get("checkpoint_artifact_id"):
254
+ print(f"checkpoint_artifact_id: {payload.get('checkpoint_artifact_id')}")
255
+ print(f"producer_fingerprint: {payload.get('producer_fingerprint')}")
256
+ print(f"produced_keys: {', '.join(payload.get('produced_keys', []))}")
257
+ return
258
+ if "keys" in payload and "name" in payload and "sample_count" in payload:
259
+ print(f"name: {payload.get('name')}")
260
+ print(f"sample_count: {payload.get('sample_count')}")
261
+ print(f"catalog_visibility: {payload.get('catalog_visibility', 'visible')}")
262
+ if payload.get("created_at") is not None:
263
+ print(f"created_at: {payload.get('created_at')}")
264
+ rows = [
265
+ [item.get("key"), item.get("sample_count")]
266
+ for item in payload["keys"]
267
+ ]
268
+ if rows:
269
+ print("keys:")
270
+ print(tabulate(rows, headers=["key", "sample_count"]))
271
+ return
272
+ if "deleted_counts" in payload and "name" in payload:
273
+ print(f"name: {payload.get('name')}")
274
+ rows = [
275
+ [key, value]
276
+ for key, value in payload.get("deleted_counts", {}).items()
277
+ ]
278
+ if rows:
279
+ print("deleted_counts:")
280
+ print(tabulate(rows, headers=["item", "count"]))
281
+ return
282
+ if "available_keys" in payload and "latest_revisions" in payload and "sample_id" in payload:
283
+ print(f"sample_id: {payload.get('sample_id')}")
284
+ print(f"sample_store: {payload.get('sample_store')}")
285
+ if payload.get("tags"):
286
+ print("tags:")
287
+ for key, value in sorted(payload["tags"].items()):
288
+ print(f" {key}: {value}")
289
+ if payload.get("available_keys"):
290
+ print("available_keys:")
291
+ for key in payload["available_keys"]:
292
+ print(f" - {key}")
293
+ rows = [
294
+ [
295
+ item.get("key"),
296
+ item.get("producer_id"),
297
+ item.get("revision_id"),
298
+ "yes" if item.get("is_root") else "",
299
+ item.get("ingested_at"),
300
+ ]
301
+ for item in payload["latest_revisions"]
302
+ ]
303
+ if rows:
304
+ print("latest_revisions:")
305
+ print(tabulate(rows, headers=["key", "producer", "revision", "root", "ingested_at"]))
306
+ return
307
+ if "shards" in payload and isinstance(payload.get("shards"), list):
308
+ summary = {
309
+ "dataset_name": payload.get("dataset_name"),
310
+ "lifecycle_state": payload.get("lifecycle_state"),
311
+ "sample_count": payload.get("sample_count"),
312
+ "shard_count": payload.get("shard_count"),
313
+ }
314
+ for key, value in summary.items():
315
+ if value is not None:
316
+ print(f"{key}: {value}")
317
+ if payload.get("scalars"):
318
+ print("scalars:")
319
+ for key, value in sorted(payload["scalars"].items()):
320
+ print(f" {key}: {value}")
321
+ rows = [
322
+ [
323
+ item.get("key_group"),
324
+ item.get("shard_index"),
325
+ item.get("content_hash"),
326
+ item.get("size_bytes"),
327
+ ]
328
+ for item in payload["shards"]
329
+ ]
330
+ if rows:
331
+ print(tabulate(rows, headers=["group", "shard", "hash", "size_bytes"]))
332
+ return
333
+ if "profiles" in payload and isinstance(payload.get("profiles"), list):
334
+ rows = [
335
+ [item.get("name"), item.get("api_base_url"), "yes" if item.get("active") else ""]
336
+ for item in payload["profiles"]
337
+ ]
338
+ print(tabulate(rows, headers=["profile", "url", "active"]))
339
+ return
340
+ if "key_groups" in payload and isinstance(payload.get("key_groups"), list):
341
+ summary_keys = [
342
+ "name",
343
+ "sample_store",
344
+ "lifecycle_state",
345
+ "sample_count",
346
+ "shard_count",
347
+ "split_set",
348
+ "split_name",
349
+ ]
350
+ for key in summary_keys:
351
+ value = payload.get(key)
352
+ if value is not None:
353
+ print(f"{key}: {value}")
354
+ if payload.get("scalars"):
355
+ print("scalars:")
356
+ for key, value in sorted(payload["scalars"].items()):
357
+ print(f" {key}: {value}")
358
+ rows = [
359
+ [item.get("name"), ",".join(item.get("keys", [])), item.get("manifest_ref")]
360
+ for item in payload["key_groups"]
361
+ ]
362
+ if rows:
363
+ print(tabulate(rows, headers=["group", "keys", "manifest"]))
364
+ return
365
+ if "datasets" in payload and isinstance(payload.get("datasets"), list):
366
+ print(f"name: {payload.get('name')}")
367
+ if payload.get("sample_store") is not None:
368
+ print(f"sample_store: {payload.get('sample_store')}")
369
+ if payload.get("created_at") is not None:
370
+ print(f"created_at: {payload.get('created_at')}")
371
+ print("datasets:")
372
+ for name in payload["datasets"]:
373
+ print(f" - {name}")
374
+ return
375
+ for key, value in payload.items():
376
+ if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
377
+ print(f"{key}:")
378
+ print(tabulate(value, headers="keys"))
379
+ continue
380
+ if isinstance(value, dict):
381
+ print(f"{key}:")
382
+ for nested_key, nested_value in value.items():
383
+ print(f" {nested_key}: {nested_value}")
384
+ continue
385
+ print(f"{key}: {value}")
386
+ return
387
+ if isinstance(payload, list):
388
+ print(tabulate(payload, headers="keys"))
389
+ return
390
+ print(payload)
datavo_cli/utils.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, TypeVar
6
+
7
+ import yaml
8
+ from pydantic import BaseModel
9
+
10
+ ModelT = TypeVar("ModelT", bound=BaseModel)
11
+
12
+
13
+ def parse_key_value_pairs(values: list[str] | None) -> dict[str, str]:
14
+ parsed: dict[str, str] = {}
15
+ for value in values or []:
16
+ if "=" not in value:
17
+ raise RuntimeError(f"Expected KEY=VALUE, got: {value}")
18
+ key, raw_value = value.split("=", 1)
19
+ key = key.strip()
20
+ if not key:
21
+ raise RuntimeError(f"Expected KEY=VALUE, got: {value}")
22
+ parsed[key] = raw_value
23
+ return parsed
24
+
25
+
26
+ def load_spec(path: str) -> Any:
27
+ if path == "-":
28
+ import sys
29
+
30
+ raw_text = sys.stdin.read()
31
+ else:
32
+ raw_text = Path(path).read_text(encoding="utf-8")
33
+ if not raw_text.strip():
34
+ raise RuntimeError("Spec input is empty.")
35
+ try:
36
+ return yaml.safe_load(raw_text)
37
+ except yaml.YAMLError as exc:
38
+ raise RuntimeError(f"Failed to parse spec: {exc}") from exc
39
+
40
+
41
+ def load_model(path: str, model_type: type[ModelT]) -> ModelT:
42
+ payload = load_spec(path)
43
+ return model_type.model_validate(payload)
44
+
45
+
46
+ def write_json(path: str | Path, payload: Any) -> None:
47
+ path = Path(path)
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
50
+
51
+
52
+ def model_payload(value: Any) -> Any:
53
+ if isinstance(value, BaseModel):
54
+ return value.model_dump(mode="json", by_alias=True)
55
+ return value
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: datavo-cli
3
+ Version: 0.22.0
4
+ Summary: Datavo command line interface
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Documentation, https://altavo.github.io/datavo/
7
+ Project-URL: Homepage, https://altavo.github.io/datavo/
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: argcomplete>=3.4
15
+ Requires-Dist: datavo-sdk[entra]==0.22.0
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: PyYAML>=6.0
18
+ Requires-Dist: tabulate>=0.9
19
+ Dynamic: license-file
20
+
21
+ # datavo-cli
22
+
23
+ Command-line interface for **[Datavo](https://altavo.github.io/datavo/)**, the
24
+ dataset store. Installs the `datavo` command and the `datavo_sdk` Python client
25
+ together, with Microsoft Entra sign-in.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install datavo-cli
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```bash
36
+ datavo auth login # sign in (defaults to https://prod.datavo.io)
37
+ datavo sample-store list # first authorized round-trip
38
+ datavo dataset list # browse datasets (--query, --key to filter)
39
+ datavo dataset pull voice_train_v1 --output-dir ./data # pull a dataset to disk
40
+ ```
41
+
42
+ Point at another instance with a named profile:
43
+
44
+ ```bash
45
+ datavo config set dev --url https://dev.datavo.io
46
+ datavo config use dev
47
+ ```
48
+
49
+ ## Documentation
50
+
51
+ Full guide, reference, and runnable examples: **https://altavo.github.io/datavo/**
@@ -0,0 +1,28 @@
1
+ datavo_cli/__init__.py,sha256=3ZANNzjxXIsBXP5B3WP0-uZNGqY7pm32g1LLQDB_AqE,50
2
+ datavo_cli/client_factory.py,sha256=KGc1mmSR8oAYOW55x-S_9EENSVUHV1u12tFBwziP0fA,6257
3
+ datavo_cli/config_store.py,sha256=VI3_M1PS9T3s80DQUx3l4lZ1d0CZfTRSbjSoOCaOiFU,7731
4
+ datavo_cli/diagnostics.py,sha256=_XlXqAPHhUPcVVCr9wJo-jOaAnB8yIBPxcglQHQRw6s,2744
5
+ datavo_cli/errors.py,sha256=qBVQvHSnYSvdG7DEYd6pU3nnvNSI0nAQWvZgHB5vhAQ,1719
6
+ datavo_cli/main.py,sha256=fNFyeBbtHWhvP6p4pdlsk11SsYdD-JJT6rcgBjkZ3oo,2893
7
+ datavo_cli/parsers.py,sha256=pvM6HZ6IczySQI8R8ahH1CTV3n5AdU6W3C7dINZMPlU,24324
8
+ datavo_cli/render.py,sha256=IXERl2KzR3jdGDYitd8CGZeJN9A-zGyv-g0iptLgjoE,16808
9
+ datavo_cli/utils.py,sha256=J87pMyoQLf5u3DjekCoiF-pce5e-hqc71vEkqiCMF1w,1541
10
+ datavo_cli/commands/__init__.py,sha256=49KcaQcSN_6W5EoLnI9BTHabEo6ZqeJ4VgMyagIEeAQ,1159
11
+ datavo_cli/commands/auth.py,sha256=j6R_pqCFpMagH1bSZRGUUd3SbFwyuGCtdIqxKiSj760,3457
12
+ datavo_cli/commands/config.py,sha256=rzi9V0vF04uj-qHMbCplAJR_jbH1W3nyUsOuO-BkdvE,3693
13
+ datavo_cli/commands/dataset.py,sha256=LoZhvNMuH5syPt9Qw5MzYRgQlBKC2_eh0vrzMqLKibw,8816
14
+ datavo_cli/commands/events.py,sha256=dErqXCPoGGSQE1n6_YU-AkyfCnZ2uHQEaHaFsaHh4r0,1735
15
+ datavo_cli/commands/jobpool.py,sha256=4HIgL2wf3Q0vCXl3Gf0t2O8yY8DsVHTZjdGPO_5tMCI,1490
16
+ datavo_cli/commands/sample.py,sha256=veEQqBHBbD2rELWpqNX33EoazgcCxSHxa5CAtVkTBNE,986
17
+ datavo_cli/commands/sample_store.py,sha256=7bYtouhmKAsE6_CdyxdAAKnhEq2CS0HKz2XS1xlatjg,4147
18
+ datavo_cli/commands/source_import.py,sha256=VA_nz41m3RwxGNzkS1p-22MV-lIZ8B8rtWkhJvX9TSk,1916
19
+ datavo_cli/commands/split_set.py,sha256=QKRSFp6it5auQH3Ufd1Y6kIktUYeZK1bEsdvhO5D7Fo,3482
20
+ datavo_cli/commands/stats.py,sha256=AiUQtdGgFa0O9xjMxMhcCQpJaJWEHASr-fi3vNPJTmM,716
21
+ datavo_cli/commands/team.py,sha256=KXWsnPKH2A9WjkYb9qfbSMUMacsbgUdm7p4ZaEj1Kac,7853
22
+ datavo_cli/commands/user.py,sha256=4V7aEdDiDVoeWXAlPuFc-v_aJvWnx4UWYMe70OS-m9Q,2701
23
+ datavo_cli-0.22.0.dist-info/licenses/LICENSE,sha256=5-AUJrHm4TQmwicX7lFZfJu0i7oDs9vvMhDmrenJFeI,11341
24
+ datavo_cli-0.22.0.dist-info/METADATA,sha256=mjpQX8duSlpvpi921B1JvdOHMZnvEseLPOkH9_gFEtk,1541
25
+ datavo_cli-0.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
26
+ datavo_cli-0.22.0.dist-info/entry_points.txt,sha256=0M-1L1oSSP5B3CyNwmBgulK8X7yrtW19k0wErk7erDQ,48
27
+ datavo_cli-0.22.0.dist-info/top_level.txt,sha256=YTkid9eYLDrFLGj-zDz6fIRREPAm8Vs1VS1YyfnO6uk,11
28
+ datavo_cli-0.22.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ datavo = datavo_cli.main:main