lamin_cli 1.8.2__py2.py3-none-any.whl → 1.10.0__py2.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.
- lamin_cli/__init__.py +12 -3
- lamin_cli/__main__.py +32 -18
- lamin_cli/_delete.py +5 -4
- lamin_cli/_io.py +144 -0
- lamin_cli/_load.py +36 -20
- lamin_cli/_migration.py +0 -1
- lamin_cli/_save.py +41 -30
- lamin_cli/_settings.py +11 -11
- lamin_cli/clone/__init__.py +0 -0
- lamin_cli/clone/_clone_verification.py +56 -0
- lamin_cli/clone/create_sqlite_clone_and_import_db.py +51 -0
- {lamin_cli-1.8.2.dist-info → lamin_cli-1.10.0.dist-info}/METADATA +3 -2
- lamin_cli-1.10.0.dist-info/RECORD +21 -0
- {lamin_cli-1.8.2.dist-info → lamin_cli-1.10.0.dist-info}/WHEEL +1 -1
- lamin_cli-1.8.2.dist-info/RECORD +0 -17
- {lamin_cli-1.8.2.dist-info → lamin_cli-1.10.0.dist-info}/entry_points.txt +0 -0
- {lamin_cli-1.8.2.dist-info → lamin_cli-1.10.0.dist-info/licenses}/LICENSE +0 -0
lamin_cli/__init__.py
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
This is the command line interface for interacting with LaminDB & LaminHub.
|
|
4
4
|
|
|
5
|
-
The interface is defined in `__main__.py`.
|
|
5
|
+
The interface is defined in `__main__.py`.
|
|
6
|
+
The root API here is used by LaminR to replicate the CLI functionality.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
|
-
__version__ = "1.
|
|
9
|
+
__version__ = "1.10.0"
|
|
9
10
|
|
|
10
11
|
from lamindb_setup import disconnect, logout
|
|
11
12
|
from lamindb_setup._connect_instance import _connect_cli as connect
|
|
@@ -15,4 +16,12 @@ from lamindb_setup._setup_user import login
|
|
|
15
16
|
from ._delete import delete
|
|
16
17
|
from ._save import save
|
|
17
18
|
|
|
18
|
-
__all__ = [
|
|
19
|
+
__all__ = [
|
|
20
|
+
"save",
|
|
21
|
+
"init",
|
|
22
|
+
"connect",
|
|
23
|
+
"delete",
|
|
24
|
+
"login",
|
|
25
|
+
"logout",
|
|
26
|
+
"disconnect",
|
|
27
|
+
]
|
lamin_cli/__main__.py
CHANGED
|
@@ -48,7 +48,13 @@ COMMAND_GROUPS = {
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"name": "Configure",
|
|
51
|
-
"commands": [
|
|
51
|
+
"commands": [
|
|
52
|
+
"checkout",
|
|
53
|
+
"switch",
|
|
54
|
+
"cache",
|
|
55
|
+
"settings",
|
|
56
|
+
"migrate",
|
|
57
|
+
],
|
|
52
58
|
},
|
|
53
59
|
{
|
|
54
60
|
"name": "Auth",
|
|
@@ -103,6 +109,7 @@ else:
|
|
|
103
109
|
from lamindb_setup._silence_loggers import silence_loggers
|
|
104
110
|
|
|
105
111
|
from lamin_cli._cache import cache
|
|
112
|
+
from lamin_cli._io import io
|
|
106
113
|
from lamin_cli._migration import migrate
|
|
107
114
|
from lamin_cli._settings import settings
|
|
108
115
|
|
|
@@ -294,54 +301,60 @@ def info(schema: bool):
|
|
|
294
301
|
@click.option("--name", type=str, default=None)
|
|
295
302
|
@click.option("--uid", type=str, default=None)
|
|
296
303
|
@click.option("--slug", type=str, default=None)
|
|
304
|
+
@click.option("--permanent", is_flag=True, default=None, help="Permanently delete the entity where applicable, e.g., for artifact, transform, collection.")
|
|
297
305
|
@click.option("--force", is_flag=True, default=False, help="Do not ask for confirmation (only relevant for instance).")
|
|
298
306
|
# fmt: on
|
|
299
|
-
def delete(entity: str, name: str | None = None, uid: str | None = None, slug: str | None = None, force: bool = False):
|
|
307
|
+
def delete(entity: str, name: str | None = None, uid: str | None = None, slug: str | None = None, permanent: bool | None = None, force: bool = False):
|
|
300
308
|
"""Delete an entity.
|
|
301
309
|
|
|
302
310
|
Currently supported: `branch`, `artifact`, `transform`, `collection`, and `instance`. For example:
|
|
303
311
|
|
|
304
312
|
```
|
|
305
313
|
lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5
|
|
314
|
+
lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5 --permanent
|
|
306
315
|
lamin delete branch --name my_branch
|
|
307
316
|
lamin delete instance --slug account/name
|
|
308
317
|
```
|
|
309
318
|
"""
|
|
310
319
|
from lamin_cli._delete import delete as delete_
|
|
311
320
|
|
|
312
|
-
return delete_(entity=entity, name=name, uid=uid, slug=slug, force=force)
|
|
321
|
+
return delete_(entity=entity, name=name, uid=uid, slug=slug, permanent=permanent, force=force)
|
|
313
322
|
|
|
314
323
|
|
|
315
324
|
@main.command()
|
|
316
|
-
@click.argument("entity", type=str)
|
|
325
|
+
@click.argument("entity", type=str, required=False)
|
|
317
326
|
@click.option("--uid", help="The uid for the entity.")
|
|
318
327
|
@click.option("--key", help="The key for the entity.")
|
|
319
328
|
@click.option(
|
|
320
329
|
"--with-env", is_flag=True, help="Also return the environment for a tranform."
|
|
321
330
|
)
|
|
322
|
-
def load(entity: str, uid: str | None = None, key: str | None = None, with_env: bool = False):
|
|
331
|
+
def load(entity: str | None = None, uid: str | None = None, key: str | None = None, with_env: bool = False):
|
|
323
332
|
"""Load a file or folder into the cache or working directory.
|
|
324
333
|
|
|
325
|
-
Pass a URL
|
|
334
|
+
Pass a URL or `--key`. For example:
|
|
326
335
|
|
|
327
336
|
```
|
|
328
337
|
lamin load https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsE
|
|
329
|
-
lamin load
|
|
338
|
+
lamin load --key mydatasets/mytable.parquet
|
|
339
|
+
lamin load --key analysis.ipynb
|
|
340
|
+
lamin load --key myanalyses/analysis.ipynb --with-env
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
You can also pass a uid and the entity type:
|
|
344
|
+
|
|
345
|
+
```
|
|
330
346
|
lamin load artifact --uid e2G7k9EVul4JbfsE
|
|
331
|
-
lamin load transform --key analysis.ipynb
|
|
332
347
|
lamin load transform --uid Vul4JbfsEYAy5
|
|
333
|
-
lamin load transform --uid Vul4JbfsEYAy5 --with-env
|
|
334
348
|
```
|
|
335
349
|
"""
|
|
336
|
-
|
|
337
|
-
if
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return load_(entity, uid=uid, key=key, with_env=with_env)
|
|
350
|
+
from lamin_cli._load import load as load_
|
|
351
|
+
if entity is not None:
|
|
352
|
+
is_slug = entity.count("/") == 1
|
|
353
|
+
if is_slug:
|
|
354
|
+
from lamindb_setup._connect_instance import _connect_cli
|
|
355
|
+
# for backward compat
|
|
356
|
+
return _connect_cli(entity)
|
|
357
|
+
return load_(entity, uid=uid, key=key, with_env=with_env)
|
|
345
358
|
|
|
346
359
|
|
|
347
360
|
def _describe(entity: str = "artifact", uid: str | None = None, key: str | None = None):
|
|
@@ -539,6 +552,7 @@ def run(filepath: str, project: str, image_url: str, packages: str, cpu: int, gp
|
|
|
539
552
|
main.add_command(settings)
|
|
540
553
|
main.add_command(cache)
|
|
541
554
|
main.add_command(migrate)
|
|
555
|
+
main.add_command(io)
|
|
542
556
|
|
|
543
557
|
# https://stackoverflow.com/questions/57810659/automatically-generate-all-help-documentation-for-click-commands
|
|
544
558
|
# https://claude.ai/chat/73c28487-bec3-4073-8110-50d1a2dd6b84
|
lamin_cli/_delete.py
CHANGED
|
@@ -9,6 +9,7 @@ def delete(
|
|
|
9
9
|
name: str | None = None,
|
|
10
10
|
uid: str | None = None,
|
|
11
11
|
slug: str | None = None,
|
|
12
|
+
permanent: bool | None = None,
|
|
12
13
|
force: bool = False,
|
|
13
14
|
):
|
|
14
15
|
# TODO: refactor to abstract getting and deleting across entities
|
|
@@ -21,22 +22,22 @@ def delete(
|
|
|
21
22
|
assert name is not None, "You have to pass a name for deleting a branch."
|
|
22
23
|
from lamindb import Branch
|
|
23
24
|
|
|
24
|
-
Branch.get(name=name).delete()
|
|
25
|
+
Branch.get(name=name).delete(permanent=permanent)
|
|
25
26
|
elif entity == "artifact":
|
|
26
27
|
assert uid is not None, "You have to pass a uid for deleting an artifact."
|
|
27
28
|
from lamindb import Artifact
|
|
28
29
|
|
|
29
|
-
Artifact.get(uid).delete()
|
|
30
|
+
Artifact.get(uid).delete(permanent=permanent)
|
|
30
31
|
elif entity == "transform":
|
|
31
32
|
assert uid is not None, "You have to pass a uid for deleting an transform."
|
|
32
33
|
from lamindb import Transform
|
|
33
34
|
|
|
34
|
-
Transform.get(uid).delete()
|
|
35
|
+
Transform.get(uid).delete(permanent=permanent)
|
|
35
36
|
elif entity == "collection":
|
|
36
37
|
assert uid is not None, "You have to pass a uid for deleting an collection."
|
|
37
38
|
from lamindb import Collection
|
|
38
39
|
|
|
39
|
-
Collection.get(uid).delete()
|
|
40
|
+
Collection.get(uid).delete(permanent=permanent)
|
|
40
41
|
elif entity == "instance":
|
|
41
42
|
return delete_instance(slug, force=force)
|
|
42
43
|
else: # backwards compatibility
|
lamin_cli/_io.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import lamindb_setup as ln_setup
|
|
11
|
+
|
|
12
|
+
from lamin_cli.clone._clone_verification import (
|
|
13
|
+
_count_instance_records,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if os.environ.get("NO_RICH"):
|
|
17
|
+
import click as click
|
|
18
|
+
else:
|
|
19
|
+
import rich_click as click
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def io():
|
|
24
|
+
"""Import and export instances."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# fmt: off
|
|
28
|
+
@io.command("snapshot")
|
|
29
|
+
@click.option("--upload/--no-upload", is_flag=True, help="Whether to upload the snapshot.", default=True)
|
|
30
|
+
@click.option("--track/--no-track", is_flag=True, help="Whether to track snapshot generation.", default=True)
|
|
31
|
+
# fmt: on
|
|
32
|
+
def snapshot(upload: bool, track: bool) -> None:
|
|
33
|
+
"""Create and optionally upload a SQLite snapshot of the current instance."""
|
|
34
|
+
if not ln_setup.settings._instance_exists:
|
|
35
|
+
raise click.ClickException(
|
|
36
|
+
"Not connected to an instance. Please run: lamin connect account/name"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
instance_owner = ln_setup.settings.instance.owner
|
|
40
|
+
instance_name = ln_setup.settings.instance.name
|
|
41
|
+
|
|
42
|
+
ln_setup.connect(f"{instance_owner}/{instance_name}", use_root_db_user=True)
|
|
43
|
+
|
|
44
|
+
import lamindb as ln
|
|
45
|
+
|
|
46
|
+
original_counts = _count_instance_records()
|
|
47
|
+
|
|
48
|
+
modules_without_lamindb = ln_setup.settings.instance.modules
|
|
49
|
+
modules_complete = modules_without_lamindb.copy()
|
|
50
|
+
modules_complete.add("lamindb")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
with tempfile.TemporaryDirectory() as export_dir:
|
|
54
|
+
if track:
|
|
55
|
+
ln.track()
|
|
56
|
+
ln_setup.io.export_db(module_names=modules_complete, output_dir=export_dir)
|
|
57
|
+
if track:
|
|
58
|
+
ln.finish()
|
|
59
|
+
|
|
60
|
+
script_path = (
|
|
61
|
+
Path(__file__).parent / "clone" / "create_sqlite_clone_and_import_db.py"
|
|
62
|
+
)
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
[
|
|
65
|
+
sys.executable,
|
|
66
|
+
str(script_path),
|
|
67
|
+
"--instance-name",
|
|
68
|
+
instance_name,
|
|
69
|
+
"--export-dir",
|
|
70
|
+
export_dir,
|
|
71
|
+
"--modules",
|
|
72
|
+
",".join(modules_without_lamindb),
|
|
73
|
+
"--original-counts",
|
|
74
|
+
json.dumps(original_counts),
|
|
75
|
+
],
|
|
76
|
+
check=False,
|
|
77
|
+
stderr=subprocess.PIPE,
|
|
78
|
+
text=True,
|
|
79
|
+
cwd=Path.cwd(),
|
|
80
|
+
)
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
try:
|
|
83
|
+
mismatches = json.loads(result.stderr.strip())
|
|
84
|
+
error_msg = "Record count mismatch detected:\n" + "\n".join(
|
|
85
|
+
[f" {table}: original={orig}, clone={clone}"
|
|
86
|
+
for table, (orig, clone) in mismatches.items()]
|
|
87
|
+
)
|
|
88
|
+
raise click.ClickException(error_msg)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
error_msg = f"Clone verification failed:\n{result.stderr}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
ln_setup.connect(f"{instance_owner}/{instance_name}", use_root_db_user=True)
|
|
94
|
+
if upload:
|
|
95
|
+
ln_setup.core._clone.upload_sqlite_clone(
|
|
96
|
+
local_sqlite_path=f"{instance_name}-clone/.lamindb/lamin.db",
|
|
97
|
+
compress=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
ln_setup.disconnect()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# fmt: off
|
|
104
|
+
@io.command("exportdb")
|
|
105
|
+
@click.option("--modules", type=str, default=None, help="Comma-separated list of modules to export (e.g., 'lamindb,bionty').",)
|
|
106
|
+
@click.option("--output-dir", type=str, help="Output directory for exported parquet files.")
|
|
107
|
+
@click.option("--max-workers", type=int, default=8, help="Number of parallel workers.")
|
|
108
|
+
@click.option("--chunk-size", type=int, default=500_000, help="Number of rows per chunk for large tables.")
|
|
109
|
+
# fmt: on
|
|
110
|
+
def exportdb(modules: str | None, output_dir: str, max_workers: int, chunk_size: int):
|
|
111
|
+
"""Export registry tables to parquet files."""
|
|
112
|
+
if not ln_setup.settings._instance_exists:
|
|
113
|
+
raise click.ClickException(
|
|
114
|
+
"Not connected to an instance. Please run: lamin connect account/name"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
module_list = modules.split(",") if modules else None
|
|
118
|
+
ln_setup.io.export_db(
|
|
119
|
+
module_names=module_list,
|
|
120
|
+
output_dir=output_dir,
|
|
121
|
+
max_workers=max_workers,
|
|
122
|
+
chunk_size=chunk_size,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# fmt: off
|
|
127
|
+
@io.command("importdb")
|
|
128
|
+
@click.option("--modules", type=str, default=None, help="Comma-separated list of modules to import (e.g., 'lamindb,bionty').")
|
|
129
|
+
@click.option("--input-dir", type=str, help="Input directory containing exported parquet files.")
|
|
130
|
+
@click.option("--if-exists", type=click.Choice(["fail", "replace", "append"]), default="replace", help="How to handle existing data.")
|
|
131
|
+
# fmt: on
|
|
132
|
+
def importdb(modules: str | None, input_dir: str, if_exists: str):
|
|
133
|
+
"""Import registry tables from parquet files."""
|
|
134
|
+
if not ln_setup.settings._instance_exists:
|
|
135
|
+
raise click.ClickException(
|
|
136
|
+
"Not connected to an instance. Please run: lamin connect account/name"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
module_list = modules.split(",") if modules else None
|
|
140
|
+
ln_setup.io.import_db(
|
|
141
|
+
module_names=module_list,
|
|
142
|
+
input_dir=input_dir,
|
|
143
|
+
if_exists=if_exists,
|
|
144
|
+
)
|
lamin_cli/_load.py
CHANGED
|
@@ -6,15 +6,35 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from lamin_utils import logger
|
|
8
8
|
|
|
9
|
-
from ._save import parse_title_r_notebook
|
|
9
|
+
from ._save import infer_registry_from_path, parse_title_r_notebook
|
|
10
10
|
from .urls import decompose_url
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def load(
|
|
14
|
-
entity: str
|
|
14
|
+
entity: str | None = None,
|
|
15
|
+
uid: str | None = None,
|
|
16
|
+
key: str | None = None,
|
|
17
|
+
with_env: bool = False,
|
|
15
18
|
):
|
|
19
|
+
"""Load artifact, collection, or transform from LaminDB.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
entity: URL containing 'lamin', or 'artifact', 'collection', or 'transform'
|
|
23
|
+
uid: Unique identifier (prefix matching supported)
|
|
24
|
+
key: Key identifier
|
|
25
|
+
with_env: If True, also load environment requirements file for transforms
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Path to loaded transform, or None for artifacts/collections
|
|
29
|
+
"""
|
|
16
30
|
import lamindb_setup as ln_setup
|
|
17
31
|
|
|
32
|
+
if entity is None:
|
|
33
|
+
if key is None:
|
|
34
|
+
raise SystemExit("Either entity or key has to be provided.")
|
|
35
|
+
else:
|
|
36
|
+
entity = infer_registry_from_path(key)
|
|
37
|
+
|
|
18
38
|
if entity.startswith("https://") and "lamin" in entity:
|
|
19
39
|
url = entity
|
|
20
40
|
instance, entity, uid = decompose_url(url)
|
|
@@ -102,27 +122,25 @@ def load(
|
|
|
102
122
|
transforms = transforms.order_by("-created_at")
|
|
103
123
|
transform = transforms.first()
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if target_relpath.exists():
|
|
113
|
-
response = input(f"! {target_relpath} exists: replace? (y/n)")
|
|
125
|
+
target_path = Path(transform.key)
|
|
126
|
+
if ln_setup.settings.dev_dir is not None:
|
|
127
|
+
target_path = ln_setup.settings.dev_dir / target_path
|
|
128
|
+
if len(target_path.parents) > 1:
|
|
129
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
if target_path.exists():
|
|
131
|
+
response = input(f"! {target_path} exists: replace? (y/n)")
|
|
114
132
|
if response != "y":
|
|
115
133
|
raise SystemExit("Aborted.")
|
|
116
134
|
|
|
117
135
|
if transform.source_code is not None:
|
|
118
|
-
if
|
|
119
|
-
script_to_notebook(transform,
|
|
136
|
+
if target_path.suffix in (".ipynb", ".Rmd", ".qmd"):
|
|
137
|
+
script_to_notebook(transform, target_path, bump_revision=True)
|
|
120
138
|
else:
|
|
121
|
-
|
|
139
|
+
target_path.write_text(transform.source_code)
|
|
122
140
|
else:
|
|
123
141
|
raise SystemExit("No source code available for this transform.")
|
|
124
142
|
|
|
125
|
-
logger.important(f"{transform.type} is here: {
|
|
143
|
+
logger.important(f"{transform.type} is here: {target_path}")
|
|
126
144
|
|
|
127
145
|
if with_env:
|
|
128
146
|
ln.settings.track_run_inputs = False
|
|
@@ -132,8 +150,7 @@ def load(
|
|
|
132
150
|
):
|
|
133
151
|
filepath_env_cache = transform.latest_run.environment.cache()
|
|
134
152
|
target_env_filename = (
|
|
135
|
-
|
|
136
|
-
/ f"{target_relpath.stem}__requirements.txt"
|
|
153
|
+
target_path.parent / f"{target_path.stem}__requirements.txt"
|
|
137
154
|
)
|
|
138
155
|
shutil.move(filepath_env_cache, target_env_filename)
|
|
139
156
|
logger.important(f"environment is here: {target_env_filename}")
|
|
@@ -142,14 +159,13 @@ def load(
|
|
|
142
159
|
"latest transform run with environment doesn't exist"
|
|
143
160
|
)
|
|
144
161
|
|
|
145
|
-
return
|
|
162
|
+
return target_path
|
|
146
163
|
case "artifact" | "collection":
|
|
147
164
|
ln.settings.track_run_inputs = False
|
|
148
165
|
|
|
149
166
|
EntityClass = ln.Artifact if entity == "artifact" else ln.Collection
|
|
150
167
|
|
|
151
|
-
# we don't use .get here because DoesNotExist is hard to catch
|
|
152
|
-
# due to private django API
|
|
168
|
+
# we don't use .get here because DoesNotExist is hard to catch due to private django API
|
|
153
169
|
# we use `.objects` here because we don't want to exclude kind = __lamindb_run__ artifacts
|
|
154
170
|
if query_by_uid:
|
|
155
171
|
entities = EntityClass.objects.filter(uid__startswith=uid)
|
lamin_cli/_migration.py
CHANGED
lamin_cli/_save.py
CHANGED
|
@@ -2,11 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
-
import sys
|
|
6
5
|
from pathlib import Path
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
6
|
|
|
9
7
|
import click
|
|
8
|
+
import lamindb_setup as ln_setup
|
|
10
9
|
from lamin_utils import logger
|
|
11
10
|
from lamindb_setup.core.hashing import hash_file
|
|
12
11
|
|
|
@@ -86,15 +85,15 @@ def save(
|
|
|
86
85
|
from lamindb_setup.core.upath import LocalPathClasses, UPath, create_path
|
|
87
86
|
|
|
88
87
|
# this allows to have the correct treatment of credentials in case of cloud paths
|
|
89
|
-
|
|
88
|
+
ppath = create_path(path)
|
|
90
89
|
# isinstance is needed to cast the type of path to UPath
|
|
91
90
|
# to avoid mypy erors
|
|
92
|
-
assert isinstance(
|
|
93
|
-
if not
|
|
94
|
-
raise click.BadParameter(f"Path {
|
|
91
|
+
assert isinstance(ppath, UPath)
|
|
92
|
+
if not ppath.exists():
|
|
93
|
+
raise click.BadParameter(f"Path {ppath} does not exist", param_hint="path")
|
|
95
94
|
|
|
96
95
|
if registry is None:
|
|
97
|
-
registry = infer_registry_from_path(
|
|
96
|
+
registry = infer_registry_from_path(ppath)
|
|
98
97
|
|
|
99
98
|
if project is not None:
|
|
100
99
|
project_record = ln.Project.filter(
|
|
@@ -121,7 +120,7 @@ def save(
|
|
|
121
120
|
f"Branch '{branch}' not found, either create it with `ln.Branch(name='...').save()` or fix typos."
|
|
122
121
|
)
|
|
123
122
|
|
|
124
|
-
is_cloud_path = not isinstance(
|
|
123
|
+
is_cloud_path = not isinstance(ppath, LocalPathClasses)
|
|
125
124
|
|
|
126
125
|
if registry == "artifact":
|
|
127
126
|
ln.settings.creation.artifact_silence_missing_run_warning = True
|
|
@@ -144,7 +143,7 @@ def save(
|
|
|
144
143
|
return "missing-key-or-description"
|
|
145
144
|
|
|
146
145
|
artifact = ln.Artifact(
|
|
147
|
-
|
|
146
|
+
ppath,
|
|
148
147
|
key=key,
|
|
149
148
|
description=description,
|
|
150
149
|
revises=revises,
|
|
@@ -167,34 +166,40 @@ def save(
|
|
|
167
166
|
if registry == "transform":
|
|
168
167
|
if key is not None:
|
|
169
168
|
logger.warning(
|
|
170
|
-
"key is ignored for transforms, the transform key is determined by the filename"
|
|
169
|
+
"key is ignored for transforms, the transform key is determined by the filename and the development directory (dev-dir)"
|
|
171
170
|
)
|
|
172
171
|
if is_cloud_path:
|
|
173
172
|
logger.error("Can not register a transform from a cloud path")
|
|
174
173
|
return "transform-with-cloud-path"
|
|
175
174
|
|
|
176
|
-
if
|
|
177
|
-
html_file_exists =
|
|
178
|
-
nb_html_file_exists =
|
|
175
|
+
if ppath.suffix in {".qmd", ".Rmd"}:
|
|
176
|
+
html_file_exists = ppath.with_suffix(".html").exists()
|
|
177
|
+
nb_html_file_exists = ppath.with_suffix(".nb.html").exists()
|
|
179
178
|
|
|
180
179
|
if not html_file_exists and not nb_html_file_exists:
|
|
181
180
|
logger.error(
|
|
182
|
-
f"Please export your {
|
|
183
|
-
f" {
|
|
181
|
+
f"Please export your {ppath.suffix} file as an html file here"
|
|
182
|
+
f" {ppath.with_suffix('.html')}"
|
|
184
183
|
)
|
|
185
184
|
return "export-qmd-Rmd-as-html"
|
|
186
185
|
elif html_file_exists and nb_html_file_exists:
|
|
187
186
|
logger.error(
|
|
188
|
-
f"Please delete one of\n - {
|
|
189
|
-
f" {
|
|
187
|
+
f"Please delete one of\n - {ppath.with_suffix('.html')}\n -"
|
|
188
|
+
f" {ppath.with_suffix('.nb.html')}"
|
|
190
189
|
)
|
|
191
190
|
return "delete-html-or-nb-html"
|
|
192
191
|
|
|
193
|
-
content =
|
|
194
|
-
uid = parse_uid_from_code(content,
|
|
192
|
+
content = ppath.read_text()
|
|
193
|
+
uid = parse_uid_from_code(content, ppath.suffix)
|
|
194
|
+
|
|
195
|
+
ppath = ppath.resolve().expanduser()
|
|
196
|
+
if ln_setup.settings.dev_dir is not None:
|
|
197
|
+
key = ppath.relative_to(ln_setup.settings.dev_dir).as_posix()
|
|
198
|
+
else:
|
|
199
|
+
key = ppath.name
|
|
195
200
|
|
|
196
201
|
if uid is not None:
|
|
197
|
-
logger.important(f"mapped '{
|
|
202
|
+
logger.important(f"mapped '{ppath.name}' on uid '{uid}'")
|
|
198
203
|
if len(uid) == 16:
|
|
199
204
|
# is full uid
|
|
200
205
|
transform = ln.Transform.filter(uid=uid).one_or_none()
|
|
@@ -214,12 +219,16 @@ def save(
|
|
|
214
219
|
if transform is None:
|
|
215
220
|
uid = f"{stem_uid}0000"
|
|
216
221
|
else:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
transform = ln.Transform.filter(key=path.name, is_latest=True).one_or_none()
|
|
222
|
+
transform_hash, _ = hash_file(ppath)
|
|
223
|
+
transform = ln.Transform.filter(hash=transform_hash).first()
|
|
220
224
|
if transform is not None and transform.hash is not None:
|
|
221
225
|
if transform.hash == transform_hash:
|
|
222
226
|
if transform.type != "notebook":
|
|
227
|
+
logger.important(f"transform already saved: {transform}")
|
|
228
|
+
if transform.key != key:
|
|
229
|
+
transform.key = key
|
|
230
|
+
logger.important(f"updated key to '{key}'")
|
|
231
|
+
transform.save()
|
|
223
232
|
return None
|
|
224
233
|
if os.getenv("LAMIN_TESTING") == "true":
|
|
225
234
|
response = "y"
|
|
@@ -245,21 +254,21 @@ def save(
|
|
|
245
254
|
if revises is None:
|
|
246
255
|
raise ln.errors.InvalidArgument("The stem uid is not found.")
|
|
247
256
|
if transform is None:
|
|
248
|
-
if
|
|
257
|
+
if ppath.suffix == ".ipynb":
|
|
249
258
|
from nbproject.dev import read_notebook
|
|
250
259
|
from nbproject.dev._meta_live import get_title
|
|
251
260
|
|
|
252
|
-
nb = read_notebook(
|
|
261
|
+
nb = read_notebook(ppath)
|
|
253
262
|
description = get_title(nb)
|
|
254
|
-
elif
|
|
263
|
+
elif ppath.suffix in {".qmd", ".Rmd"}:
|
|
255
264
|
description = parse_title_r_notebook(content)
|
|
256
265
|
else:
|
|
257
266
|
description = None
|
|
258
267
|
transform = ln.Transform(
|
|
259
268
|
uid=uid,
|
|
260
269
|
description=description,
|
|
261
|
-
key=
|
|
262
|
-
type="script" if
|
|
270
|
+
key=key,
|
|
271
|
+
type="script" if ppath.suffix in {".R", ".py"} else "notebook",
|
|
263
272
|
revises=revises,
|
|
264
273
|
)
|
|
265
274
|
if space is not None:
|
|
@@ -267,7 +276,9 @@ def save(
|
|
|
267
276
|
if branch is not None:
|
|
268
277
|
transform.branch = branch_record
|
|
269
278
|
transform.save()
|
|
270
|
-
logger.important(
|
|
279
|
+
logger.important(
|
|
280
|
+
f"created Transform('{transform.uid}', key='{transform.key}')"
|
|
281
|
+
)
|
|
271
282
|
if project is not None:
|
|
272
283
|
transform.projects.add(project_record)
|
|
273
284
|
logger.important(f"labeled with project: {project_record.name}")
|
|
@@ -292,7 +303,7 @@ def save(
|
|
|
292
303
|
return_code = save_context_core(
|
|
293
304
|
run=run,
|
|
294
305
|
transform=transform,
|
|
295
|
-
filepath=
|
|
306
|
+
filepath=ppath,
|
|
296
307
|
from_cli=True,
|
|
297
308
|
)
|
|
298
309
|
return return_code
|
lamin_cli/_settings.py
CHANGED
|
@@ -22,7 +22,7 @@ def settings(ctx):
|
|
|
22
22
|
|
|
23
23
|
Allows to get and set these settings:
|
|
24
24
|
|
|
25
|
-
- `
|
|
25
|
+
- `dev-dir` → {attr}`~lamindb.setup.core.SetupSettings.dev_dir`
|
|
26
26
|
- `private-django-api` → {attr}`~lamindb.setup.core.SetupSettings.private_django_api`
|
|
27
27
|
- `branch` → current branch (use `lamin switch --branch` to change)
|
|
28
28
|
- `space` → current space (use `lamin switch --space` to change)
|
|
@@ -30,16 +30,16 @@ def settings(ctx):
|
|
|
30
30
|
Examples for getting a setting:
|
|
31
31
|
|
|
32
32
|
```
|
|
33
|
-
lamin settings get
|
|
33
|
+
lamin settings get dev-dir
|
|
34
34
|
lamin settings get branch
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Examples for setting the working directory:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
lamin settings set
|
|
41
|
-
lamin settings set
|
|
42
|
-
lamin settings set
|
|
40
|
+
lamin settings set dev-dir . # set dev-dir to current directory
|
|
41
|
+
lamin settings set dev-dir ~/my-project # set dev-dir to ~/my-project
|
|
42
|
+
lamin settings set dev-dir none # unset dev-dir
|
|
43
43
|
```
|
|
44
44
|
"""
|
|
45
45
|
if ctx.invoked_subcommand is None:
|
|
@@ -53,7 +53,7 @@ def settings(ctx):
|
|
|
53
53
|
@click.argument(
|
|
54
54
|
"setting",
|
|
55
55
|
type=click.Choice(
|
|
56
|
-
["auto-connect", "private-django-api", "
|
|
56
|
+
["auto-connect", "private-django-api", "dev-dir"], case_sensitive=False
|
|
57
57
|
),
|
|
58
58
|
)
|
|
59
59
|
@click.argument("value") # No explicit type - let Click handle it
|
|
@@ -65,17 +65,17 @@ def set(setting: str, value: str):
|
|
|
65
65
|
settings_.auto_connect = click.BOOL(value)
|
|
66
66
|
if setting == "private-django-api":
|
|
67
67
|
settings_.private_django_api = click.BOOL(value)
|
|
68
|
-
if setting == "
|
|
68
|
+
if setting == "dev-dir":
|
|
69
69
|
if value.lower() == "none":
|
|
70
70
|
value = None # type: ignore[assignment]
|
|
71
|
-
settings_.
|
|
71
|
+
settings_.dev_dir = value
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
@settings.command("get")
|
|
75
75
|
@click.argument(
|
|
76
76
|
"setting",
|
|
77
77
|
type=click.Choice(
|
|
78
|
-
["auto-connect", "private-django-api", "space", "branch", "
|
|
78
|
+
["auto-connect", "private-django-api", "space", "branch", "dev-dir"],
|
|
79
79
|
case_sensitive=False,
|
|
80
80
|
),
|
|
81
81
|
)
|
|
@@ -87,8 +87,8 @@ def get(setting: str):
|
|
|
87
87
|
_, value = settings_._read_branch_idlike_name()
|
|
88
88
|
elif setting == "space":
|
|
89
89
|
_, value = settings_._read_space_idlike_name()
|
|
90
|
-
elif setting == "
|
|
91
|
-
value = settings_.
|
|
90
|
+
elif setting == "dev-dir":
|
|
91
|
+
value = settings_.dev_dir
|
|
92
92
|
if value is None:
|
|
93
93
|
value = "None"
|
|
94
94
|
else:
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
|
|
3
|
+
from django.db import OperationalError, ProgrammingError
|
|
4
|
+
from lamin_utils import logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _count_instance_records() -> dict[str, int]:
|
|
8
|
+
"""Count all records across SQLRecord registries in parallel.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
Dictionary mapping table names (format: "app_label.ModelName") to their record counts.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> counts = _count_all_records()
|
|
15
|
+
>>> counts
|
|
16
|
+
{'lamindb.Artifact': 1523, 'lamindb.Collection': 42, 'bionty.Gene': 60000}
|
|
17
|
+
"""
|
|
18
|
+
# Import here to ensure that models are loaded
|
|
19
|
+
from django.apps import apps
|
|
20
|
+
from lamindb.models import SQLRecord
|
|
21
|
+
|
|
22
|
+
def _count_model(model):
|
|
23
|
+
"""Count records for a single model."""
|
|
24
|
+
table_name = f"{model._meta.app_label}.{model.__name__}"
|
|
25
|
+
try:
|
|
26
|
+
return (table_name, model.objects.count())
|
|
27
|
+
except (OperationalError, ProgrammingError) as e:
|
|
28
|
+
logger.warning(f"Could not count {table_name}: {e}")
|
|
29
|
+
return (table_name, 0)
|
|
30
|
+
|
|
31
|
+
models = [m for m in apps.get_models() if issubclass(m, SQLRecord)]
|
|
32
|
+
|
|
33
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
34
|
+
results = executor.map(_count_model, models)
|
|
35
|
+
|
|
36
|
+
return dict(results)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _compare_record_counts(
|
|
40
|
+
original: dict[str, int], clone: dict[str, int]
|
|
41
|
+
) -> dict[str, tuple[int, int]]:
|
|
42
|
+
"""Compare record counts and return mismatches."""
|
|
43
|
+
mismatches = {}
|
|
44
|
+
|
|
45
|
+
all_tables = set(original.keys()) | set(clone.keys())
|
|
46
|
+
|
|
47
|
+
for table in all_tables:
|
|
48
|
+
orig_count = original.get(table, 0)
|
|
49
|
+
clone_count = clone.get(table, 0)
|
|
50
|
+
|
|
51
|
+
# we allow a difference of 1 because of tracking
|
|
52
|
+
# new records during the cloning process
|
|
53
|
+
if abs(clone_count - orig_count) > 1:
|
|
54
|
+
mismatches[table] = (orig_count, clone_count)
|
|
55
|
+
|
|
56
|
+
return mismatches
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import lamindb_setup as ln_setup
|
|
7
|
+
|
|
8
|
+
if __name__ == "__main__":
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser()
|
|
12
|
+
parser.add_argument("--instance-name", required=True)
|
|
13
|
+
parser.add_argument("--export-dir", required=True)
|
|
14
|
+
parser.add_argument("--modules", required=True)
|
|
15
|
+
parser.add_argument("--original-counts", required=True)
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
instance_name = args.instance_name
|
|
19
|
+
export_dir = args.export_dir
|
|
20
|
+
modules_without_lamindb = {m for m in args.modules.split(",") if m}
|
|
21
|
+
modules_complete = modules_without_lamindb.copy()
|
|
22
|
+
modules_complete.add("lamindb")
|
|
23
|
+
|
|
24
|
+
ln_setup.init(
|
|
25
|
+
storage=f"{instance_name}-clone", modules=f"{','.join(modules_without_lamindb)}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ln_setup.io.import_db(
|
|
29
|
+
module_names=list(modules_complete), input_dir=export_dir, if_exists="replace"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from django.db import connection
|
|
33
|
+
|
|
34
|
+
with connection.cursor() as cursor:
|
|
35
|
+
cursor.execute("PRAGMA wal_checkpoint(FULL)")
|
|
36
|
+
|
|
37
|
+
from lamin_cli.clone._clone_verification import (
|
|
38
|
+
_compare_record_counts,
|
|
39
|
+
_count_instance_records,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
clone_counts = _count_instance_records()
|
|
43
|
+
original_counts = json.loads(args.original_counts)
|
|
44
|
+
mismatches = _compare_record_counts(original_counts, clone_counts)
|
|
45
|
+
if mismatches:
|
|
46
|
+
print(json.dumps(mismatches), file=sys.stderr)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
from django.db import connections
|
|
50
|
+
|
|
51
|
+
connections.close_all()
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: lamin_cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.0
|
|
4
4
|
Summary: Lamin CLI.
|
|
5
5
|
Author-email: Lamin Labs <open-source@lamin.ai>
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
7
8
|
Requires-Dist: rich-click>=1.7
|
|
8
9
|
Project-URL: Home, https://github.com/laminlabs/lamin-cli
|
|
9
10
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
lamin_cli/__init__.py,sha256=EEqwDNhBI_f1OthJACbb7XrDCxzXxoPuIkCP0wbdWdk,605
|
|
2
|
+
lamin_cli/__main__.py,sha256=zGMlXpDgKZpfQqIsedPnk0S8-jy0NEFcF4amtyIOHE0,19721
|
|
3
|
+
lamin_cli/_annotate.py,sha256=ZD76__K-mQt7UpYqyM1I2lKCs-DraTmnkjsByIHmD-g,1839
|
|
4
|
+
lamin_cli/_cache.py,sha256=oplwE8AcS_9PYptQUZxff2qTIdNFS81clGPkJNWk098,800
|
|
5
|
+
lamin_cli/_delete.py,sha256=1wp8LQdiWHJznRrm4abxEhApB9derBWEm25ufrzjvIg,1531
|
|
6
|
+
lamin_cli/_io.py,sha256=v103aEmMPegQQJGUfsrAB5rzdLN4NqkN07ZxhglTv7o,4989
|
|
7
|
+
lamin_cli/_load.py,sha256=OqkOJe2Afq4ZrN5abM1ueqaDDvm1psdnQmzhXTTWe1I,8233
|
|
8
|
+
lamin_cli/_migration.py,sha256=w-TLYC2q6sqcfNtIUewqRW_69_xy_71X3eSV7Ud58jk,1239
|
|
9
|
+
lamin_cli/_save.py,sha256=bysKSkivcvELW1srOoc772udOzREEnFRVPUClBJ9Qac,12084
|
|
10
|
+
lamin_cli/_settings.py,sha256=nqVM8FO9kjL0SXYqP8x-Xfww6Qth4I86HKKeTirsgc4,2657
|
|
11
|
+
lamin_cli/urls.py,sha256=gc72s4SpaAQA8J50CtCIWlr49DWOSL_a6OM9lXfPouM,367
|
|
12
|
+
lamin_cli/clone/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
lamin_cli/clone/_clone_verification.py,sha256=8hRFZt_1Z0i2wMqKBkrioq01RJEjwHy9cYmw6LV4PXI,1835
|
|
14
|
+
lamin_cli/clone/create_sqlite_clone_and_import_db.py,sha256=yv2mLkHdRUao_IkZO_4Swu5w4Nieh1gMrBwPbyn4c1o,1544
|
|
15
|
+
lamin_cli/compute/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
lamin_cli/compute/modal.py,sha256=QnR7GyyvWWWkLnou95HxS9xxSQfw1k-SiefM_qRVnU0,6010
|
|
17
|
+
lamin_cli-1.10.0.dist-info/entry_points.txt,sha256=Qms85i9cZPlu-U7RnVZhFsF7vJ9gaLZUFkCjcGcXTpg,49
|
|
18
|
+
lamin_cli-1.10.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
19
|
+
lamin_cli-1.10.0.dist-info/WHEEL,sha256=Dyt6SBfaasWElUrURkknVFAZDHSTwxg3PaTza7RSbkY,100
|
|
20
|
+
lamin_cli-1.10.0.dist-info/METADATA,sha256=QIppUnCd0P8oMGAcvCSREQo5De9ifM_uomSTgfWdH6k,360
|
|
21
|
+
lamin_cli-1.10.0.dist-info/RECORD,,
|
lamin_cli-1.8.2.dist-info/RECORD
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
lamin_cli/__init__.py,sha256=UE_o2BSyhfXL0PkGbgUKzFvRh9nw9oZf7sdX1gNf0G0,563
|
|
2
|
-
lamin_cli/__main__.py,sha256=zuzvpfWDvrLxvcLNwAyGQHIGXiFvAra4dnPe7Nygu6M,19225
|
|
3
|
-
lamin_cli/_annotate.py,sha256=ZD76__K-mQt7UpYqyM1I2lKCs-DraTmnkjsByIHmD-g,1839
|
|
4
|
-
lamin_cli/_cache.py,sha256=oplwE8AcS_9PYptQUZxff2qTIdNFS81clGPkJNWk098,800
|
|
5
|
-
lamin_cli/_delete.py,sha256=dxItUgf7At247iYGLagmJ2Ccp5nAWgodPL7c7zfZQ_0,1420
|
|
6
|
-
lamin_cli/_load.py,sha256=OgTGqZQtoJ0GYOhuJihz-izt0F4jM4U_nSX_vhPoukg,7698
|
|
7
|
-
lamin_cli/_migration.py,sha256=XUl_L9_3pTTk5jJoBiqbzf0Bd2LdKKtHa1zPZ4Rla5c,1267
|
|
8
|
-
lamin_cli/_save.py,sha256=EPYsRpl0vD_Q52ksqVyP56ZOvPXBjgTFPeJWyGokN8I,11555
|
|
9
|
-
lamin_cli/_settings.py,sha256=mTnwU5zfBGwt9x6RrhFpi4f0DeyEfI02YWs96x_JYWw,2672
|
|
10
|
-
lamin_cli/urls.py,sha256=gc72s4SpaAQA8J50CtCIWlr49DWOSL_a6OM9lXfPouM,367
|
|
11
|
-
lamin_cli/compute/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
lamin_cli/compute/modal.py,sha256=QnR7GyyvWWWkLnou95HxS9xxSQfw1k-SiefM_qRVnU0,6010
|
|
13
|
-
lamin_cli-1.8.2.dist-info/entry_points.txt,sha256=Qms85i9cZPlu-U7RnVZhFsF7vJ9gaLZUFkCjcGcXTpg,49
|
|
14
|
-
lamin_cli-1.8.2.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
15
|
-
lamin_cli-1.8.2.dist-info/WHEEL,sha256=ssQ84EZ5gH1pCOujd3iW7HClo_O_aDaClUbX4B8bjKY,100
|
|
16
|
-
lamin_cli-1.8.2.dist-info/METADATA,sha256=ISh-SL8AGEvoOOSIkaUREwzYdLVk4U01NVAgySnzA20,337
|
|
17
|
-
lamin_cli-1.8.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|