lamin_cli 1.11.0__py2.py3-none-any.whl → 1.12.1__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/__main__.py CHANGED
@@ -1,625 +1,826 @@
1
- from __future__ import annotations
2
-
3
- import inspect
4
- import os
5
- import shutil
6
- import sys
7
- import warnings
8
- from collections import OrderedDict
9
- from functools import wraps
10
- from importlib.metadata import PackageNotFoundError, version
11
- from pathlib import Path
12
- from typing import TYPE_CHECKING, Literal
13
-
14
- import lamindb_setup as ln_setup
15
- from lamin_utils import logger
16
- from lamindb_setup._init_instance import (
17
- DOC_DB,
18
- DOC_INSTANCE_NAME,
19
- DOC_MODULES,
20
- DOC_STORAGE_ARG,
21
- )
22
-
23
- from lamin_cli import connect as connect_
24
- from lamin_cli import disconnect as disconnect_
25
- from lamin_cli import init as init_
26
- from lamin_cli import login as login_
27
- from lamin_cli import logout as logout_
28
- from lamin_cli import save as save_
29
-
30
- from .urls import decompose_url
31
-
32
- if TYPE_CHECKING:
33
- from collections.abc import Mapping
34
-
35
- COMMAND_GROUPS = {
36
- "lamin": [
37
- {
38
- "name": "Manage connections",
39
- "commands": ["connect", "info", "init", "disconnect"],
40
- },
41
- {
42
- "name": "Load, save, create & delete data",
43
- "commands": ["load", "save", "create", "delete"],
44
- },
45
- {
46
- "name": "Tracking within shell scripts",
47
- "commands": ["track", "finish"],
48
- },
49
- {
50
- "name": "Describe, annotate & list data",
51
- "commands": ["describe", "annotate", "list"],
52
- },
53
- {
54
- "name": "Configure",
55
- "commands": [
56
- "checkout",
57
- "switch",
58
- "cache",
59
- "settings",
60
- "migrate",
61
- ],
62
- },
63
- {
64
- "name": "Auth",
65
- "commands": [
66
- "login",
67
- "logout",
68
- ],
69
- },
70
- ]
71
- }
72
-
73
- # https://github.com/ewels/rich-click/issues/19
74
- # Otherwise rich-click takes over the formatting.
75
- if os.environ.get("NO_RICH"):
76
- import click as click
77
-
78
- class OrderedGroup(click.Group):
79
- """Overwrites list_commands to return commands in order of definition."""
80
-
81
- def __init__(
82
- self,
83
- name: str | None = None,
84
- commands: Mapping[str, click.Command] | None = None,
85
- **kwargs,
86
- ):
87
- super().__init__(name, commands, **kwargs)
88
- self.commands = commands or OrderedDict()
89
-
90
- def list_commands(self, ctx: click.Context) -> Mapping[str, click.Command]:
91
- return self.commands
92
-
93
- lamin_group_decorator = click.group(cls=OrderedGroup)
94
-
95
- else:
96
- import rich_click as click
97
-
98
- def lamin_group_decorator(f):
99
- @click.rich_config(
100
- help_config=click.RichHelpConfiguration(
101
- command_groups=COMMAND_GROUPS,
102
- style_commands_table_column_width_ratio=(1, 10),
103
- )
104
- )
105
- @click.group()
106
- @wraps(f)
107
- def wrapper(*args, **kwargs):
108
- return f(*args, **kwargs)
109
-
110
- return wrapper
111
-
112
-
113
- from lamindb_setup._silence_loggers import silence_loggers
114
-
115
- from lamin_cli._cache import cache
116
- from lamin_cli._io import io
117
- from lamin_cli._migration import migrate
118
- from lamin_cli._settings import settings
119
-
120
- if TYPE_CHECKING:
121
- from click import Command, Context
122
-
123
- try:
124
- lamindb_version = version("lamindb")
125
- except PackageNotFoundError:
126
- lamindb_version = "lamindb installation not found"
127
-
128
-
129
- @lamin_group_decorator
130
- @click.version_option(version=lamindb_version, prog_name="lamindb")
131
- def main():
132
- """Manage data with LaminDB instances."""
133
- silence_loggers()
134
-
135
-
136
- @main.command()
137
- @click.argument("user", type=str, default=None, required=False)
138
- @click.option("--key", type=str, default=None, hidden=True, help="The legacy API key.")
139
- def login(user: str, key: str | None):
140
- # note that the docstring needs to be synced with ln.setup.login()
141
- """Log into LaminHub.
142
-
143
- `lamin login` prompts for your API key unless you set it via environment variable `LAMIN_API_KEY`.
144
-
145
- You can create your API key in your account settings on LaminHub (top right corner).
146
-
147
- After authenticating once, you can re-authenticate and switch between accounts via `lamin login myhandle`.
148
-
149
- See also: Login in a Python session via {func}`~lamindb.setup.login`.
150
- """
151
- return login_(user, key=key)
152
-
153
-
154
- @main.command()
155
- def logout():
156
- """Log out of LaminHub."""
157
- return logout_()
158
-
159
-
160
- def schema_to_modules_callback(ctx, param, value):
161
- if param.name == "schema" and value is not None:
162
- warnings.warn(
163
- "The --schema option is deprecated and will be removed in a future version."
164
- " Please use --modules instead.",
165
- DeprecationWarning,
166
- stacklevel=2,
167
- )
168
- return value
169
-
170
-
171
- # fmt: off
172
- @main.command()
173
- @click.option("--storage", type=str, default = ".", help=DOC_STORAGE_ARG)
174
- @click.option("--name", type=str, default=None, help=DOC_INSTANCE_NAME)
175
- @click.option("--db", type=str, default=None, help=DOC_DB)
176
- @click.option("--modules", type=str, default=None, help=DOC_MODULES)
177
- # fmt: on
178
- def init(
179
- storage: str,
180
- name: str | None,
181
- db: str | None,
182
- modules: str | None,
183
- ):
184
- """Init a LaminDB instance.
185
-
186
- See also: Init in a Python session via {func}`~lamindb.setup.init`.
187
- """
188
- return init_(storage=storage, db=db, modules=modules, name=name)
189
-
190
-
191
- # fmt: off
192
- @main.command()
193
- @click.argument("instance", type=str)
194
- @click.option("--use_proxy_db", is_flag=True, help="Use proxy database connection.")
195
- # fmt: on
196
- def connect(instance: str, use_proxy_db: bool):
197
- """Configure default instance for connections.
198
-
199
- Python/R sessions and CLI commands will then auto-connect to the configured instance.
200
-
201
- Pass a slug (`account/name`) or URL (`https://lamin.ai/account/name`).
202
-
203
- See also: Connect in a Python session via {func}`~lamindb.connect`.
204
- """
205
- return connect_(instance, use_proxy_db=use_proxy_db)
206
-
207
-
208
- @main.command()
209
- def disconnect():
210
- """Clear default instance configuration.
211
-
212
- See also: Disconnect in a Python session via {func}`~lamindb.setup.disconnect`.
213
- """
214
- return disconnect_()
215
-
216
-
217
- # fmt: off
218
- @main.command()
219
- @click.argument("entity", type=str)
220
- @click.option("--name", type=str, default=None, help="A name.")
221
- # fmt: on
222
- def create(entity: Literal["branch"], name: str | None = None):
223
- """Create a record for an entity.
224
-
225
- Currently only supports creating branches and projects.
226
-
227
- ```
228
- lamin create branch --name my_branch
229
- lamin create project --name my_project
230
- ```
231
- """
232
- from lamindb.models import Branch, Project
233
-
234
- if entity == "branch":
235
- record = Branch(name=name).save()
236
- elif entity == "project":
237
- record = Project(name=name).save()
238
- else:
239
- raise NotImplementedError(f"Creating {entity} is not implemented.")
240
- logger.important(f"created {entity}: {record.name}")
241
-
242
-
243
- # fmt: off
244
- @main.command(name="list")
245
- @click.argument("entity", type=str)
246
- @click.option("--name", type=str, default=None, help="A name.")
247
- # fmt: on
248
- def list_(entity: Literal["branch"], name: str | None = None):
249
- """List records for an entity.
250
-
251
- ```
252
- lamin list branch
253
- lamin list space
254
- ```
255
- """
256
- assert entity in {"branch", "space"}, "Currently only supports listing branches and spaces."
257
-
258
- from lamindb.models import Branch, Space
259
-
260
- if entity == "branch":
261
- print(Branch.to_dataframe())
262
- else:
263
- print(Space.to_dataframe())
264
-
265
-
266
- # fmt: off
267
- @main.command()
268
- @click.option("--branch", type=str, default=None, help="A valid branch name or uid.")
269
- @click.option("--space", type=str, default=None, help="A valid branch name or uid.")
270
- # fmt: on
271
- def switch(branch: str | None = None, space: str | None = None):
272
- """Switch between branches or spaces.
273
-
274
- ```
275
- lamin switch --branch my_branch
276
- lamin switch --space our_space
277
- ```
278
- """
279
- from lamindb.setup import switch as switch_
280
-
281
- switch_(branch=branch, space=space)
282
-
283
-
284
- @main.command()
285
- @click.option("--schema", is_flag=True, help="View database schema.")
286
- def info(schema: bool):
287
- """Show info about the environment, instance, branch, space, and user.
288
-
289
- See also: Print the instance settings in a Python session via {func}`~lamindb.setup.settings`.
290
- """
291
- if schema:
292
- from lamindb_setup._schema import view
293
-
294
- click.echo("Open in browser: http://127.0.0.1:8000/schema/")
295
- return view()
296
- else:
297
- from lamindb_setup import settings as settings_
298
-
299
- click.echo(settings_)
300
-
301
-
302
- # fmt: off
303
- @main.command()
304
- @click.argument("entity", type=str)
305
- @click.option("--name", type=str, default=None)
306
- @click.option("--uid", type=str, default=None)
307
- @click.option("--slug", type=str, default=None)
308
- @click.option("--permanent", is_flag=True, default=None, help="Permanently delete the entity where applicable, e.g., for artifact, transform, collection.")
309
- @click.option("--force", is_flag=True, default=False, help="Do not ask for confirmation (only relevant for instance).")
310
- # fmt: on
311
- def delete(entity: str, name: str | None = None, uid: str | None = None, slug: str | None = None, permanent: bool | None = None, force: bool = False):
312
- """Delete an entity.
313
-
314
- Currently supported: `branch`, `artifact`, `transform`, `collection`, and `instance`. For example:
315
-
316
- ```
317
- lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5
318
- lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5 --permanent
319
- lamin delete branch --name my_branch
320
- lamin delete instance --slug account/name
321
- ```
322
- """
323
- from lamin_cli._delete import delete as delete_
324
-
325
- return delete_(entity=entity, name=name, uid=uid, slug=slug, permanent=permanent, force=force)
326
-
327
-
328
- @main.command()
329
- @click.argument("entity", type=str, required=False)
330
- @click.option("--uid", help="The uid for the entity.")
331
- @click.option("--key", help="The key for the entity.")
332
- @click.option(
333
- "--with-env", is_flag=True, help="Also return the environment for a tranform."
334
- )
335
- def load(entity: str | None = None, uid: str | None = None, key: str | None = None, with_env: bool = False):
336
- """Load a file or folder into the cache or working directory.
337
-
338
- Pass a URL or `--key`. For example:
339
-
340
- ```
341
- lamin load https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsE
342
- lamin load --key mydatasets/mytable.parquet
343
- lamin load --key analysis.ipynb
344
- lamin load --key myanalyses/analysis.ipynb --with-env
345
- ```
346
-
347
- You can also pass a uid and the entity type:
348
-
349
- ```
350
- lamin load artifact --uid e2G7k9EVul4JbfsE
351
- lamin load transform --uid Vul4JbfsEYAy5
352
- ```
353
- """
354
- from lamin_cli._load import load as load_
355
- if entity is not None:
356
- is_slug = entity.count("/") == 1
357
- if is_slug:
358
- from lamindb_setup._connect_instance import _connect_cli
359
- # for backward compat
360
- return _connect_cli(entity)
361
- return load_(entity, uid=uid, key=key, with_env=with_env)
362
-
363
-
364
- def _describe(entity: str = "artifact", uid: str | None = None, key: str | None = None):
365
- if entity.startswith("https://") and "lamin" in entity:
366
- url = entity
367
- instance, entity, uid = decompose_url(url)
368
- elif entity not in {"artifact"}:
369
- raise SystemExit("Entity has to be a laminhub URL or 'artifact'")
370
- else:
371
- instance = ln_setup.settings.instance.slug
372
-
373
- ln_setup.connect(instance)
374
- import lamindb as ln
375
-
376
- if uid is not None:
377
- artifact = ln.Artifact.get(uid)
378
- else:
379
- artifact = ln.Artifact.get(key=key)
380
- artifact.describe()
381
-
382
-
383
- @main.command()
384
- @click.argument("entity", type=str, default="artifact")
385
- @click.option("--uid", help="The uid for the entity.")
386
- @click.option("--key", help="The key for the entity.")
387
- def describe(entity: str = "artifact", uid: str | None = None, key: str | None = None):
388
- """Describe an artifact.
389
-
390
- Examples:
391
-
392
- ```
393
- lamin describe --key example_datasets/mini_immuno/dataset1.h5ad
394
- lamin describe https://lamin.ai/laminlabs/lamin-site-assets/artifact/6sofuDVvTANB0f48
395
- ```
396
-
397
- See also: Describe an artifact in a Python session via {func}`~lamindb.Artifact.describe`.
398
- """
399
- _describe(entity=entity, uid=uid, key=key)
400
-
401
-
402
- @main.command()
403
- @click.argument("entity", type=str, default="artifact")
404
- @click.option("--uid", help="The uid for the entity.")
405
- @click.option("--key", help="The key for the entity.")
406
- def get(entity: str = "artifact", uid: str | None = None, key: str | None = None):
407
- """Query metadata about an entity.
408
-
409
- Currently equivalent to `lamin describe`.
410
- """
411
- logger.warning("please use `lamin describe` instead of `lamin get` to describe")
412
- _describe(entity=entity, uid=uid, key=key)
413
-
414
-
415
- @main.command()
416
- @click.argument("path", type=str)
417
- @click.option("--key", type=str, default=None, help="The key of the artifact or transform.")
418
- @click.option("--description", type=str, default=None, help="A description of the artifact or transform.")
419
- @click.option("--stem-uid", type=str, default=None, help="The stem uid of the artifact or transform.")
420
- @click.option("--project", type=str, default=None, help="A valid project name or uid.")
421
- @click.option("--space", type=str, default=None, help="A valid space name or uid.")
422
- @click.option("--branch", type=str, default=None, help="A valid branch name or uid.")
423
- @click.option("--registry", type=str, default=None, help="Either 'artifact' or 'transform'. If not passed, chooses based on path suffix.")
424
- def save(path: str, key: str, description: str, stem_uid: str, project: str, space: str, branch: str, registry: str):
425
- """Save a file or folder.
426
-
427
- Example: Given a valid project name "my_project",
428
-
429
- ```
430
- lamin save my_table.csv --key my_tables/my_table.csv --project my_project
431
- ```
432
-
433
- By passing a `--project` identifier, the artifact will be labeled with the corresponding project.
434
- If you pass a `--space` or `--branch` identifier, you save the artifact in the corresponding {class}`~lamindb.Space` or on the corresponding {class}`~lamindb.Branch`.
435
-
436
- Note: Defaults to saving `.py`, `.ipynb`, `.R`, `.Rmd`, and `.qmd` as {class}`~lamindb.Transform` and
437
- other file types and folders as {class}`~lamindb.Artifact`. You can enforce saving a file as
438
- an {class}`~lamindb.Artifact` by passing `--registry artifact`.
439
- """
440
- if save_(path=path, key=key, description=description, stem_uid=stem_uid, project=project, space=space, branch=branch, registry=registry) is not None:
441
- sys.exit(1)
442
-
443
- @main.command()
444
- def track():
445
- """Start tracking a run of a shell script.
446
-
447
- This command works like {func}`~lamindb.track()` in a Python session. Here is an example script:
448
-
449
- ```
450
- # my_script.sh
451
- set -e # exit on error
452
- lamin track # initiate a tracked shell script run
453
- lamin load --key raw/file1.txt
454
- # do something
455
- lamin save processed_file1.txt --key processed/file1.txt
456
- lamin finish # mark the shell script run as finished
457
- ```
458
-
459
- If you run that script, it will track the run of the script, and save the input and output artifacts:
460
-
461
- ```
462
- sh my_script.sh
463
- ```
464
- """
465
- from lamin_cli._context import track as track_
466
- return track_()
467
-
468
-
469
- @main.command()
470
- def finish():
471
- """Finish the current tracked run of a shell script.
472
-
473
- This command works like {func}`~lamindb.finish()` in a Python session.
474
- """
475
- from lamin_cli._context import finish as finish_
476
- return finish_()
477
-
478
-
479
- @main.command()
480
- @click.argument("entity", type=str, default=None, required=False)
481
- @click.option("--key", type=str, default=None, help="The key of an artifact or transform.")
482
- @click.option("--uid", type=str, default=None, help="The uid of an artifact or transform.")
483
- @click.option("--project", type=str, default=None, help="A valid project name or uid.")
484
- @click.option("--features", multiple=True, help="Feature annotations. Supports: feature=value, feature=val1,val2, or feature=\"val1\",\"val2\"")
485
- def annotate(entity: str | None, key: str, uid: str, project: str, features: tuple):
486
- """Annotate an artifact or transform.
487
-
488
- Entity is either 'artifact' or 'transform'. If not passed, chooses based on key suffix.
489
-
490
- You can annotate with projects and valid features & values. For example,
491
-
492
- ```
493
- lamin annotate --key raw/sample.fastq --project "My Project"
494
- lamin annotate --key raw/sample.fastq --features perturbation=IFNG,DMSO cell_line=HEK297
495
- lamin annotate --key my-notebook.ipynb --project "My Project"
496
- ```
497
- """
498
- import lamindb as ln
499
-
500
- from lamin_cli._annotate import _parse_features_list
501
- from lamin_cli._save import infer_registry_from_path
502
-
503
- # once we enable passing the URL as entity, then we don't need to throw this error
504
- if not ln.setup.settings._instance_exists:
505
- raise click.ClickException("Not connected to an instance. Please run: lamin connect account/name")
506
-
507
- if entity is None:
508
- if key is not None:
509
- registry = infer_registry_from_path(key)
510
- else:
511
- registry = "artifact"
512
- else:
513
- registry = entity
514
- if registry == "artifact":
515
- model = ln.Artifact
516
- else:
517
- model = ln.Transform
518
-
519
- # Get the artifact
520
- if key is not None:
521
- artifact = model.get(key=key)
522
- elif uid is not None:
523
- artifact = model.get(uid) # do not use uid=uid, because then no truncated uids would work
524
- else:
525
- raise ln.errors.InvalidArgument("Either --key or --uid must be provided")
526
-
527
- # Handle project annotation
528
- if project is not None:
529
- project_record = ln.Project.filter(
530
- ln.Q(name=project) | ln.Q(uid=project)
531
- ).one_or_none()
532
- if project_record is None:
533
- raise ln.errors.InvalidArgument(
534
- f"Project '{project}' not found, either create it with `ln.Project(name='...').save()` or fix typos."
535
- )
536
- artifact.projects.add(project_record)
537
-
538
- # Handle feature annotations
539
- if features:
540
- feature_dict = _parse_features_list(features)
541
- artifact.features.add_values(feature_dict)
542
-
543
- artifact_rep = artifact.key if artifact.key else artifact.description if artifact.description else artifact.uid
544
- logger.important(f"annotated {registry}: {artifact_rep}")
545
-
546
-
547
- @main.command()
548
- @click.argument("filepath", type=str)
549
- @click.option("--project", type=str, default=None, help="A valid project name or uid. When running on Modal, creates an app with the same name.", required=True)
550
- @click.option("--image-url", type=str, default=None, help="A URL to the base docker image to use.")
551
- @click.option("--packages", type=str, default="lamindb", help="A comma-separated list of additional packages to install.")
552
- @click.option("--cpu", type=float, default=None, help="Configuration for the CPU.")
553
- @click.option("--gpu", type=str, default=None, help="The type of GPU to use (only compatible with cuda images).")
554
- def run(filepath: str, project: str, image_url: str, packages: str, cpu: int, gpu: str | None):
555
- """Run a compute job in the cloud.
556
-
557
- This is an EXPERIMENTAL feature that enables to run a script on Modal.
558
-
559
- Example: Given a valid project name "my_project",
560
-
561
- ```
562
- lamin run my_script.py --project my_project
563
- ```
564
- """
565
- from lamin_cli.compute.modal import Runner
566
-
567
- default_mount_dir = Path('./modal_mount_dir')
568
- if not default_mount_dir.is_dir():
569
- default_mount_dir.mkdir(parents=True, exist_ok=True)
570
-
571
- shutil.copy(filepath, default_mount_dir)
572
-
573
- filepath_in_mount_dir = default_mount_dir / Path(filepath).name
574
-
575
- package_list = []
576
- if packages:
577
- package_list = [package.strip() for package in packages.split(',')]
578
-
579
- runner = Runner(
580
- local_mount_dir=default_mount_dir,
581
- app_name=project,
582
- packages=package_list,
583
- image_url=image_url,
584
- cpu=cpu,
585
- gpu=gpu
586
- )
587
-
588
- runner.run(filepath_in_mount_dir)
589
-
590
-
591
- main.add_command(settings)
592
- main.add_command(cache)
593
- main.add_command(migrate)
594
- main.add_command(io)
595
-
596
- # https://stackoverflow.com/questions/57810659/automatically-generate-all-help-documentation-for-click-commands
597
- # https://claude.ai/chat/73c28487-bec3-4073-8110-50d1a2dd6b84
598
- def _generate_help():
599
- out: dict[str, dict[str, str | None]] = {}
600
-
601
- def recursive_help(
602
- cmd: Command, parent: Context | None = None, name: tuple[str, ...] = ()
603
- ):
604
- ctx = click.Context(cmd, info_name=cmd.name, parent=parent)
605
- assert cmd.name
606
- name = (*name, cmd.name)
607
- command_name = " ".join(name)
608
-
609
- docstring = inspect.getdoc(cmd.callback)
610
- usage = cmd.get_help(ctx).split("\n")[0]
611
- options = cmd.get_help(ctx).split("Options:")[1]
612
- out[command_name] = {
613
- "help": usage + "\n\nOptions:" + options,
614
- "docstring": docstring,
615
- }
616
-
617
- for sub in getattr(cmd, "commands", {}).values():
618
- recursive_help(sub, ctx, name=name)
619
-
620
- recursive_help(main)
621
- return out
622
-
623
-
624
- if __name__ == "__main__":
625
- main()
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import warnings
8
+ from collections import OrderedDict
9
+ from functools import wraps
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ import lamindb_setup as ln_setup
15
+ from lamin_utils import logger
16
+ from lamindb_setup._init_instance import (
17
+ DOC_DB,
18
+ DOC_INSTANCE_NAME,
19
+ DOC_MODULES,
20
+ DOC_STORAGE_ARG,
21
+ )
22
+
23
+ from lamin_cli import connect as connect_
24
+ from lamin_cli import disconnect as disconnect_
25
+ from lamin_cli import init as init_
26
+ from lamin_cli import login as login_
27
+ from lamin_cli import logout as logout_
28
+ from lamin_cli import save as save_
29
+
30
+ from .urls import decompose_url
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Mapping
34
+
35
+ COMMAND_GROUPS = {
36
+ "lamin": [
37
+ {
38
+ "name": "Manage connections",
39
+ "commands": ["connect", "info", "init", "disconnect"],
40
+ },
41
+ {
42
+ "name": "Load, save, create & delete data",
43
+ "commands": ["load", "save", "create", "delete"],
44
+ },
45
+ {
46
+ "name": "Tracking within shell scripts",
47
+ "commands": ["track", "finish"],
48
+ },
49
+ {
50
+ "name": "Describe, annotate & list data",
51
+ "commands": ["describe", "annotate", "list"],
52
+ },
53
+ {
54
+ "name": "Configure",
55
+ "commands": [
56
+ "switch",
57
+ "settings",
58
+ "migrate",
59
+ ],
60
+ },
61
+ {
62
+ "name": "Auth",
63
+ "commands": [
64
+ "login",
65
+ "logout",
66
+ ],
67
+ },
68
+ ]
69
+ }
70
+
71
+ # https://github.com/ewels/rich-click/issues/19
72
+ # Otherwise rich-click takes over the formatting.
73
+ if os.environ.get("NO_RICH"):
74
+ import click as click
75
+
76
+ class OrderedGroup(click.Group):
77
+ """Overwrites list_commands to return commands in order of definition."""
78
+
79
+ def __init__(
80
+ self,
81
+ name: str | None = None,
82
+ commands: Mapping[str, click.Command] | None = None,
83
+ **kwargs,
84
+ ):
85
+ super().__init__(name, commands, **kwargs)
86
+ self.commands = commands or OrderedDict()
87
+
88
+ def list_commands(self, ctx: click.Context) -> Mapping[str, click.Command]:
89
+ return self.commands
90
+
91
+ lamin_group_decorator = click.group(cls=OrderedGroup)
92
+
93
+ else:
94
+ import rich_click as click
95
+
96
+ def lamin_group_decorator(f):
97
+ @click.rich_config(
98
+ help_config=click.RichHelpConfiguration(
99
+ command_groups=COMMAND_GROUPS,
100
+ style_commands_table_column_width_ratio=(1, 10),
101
+ )
102
+ )
103
+ @click.group()
104
+ @wraps(f)
105
+ def wrapper(*args, **kwargs):
106
+ return f(*args, **kwargs)
107
+
108
+ return wrapper
109
+
110
+
111
+ from lamindb_setup._silence_loggers import silence_loggers
112
+
113
+ from lamin_cli._io import io
114
+ from lamin_cli._migration import migrate
115
+ from lamin_cli._settings import settings
116
+
117
+ if TYPE_CHECKING:
118
+ from click import Command, Context
119
+
120
+ try:
121
+ lamindb_version = version("lamindb")
122
+ except PackageNotFoundError:
123
+ lamindb_version = "lamindb installation not found"
124
+
125
+
126
+ @lamin_group_decorator
127
+ @click.version_option(version=lamindb_version, prog_name="lamindb")
128
+ def main():
129
+ """Manage data with LaminDB instances."""
130
+ silence_loggers()
131
+
132
+
133
+ @main.command()
134
+ @click.argument("user", type=str, default=None, required=False)
135
+ @click.option("--key", type=str, default=None, hidden=True, help="The legacy API key.")
136
+ def login(user: str, key: str | None):
137
+ # note that the docstring needs to be synced with ln.setup.login()
138
+ """Log into LaminHub.
139
+
140
+ `lamin login` prompts for your API key unless you set it via environment variable `LAMIN_API_KEY`.
141
+
142
+ You can create your API key in your account settings on LaminHub (top right corner).
143
+
144
+ After authenticating once, you can re-authenticate and switch between accounts via `lamin login myhandle`.
145
+
146
+ → Python/R alternative: {func}`~lamindb.setup.login`
147
+ """
148
+ return login_(user, key=key)
149
+
150
+
151
+ @main.command()
152
+ def logout():
153
+ """Log out of LaminHub."""
154
+ return logout_()
155
+
156
+
157
+ def schema_to_modules_callback(ctx, param, value):
158
+ if param.name == "schema" and value is not None:
159
+ warnings.warn(
160
+ "The --schema option is deprecated and will be removed in a future version."
161
+ " Please use --modules instead.",
162
+ DeprecationWarning,
163
+ stacklevel=2,
164
+ )
165
+ return value
166
+
167
+
168
+ # fmt: off
169
+ @main.command()
170
+ @click.option("--storage", type=str, default = ".", help=DOC_STORAGE_ARG)
171
+ @click.option("--name", type=str, default=None, help=DOC_INSTANCE_NAME)
172
+ @click.option("--db", type=str, default=None, help=DOC_DB)
173
+ @click.option("--modules", type=str, default=None, help=DOC_MODULES)
174
+ # fmt: on
175
+ def init(
176
+ storage: str,
177
+ name: str | None,
178
+ db: str | None,
179
+ modules: str | None,
180
+ ):
181
+ """Initialize an instance.
182
+
183
+ This initializes a LaminDB instance, for example:
184
+
185
+ ```
186
+ lamin init --storage ./mydata
187
+ lamin init --storage s3://my-bucket
188
+ lamin init --storage gs://my-bucket
189
+ lamin init --storage ./mydata --modules bionty
190
+ lamin init --storage ./mydata --modules bionty,pertdb
191
+ ```
192
+
193
+ → Python/R alternative: {func}`~lamindb.setup.init`
194
+ """
195
+ return init_(storage=storage, db=db, modules=modules, name=name)
196
+
197
+
198
+ # fmt: off
199
+ @main.command()
200
+ @click.argument("instance", type=str)
201
+ # fmt: on
202
+ def connect(instance: str):
203
+ """Set a default instance for auto-connection.
204
+
205
+ Python/R sessions and CLI commands will then auto-connect to this LaminDB instance.
206
+
207
+ Pass a slug (`account/name`) or URL (`https://lamin.ai/account/name`), for example:
208
+
209
+ ```
210
+ lamin connect laminlabs/cellxgene
211
+ lamin connect https://lamin.ai/laminlabs/cellxgene
212
+ ```
213
+
214
+ Python/R alternative: {func}`~lamindb.connect` the global default database or a database object via {class}`~lamindb.DB`
215
+ """
216
+ return connect_(instance)
217
+
218
+
219
+ @main.command()
220
+ def disconnect():
221
+ """Unset the default instance for auto-connection.
222
+
223
+ Python/R sessions and CLI commands will no longer auto-connect to a LaminDB instance.
224
+
225
+ For example:
226
+
227
+ ```
228
+ lamin disconnect
229
+ ```
230
+
231
+ → Python/R alternative: {func}`~lamindb.setup.disconnect`
232
+ """
233
+ return disconnect_()
234
+
235
+
236
+ # fmt: off
237
+ @main.command()
238
+ @click.argument("registry", type=click.Choice(["branch", "project"]))
239
+ @click.argument("name", type=str, required=False)
240
+ # below is deprecated, for backward compatibility
241
+ @click.option("--name", "name_opt", type=str, default=None, hidden=True, help="A name.")
242
+ # fmt: on
243
+ def create(
244
+ registry: Literal["branch", "project"],
245
+ name: str | None,
246
+ name_opt: str | None,
247
+ ):
248
+ """Create an object.
249
+
250
+ Currently only supports creating branches and projects.
251
+
252
+ ```
253
+ lamin create branch my_branch
254
+ lamin create project my_project
255
+ ```
256
+
257
+ → Python/R alternative: {class}`~lamindb.Branch` and {class}`~lamindb.Project`.
258
+ """
259
+ resolved_name = name if name is not None else name_opt
260
+ if resolved_name is None:
261
+ raise click.UsageError(
262
+ "Specify a name. Examples: lamin create branch my_branch, lamin create project my_project"
263
+ )
264
+ if name_opt is not None:
265
+ warnings.warn(
266
+ "lamin create --name is deprecated; use 'lamin create <registry> <name>' instead, e.g. lamin create branch my_branch.",
267
+ DeprecationWarning,
268
+ stacklevel=2,
269
+ )
270
+
271
+ from lamindb.models import Branch, Project
272
+
273
+ if registry == "branch":
274
+ record = Branch(name=resolved_name).save()
275
+ elif registry == "project":
276
+ record = Project(name=resolved_name).save()
277
+ else:
278
+ raise NotImplementedError(f"Creating {registry} object is not implemented.")
279
+ logger.important(f"created {registry}: {record.name}")
280
+
281
+
282
+ # fmt: off
283
+ @main.command(name="list")
284
+ @click.argument("registry", type=str)
285
+ # fmt: on
286
+ def list_(registry: Literal["branch", "space"]):
287
+ """List objects.
288
+
289
+ For example:
290
+
291
+ ```
292
+ lamin list branch
293
+ lamin list space
294
+ ```
295
+
296
+ → Python/R alternative: {meth}`~lamindb.Branch.to_dataframe()`
297
+ """
298
+ assert registry in {"branch", "space"}, "Currently only supports listing branches and spaces."
299
+
300
+ from lamindb.models import Branch, Space
301
+
302
+ if registry == "branch":
303
+ print(Branch.to_dataframe())
304
+ else:
305
+ print(Space.to_dataframe())
306
+
307
+
308
+ # fmt: off
309
+ @main.command()
310
+ @click.argument("registry", type=click.Choice(["branch", "space"]), required=False)
311
+ @click.argument("name", type=str, required=False)
312
+ # below are deprecated, for backward compatibility
313
+ @click.option("--branch", type=str, default=None, hidden=True, help="A valid branch name or uid.")
314
+ @click.option("--space", type=str, default=None, hidden=True, help="A valid space name or uid.")
315
+ # fmt: on
316
+ def switch(
317
+ registry: Literal["branch", "space"] | None,
318
+ name: str | None,
319
+ branch: str | None,
320
+ space: str | None,
321
+ ):
322
+ """Switch between branches or spaces.
323
+
324
+ Python/R sessions and CLI commands will use the current default branch or space, for example:
325
+
326
+ ```
327
+ lamin switch branch my_branch
328
+ lamin switch space our_space
329
+ ```
330
+
331
+ Python/R alternative: {attr}`~lamindb.setup.core.SetupSettings.branch` and {attr}`~lamindb.setup.core.SetupSettings.space`
332
+ """
333
+ if registry is not None and name is not None:
334
+ branch = name if registry == "branch" else None
335
+ space = name if registry == "space" else None
336
+ elif branch is None and space is None:
337
+ raise click.UsageError(
338
+ "Specify branch or space. Examples: lamin switch branch my_branch, lamin switch space our_space"
339
+ )
340
+ else:
341
+ warnings.warn(
342
+ "lamin switch --branch and --space are deprecated; use 'lamin switch branch <name>' or 'lamin switch space <name>' instead.",
343
+ DeprecationWarning,
344
+ stacklevel=2,
345
+ )
346
+
347
+ from lamindb.setup import switch as switch_
348
+
349
+ switch_(branch=branch, space=space)
350
+
351
+
352
+ @main.command()
353
+ @click.option("--schema", is_flag=True, help="View database schema via Django plugin.")
354
+ def info(schema: bool):
355
+ """Show info about the instance, development & cache directories, branch, space, and user.
356
+
357
+ Manage settings via [lamin settings](https://docs.lamin.ai/cli#settings).
358
+
359
+ Python/R alternative: {func}`~lamindb.setup.settings`
360
+ """
361
+ if schema:
362
+ from lamindb_setup._schema import view
363
+
364
+ click.echo("Open in browser: http://127.0.0.1:8000/schema/")
365
+ return view()
366
+ else:
367
+ from lamindb_setup import settings as settings_
368
+
369
+ click.echo(settings_)
370
+
371
+
372
+ # fmt: off
373
+ @main.command()
374
+ # entity can be a registry or an object in the registry
375
+ @click.argument("entity", type=str)
376
+ @click.option("--name", type=str, default=None)
377
+ @click.option("--uid", type=str, default=None)
378
+ @click.option("--key", type=str, default=None, help="The key for the entity (artifact, transform).")
379
+ @click.option("--permanent", is_flag=True, default=None, help="Permanently delete the entity where applicable, e.g., for artifact, transform, collection.")
380
+ @click.option("--force", is_flag=True, default=False, help="Do not ask for confirmation (only relevant for instance).")
381
+ # fmt: on
382
+ def delete(entity: str, name: str | None = None, uid: str | None = None, key: str | None = None, slug: str | None = None, permanent: bool | None = None, force: bool = False):
383
+ """Delete an object.
384
+
385
+ Currently supported: `branch`, `artifact`, `transform`, `collection`, and `instance`. For example:
386
+
387
+ ```
388
+ # via --key or --name
389
+ lamin delete artifact --key mydatasets/mytable.parquet
390
+ lamin delete transform --key myanalyses/analysis.ipynb
391
+ lamin delete branch --name my_branch
392
+ lamin delete instance --slug account/name
393
+ # via registry and --uid
394
+ lamin delete artifact --uid e2G7k9EVul4JbfsE
395
+ lamin delete transform --uid Vul4JbfsEYAy5
396
+ # via URL
397
+ lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5
398
+ lamin delete https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsEYAy5 --permanent
399
+ ```
400
+
401
+ → Python/R alternative: {meth}`~lamindb.SQLRecord.delete` and {func}`~lamindb.setup.delete`
402
+ """
403
+ from lamin_cli._delete import delete as delete_
404
+
405
+ return delete_(entity=entity, name=name, uid=uid, key=key, permanent=permanent, force=force)
406
+
407
+
408
+ @main.command()
409
+ # entity can be a registry or an object in the registry
410
+ @click.argument("entity", type=str, required=False)
411
+ @click.option("--uid", help="The uid for the entity.")
412
+ @click.option("--key", help="The key for the entity.")
413
+ @click.option(
414
+ "--with-env", is_flag=True, help="Also return the environment for a tranform."
415
+ )
416
+ def load(entity: str | None = None, uid: str | None = None, key: str | None = None, with_env: bool = False):
417
+ """Sync a file/folder into a local cache (artifacts) or development directory (transforms).
418
+
419
+ Pass a URL or `--key`. For example:
420
+
421
+ ```
422
+ # via key
423
+ lamin load --key mydatasets/mytable.parquet
424
+ lamin load --key analysis.ipynb
425
+ lamin load --key myanalyses/analysis.ipynb --with-env
426
+ # via registry and --uid
427
+ lamin load artifact --uid e2G7k9EVul4JbfsE
428
+ lamin load transform --uid Vul4JbfsEYAy5
429
+ # via URL
430
+ lamin load https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsE
431
+ ```
432
+
433
+ Python/R alternative: {func}`~lamindb.Artifact.load`, no equivalent for transforms
434
+ """
435
+ from lamin_cli._load import load as load_
436
+ if entity is not None:
437
+ is_slug = entity.count("/") == 1
438
+ if is_slug:
439
+ from lamindb_setup._connect_instance import _connect_cli
440
+ # for backward compat
441
+ return _connect_cli(entity)
442
+ return load_(entity, uid=uid, key=key, with_env=with_env)
443
+
444
+
445
+ def _describe(entity: str = "artifact", uid: str | None = None, key: str | None = None):
446
+ if entity.startswith("https://") and "lamin" in entity:
447
+ url = entity
448
+ instance, entity, uid = decompose_url(url)
449
+ elif entity not in {"artifact"}:
450
+ raise SystemExit("Entity has to be a laminhub URL or 'artifact'")
451
+ else:
452
+ instance = ln_setup.settings.instance.slug
453
+
454
+ ln_setup.connect(instance)
455
+ import lamindb as ln
456
+
457
+ if uid is not None:
458
+ artifact = ln.Artifact.get(uid)
459
+ else:
460
+ artifact = ln.Artifact.get(key=key)
461
+ artifact.describe()
462
+
463
+
464
+ @main.command()
465
+ # entity can be a registry or an object in the registry
466
+ @click.argument("entity", type=str, default="artifact")
467
+ @click.option("--uid", help="The uid for the entity.")
468
+ @click.option("--key", help="The key for the entity.")
469
+ def describe(entity: str = "artifact", uid: str | None = None, key: str | None = None):
470
+ """Describe an object.
471
+
472
+ Examples:
473
+
474
+ ```
475
+ # via --key
476
+ lamin describe --key example_datasets/mini_immuno/dataset1.h5ad
477
+ # via registry and --uid
478
+ lamin describe artifact --uid e2G7k9EVul4JbfsE
479
+ # via URL
480
+ lamin describe https://lamin.ai/laminlabs/lamin-site-assets/artifact/6sofuDVvTANB0f48
481
+ ```
482
+
483
+ Python/R alternative: {meth}`~lamindb.Artifact.describe`
484
+ """
485
+ _describe(entity=entity, uid=uid, key=key)
486
+
487
+
488
+ @main.command()
489
+ # entity can be a registry or an object in the registry
490
+ @click.argument("entity", type=str, default="artifact")
491
+ @click.option("--uid", help="The uid for the entity.")
492
+ @click.option("--key", help="The key for the entity.")
493
+ def get(entity: str = "artifact", uid: str | None = None, key: str | None = None):
494
+ """Query metadata about an object.
495
+
496
+ Currently equivalent to `lamin describe`.
497
+ """
498
+ logger.warning("please use `lamin describe` instead of `lamin get` to describe")
499
+ _describe(entity=entity, uid=uid, key=key)
500
+
501
+
502
+ @main.command()
503
+ @click.argument("path", type=str)
504
+ @click.option("--key", type=str, default=None, help="The key of the artifact or transform.")
505
+ @click.option("--description", type=str, default=None, help="A description of the artifact or transform.")
506
+ @click.option("--stem-uid", type=str, default=None, help="The stem uid of the artifact or transform.")
507
+ @click.option("--project", type=str, default=None, help="A valid project name or uid.")
508
+ @click.option("--space", type=str, default=None, help="A valid space name or uid.")
509
+ @click.option("--branch", type=str, default=None, help="A valid branch name or uid.")
510
+ @click.option(
511
+ "--registry",
512
+ type=click.Choice(["artifact", "transform"]),
513
+ default=None,
514
+ help="Either 'artifact' or 'transform'. If not passed, chooses based on path suffix.",
515
+ )
516
+ def save(
517
+ path: str,
518
+ key: str,
519
+ description: str,
520
+ stem_uid: str,
521
+ project: str,
522
+ space: str,
523
+ branch: str,
524
+ registry: Literal["artifact", "transform"] | None,
525
+ ):
526
+ """Save a file or folder as an artifact or transform.
527
+
528
+ Example:
529
+
530
+ ```
531
+ lamin save my_table.csv --key my_tables/my_table.csv --project my_project
532
+ ```
533
+
534
+ By passing a `--project` identifier, the artifact will be labeled with the corresponding project.
535
+ If you pass a `--space` or `--branch` identifier, you save the artifact in the corresponding {class}`~lamindb.Space` or on the corresponding {class}`~lamindb.Branch`.
536
+
537
+ Defaults to saving `.py`, `.ipynb`, `.R`, `.Rmd`, and `.qmd` as {class}`~lamindb.Transform` and
538
+ other file types and folders as {class}`~lamindb.Artifact`. You can enforce saving a file as
539
+ an {class}`~lamindb.Artifact` by passing `--registry artifact`.
540
+
541
+ → Python/R alternative: {class}`~lamindb.Artifact` and {class}`~lamindb.Transform`
542
+ """
543
+ if save_(path=path, key=key, description=description, stem_uid=stem_uid, project=project, space=space, branch=branch, registry=registry) is not None:
544
+ sys.exit(1)
545
+
546
+ @main.command()
547
+ def track():
548
+ """Start tracking a run of a shell script.
549
+
550
+ This command works like {func}`~lamindb.track()` in a Python session. Here is an example script:
551
+
552
+ ```
553
+ # my_script.sh
554
+ set -e # exit on error
555
+ lamin track # initiate a tracked shell script run
556
+ lamin load --key raw/file1.txt
557
+ # do something
558
+ lamin save processed_file1.txt --key processed/file1.txt
559
+ lamin finish # mark the shell script run as finished
560
+ ```
561
+
562
+ If you run that script, it will track the run of the script, and save the input and output artifacts:
563
+
564
+ ```
565
+ sh my_script.sh
566
+ ```
567
+
568
+ Python/R alternative: {func}`~lamindb.track` and {func}`~lamindb.finish` for (non-shell) scripts or notebooks
569
+ """
570
+ from lamin_cli._context import track as track_
571
+ return track_()
572
+
573
+
574
+ @main.command()
575
+ def finish():
576
+ """Finish a currently tracked run of a shell script.
577
+
578
+ → Python/R alternative: {func}`~lamindb.finish()`
579
+ """
580
+ from lamin_cli._context import finish as finish_
581
+ return finish_()
582
+
583
+
584
+ @main.command()
585
+ # entity can be a registry or an object in the registry
586
+ @click.argument("entity", type=str, default=None, required=False)
587
+ @click.option("--key", type=str, default=None, help="The key of an artifact or transform.")
588
+ @click.option("--uid", type=str, default=None, help="The uid of an artifact or transform.")
589
+ @click.option("--project", type=str, default=None, help="A valid project name or uid.")
590
+ @click.option("--ulabel", type=str, default=None, help="A valid ulabel name or uid.")
591
+ @click.option("--record", type=str, default=None, help="A valid record name or uid.")
592
+ @click.option("--features", multiple=True, help="Feature annotations. Supports: feature=value, feature=val1,val2, or feature=\"val1\",\"val2\"")
593
+ def annotate(entity: str | None, key: str, uid: str, project: str, ulabel: str, record: str, features: tuple):
594
+ """Annotate an artifact or transform.
595
+
596
+ You can annotate with projects, ulabels, records, and valid features & values. For example,
597
+
598
+ ```
599
+ # via --key
600
+ lamin annotate --key raw/sample.fastq --project "My Project"
601
+ lamin annotate --key raw/sample.fastq --ulabel "My ULabel" --record "Experiment 1"
602
+ lamin annotate --key raw/sample.fastq --features perturbation=IFNG,DMSO cell_line=HEK297
603
+ lamin annotate --key my-notebook.ipynb --project "My Project"
604
+ # via registry and --uid
605
+ lamin annotate artifact --uid e2G7k9EVul4JbfsE --project "My Project"
606
+ # via URL
607
+ lamin annotate https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsE --project "My Project"
608
+ ```
609
+
610
+ Python/R alternative: `artifact.features.add_values()` via {meth}`~lamindb.models.FeatureManager.add_values` and `artifact.projects.add()`, `artifact.ulabels.add()`, `artifact.records.add()`, ... via {meth}`~lamindb.models.RelatedManager.add`
611
+ """
612
+ from lamin_cli._annotate import _parse_features_list
613
+ from lamin_cli._save import infer_registry_from_path
614
+
615
+ # Handle URL: decompose and connect (same pattern as load/delete)
616
+ if entity is not None and entity.startswith("https://"):
617
+ url = entity
618
+ instance, registry, uid = decompose_url(url)
619
+ if registry not in {"artifact", "transform"}:
620
+ raise click.ClickException(
621
+ f"Annotate does not support {registry}. Use artifact or transform URLs."
622
+ )
623
+ ln_setup.connect(instance)
624
+ else:
625
+ if not ln_setup.settings._instance_exists:
626
+ raise click.ClickException(
627
+ "Not connected to an instance. Please run: lamin connect account/name"
628
+ )
629
+ if entity is None:
630
+ registry = infer_registry_from_path(key) if key is not None else "artifact"
631
+ else:
632
+ registry = entity
633
+ if registry not in {"artifact", "transform"}:
634
+ raise click.ClickException(
635
+ f"Annotate does not support {registry}. Use artifact or transform URLs."
636
+ )
637
+
638
+ # import lamindb after connect went through
639
+ import lamindb as ln
640
+
641
+ if registry == "artifact":
642
+ model = ln.Artifact
643
+ else:
644
+ model = ln.Transform
645
+
646
+ # Get the artifact or transform
647
+ if key is not None:
648
+ artifact = model.get(key=key)
649
+ elif uid is not None:
650
+ artifact = model.get(uid) # do not use uid=uid, because then no truncated uids would work
651
+ else:
652
+ raise ln.errors.InvalidArgument(
653
+ "Either pass a URL as entity or provide --key or --uid"
654
+ )
655
+
656
+ # Handle project annotation
657
+ if project is not None:
658
+ project_record = ln.Project.filter(
659
+ ln.Q(name=project) | ln.Q(uid=project)
660
+ ).one_or_none()
661
+ if project_record is None:
662
+ raise ln.errors.InvalidArgument(
663
+ f"Project '{project}' not found, either create it with `ln.Project(name='...').save()` or fix typos."
664
+ )
665
+ artifact.projects.add(project_record)
666
+
667
+ # Handle ulabel annotation
668
+ if ulabel is not None:
669
+ ulabel_record = ln.ULabel.filter(
670
+ ln.Q(name=ulabel) | ln.Q(uid=ulabel)
671
+ ).one_or_none()
672
+ if ulabel_record is None:
673
+ raise ln.errors.InvalidArgument(
674
+ f"ULabel '{ulabel}' not found, either create it with `ln.ULabel(name='...').save()` or fix typos."
675
+ )
676
+ artifact.ulabels.add(ulabel_record)
677
+
678
+ # Handle record annotation
679
+ if record is not None:
680
+ record_record = ln.Record.filter(
681
+ ln.Q(name=record) | ln.Q(uid=record)
682
+ ).one_or_none()
683
+ if record_record is None:
684
+ raise ln.errors.InvalidArgument(
685
+ f"Record '{record}' not found, either create it with `ln.Record(name='...').save()` or fix typos."
686
+ )
687
+ artifact.records.add(record_record)
688
+
689
+ # Handle feature annotations
690
+ if features:
691
+ feature_dict = _parse_features_list(features)
692
+ artifact.features.add_values(feature_dict)
693
+
694
+ artifact_rep = artifact.key if artifact.key else artifact.description if artifact.description else artifact.uid
695
+ logger.important(f"annotated {registry}: {artifact_rep}")
696
+
697
+
698
+ @main.command()
699
+ @click.argument("filepath", type=str)
700
+ @click.option("--project", type=str, default=None, help="A valid project name or uid. When running on Modal, creates an app with the same name.", required=True)
701
+ @click.option("--image-url", type=str, default=None, help="A URL to the base docker image to use.")
702
+ @click.option("--packages", type=str, default=None, help="A comma-separated list of additional packages to install.")
703
+ @click.option("--cpu", type=float, default=None, help="Configuration for the CPU.")
704
+ @click.option("--gpu", type=str, default=None, help="The type of GPU to use (only compatible with cuda images).")
705
+ def run(filepath: str, project: str, image_url: str, packages: str, cpu: int, gpu: str | None):
706
+ """Run a compute job in the cloud.
707
+
708
+ This is an EXPERIMENTAL feature that enables to run a script on Modal.
709
+
710
+ Example: Given a valid project name "my_project",
711
+
712
+ ```
713
+ lamin run my_script.py --project my_project
714
+ ```
715
+
716
+ → Python/R alternative: no equivalent
717
+ """
718
+ from lamin_cli.compute.modal import Runner
719
+
720
+ default_mount_dir = Path('./modal_mount_dir')
721
+ if not default_mount_dir.is_dir():
722
+ default_mount_dir.mkdir(parents=True, exist_ok=True)
723
+
724
+ shutil.copy(filepath, default_mount_dir)
725
+
726
+ filepath_in_mount_dir = default_mount_dir / Path(filepath).name
727
+
728
+ package_list = []
729
+ if packages:
730
+ package_list = [package.strip() for package in packages.split(',')]
731
+
732
+ runner = Runner(
733
+ local_mount_dir=default_mount_dir,
734
+ app_name=project,
735
+ packages=package_list,
736
+ image_url=image_url,
737
+ cpu=cpu,
738
+ gpu=gpu
739
+ )
740
+
741
+ runner.run(filepath_in_mount_dir)
742
+
743
+
744
+ main.add_command(settings)
745
+ main.add_command(migrate)
746
+ main.add_command(io)
747
+
748
+
749
+ def _deprecated_cache_set(cache_dir: str) -> None:
750
+ logger.warning("'lamin cache' is deprecated. Use 'lamin settings cache-dir' instead.")
751
+ from lamindb_setup._cache import set_cache_dir
752
+
753
+ set_cache_dir(cache_dir)
754
+
755
+
756
+ def _deprecated_cache_clear() -> None:
757
+ logger.warning("'lamin cache' is deprecated. Use 'lamin settings cache-dir' instead.")
758
+ from lamindb_setup._cache import clear_cache_dir
759
+
760
+ clear_cache_dir()
761
+
762
+
763
+ def _deprecated_cache_get() -> None:
764
+ logger.warning("'lamin cache' is deprecated. Use 'lamin settings cache-dir' instead.")
765
+ from lamindb_setup._cache import get_cache_dir
766
+
767
+ click.echo(f"The cache directory is {get_cache_dir()}")
768
+
769
+
770
+ @main.group("cache", hidden=True)
771
+ def deprecated_cache():
772
+ """Deprecated. Use 'lamin settings cache-dir' instead."""
773
+
774
+
775
+ @deprecated_cache.command("set")
776
+ @click.argument(
777
+ "cache_dir",
778
+ type=click.Path(dir_okay=True, file_okay=False),
779
+ )
780
+ def _deprecated_cache_set_cmd(cache_dir: str) -> None:
781
+ _deprecated_cache_set(cache_dir)
782
+
783
+
784
+ @deprecated_cache.command("clear")
785
+ def _deprecated_cache_clear_cmd() -> None:
786
+ _deprecated_cache_clear()
787
+
788
+
789
+ @deprecated_cache.command("get")
790
+ def _deprecated_cache_get_cmd() -> None:
791
+ _deprecated_cache_get()
792
+
793
+ # https://stackoverflow.com/questions/57810659/automatically-generate-all-help-documentation-for-click-commands
794
+ # https://claude.ai/chat/73c28487-bec3-4073-8110-50d1a2dd6b84
795
+ def _generate_help():
796
+ out: dict[str, dict[str, str | None]] = {}
797
+
798
+ def recursive_help(
799
+ cmd: Command, parent: Context | None = None, name: tuple[str, ...] = ()
800
+ ):
801
+ if getattr(cmd, "hidden", False):
802
+ return
803
+ ctx = click.Context(cmd, info_name=cmd.name, parent=parent)
804
+ assert cmd.name
805
+ name = (*name, cmd.name)
806
+ command_name = " ".join(name)
807
+
808
+ docstring = inspect.getdoc(cmd.callback)
809
+ usage = cmd.get_help(ctx).split("\n")[0]
810
+ options = cmd.get_help(ctx).split("Options:")[1]
811
+ out[command_name] = {
812
+ "help": usage + "\n\nOptions:" + options,
813
+ "docstring": docstring,
814
+ }
815
+
816
+ for sub in getattr(cmd, "commands", {}).values():
817
+ if getattr(sub, "hidden", False):
818
+ continue
819
+ recursive_help(sub, ctx, name=name)
820
+
821
+ recursive_help(main)
822
+ return out
823
+
824
+
825
+ if __name__ == "__main__":
826
+ main()