lamin_cli 1.9.0__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 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`. The root API here is used by LaminR to replicate the CLI functionality.
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.0"
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__ = ["save", "init", "connect", "delete", "login", "disconnect"]
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": ["checkout", "switch", "cache", "settings", "migrate"],
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, `artifact`, or `transform`. For example:
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 artifact --key mydatasets/mytable.parquet
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
- is_slug = entity.count("/") == 1
337
- if is_slug:
338
- from lamindb_setup._connect_instance import _connect_cli
339
- # for backward compat and convenience, connect to the instance
340
- return _connect_cli(entity)
341
- else:
342
- from lamin_cli._load import load as load_
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, uid: str | None = None, key: str | None = None, with_env: bool = False
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
- target_relpath = Path(transform.key)
106
- if len(target_relpath.parents) > 1:
107
- logger.important(
108
- "preserve the folder structure for versioning:"
109
- f" {target_relpath.parent}/"
110
- )
111
- target_relpath.parent.mkdir(parents=True, exist_ok=True)
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 target_relpath.suffix in (".ipynb", ".Rmd", ".qmd"):
119
- script_to_notebook(transform, target_relpath, bump_revision=True)
136
+ if target_path.suffix in (".ipynb", ".Rmd", ".qmd"):
137
+ script_to_notebook(transform, target_path, bump_revision=True)
120
138
  else:
121
- target_relpath.write_text(transform.source_code)
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: {target_relpath}")
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
- target_relpath.parent
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 target_relpath
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
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from typing import Optional
5
4
 
6
5
  if os.environ.get("NO_RICH"):
7
6
  import click as click
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
- path = create_path(path)
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(path, UPath)
93
- if not path.exists():
94
- raise click.BadParameter(f"Path {path} does not exist", param_hint="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(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(path, LocalPathClasses)
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
- path,
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 path.suffix in {".qmd", ".Rmd"}:
177
- html_file_exists = path.with_suffix(".html").exists()
178
- nb_html_file_exists = path.with_suffix(".nb.html").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 {path.suffix} file as an html file here"
183
- f" {path.with_suffix('.html')}"
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 - {path.with_suffix('.html')}\n -"
189
- f" {path.with_suffix('.nb.html')}"
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 = path.read_text()
194
- uid = parse_uid_from_code(content, path.suffix)
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 '{path.name}' on uid '{uid}'")
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
- # TODO: account for folders as we do in ln.track()
218
- transform_hash, _ = hash_file(path)
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 path.suffix == ".ipynb":
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(path)
261
+ nb = read_notebook(ppath)
253
262
  description = get_title(nb)
254
- elif path.suffix in {".qmd", ".Rmd"}:
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=path.name,
262
- type="script" if path.suffix in {".R", ".py"} else "notebook",
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(f"created Transform('{transform.uid}')")
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=path,
306
+ filepath=ppath,
296
307
  from_cli=True,
297
308
  )
298
309
  return return_code
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: lamin_cli
3
- Version: 1.9.0
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 3.10.1
2
+ Generator: flit 3.12.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any
@@ -1,17 +0,0 @@
1
- lamin_cli/__init__.py,sha256=DFl_WGoPWYCrKssL6X5UCL9Via9gGqBVfd0j8UzEsL0,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=nqVM8FO9kjL0SXYqP8x-Xfww6Qth4I86HKKeTirsgc4,2657
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.9.0.dist-info/entry_points.txt,sha256=Qms85i9cZPlu-U7RnVZhFsF7vJ9gaLZUFkCjcGcXTpg,49
14
- lamin_cli-1.9.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
15
- lamin_cli-1.9.0.dist-info/WHEEL,sha256=ssQ84EZ5gH1pCOujd3iW7HClo_O_aDaClUbX4B8bjKY,100
16
- lamin_cli-1.9.0.dist-info/METADATA,sha256=YhZjJlaC6E_BDVHstvkFya-5p2qEn6lZCrPUzYkN_8w,337
17
- lamin_cli-1.9.0.dist-info/RECORD,,