Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions lamin_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ def load(entity: str | None = None, uid: str | None = None, key: str | None = No
lamin load --key myanalyses/analysis.ipynb --with-env
# notes via name and topic/type hierarchy
lamin load README.md
lamin load my-topic/my-note.md
lamin load My-topic/my-note.md
# anything via URL
lamin load https://lamin.ai/account/instance/artifact/e2G7k9EVul4JbfsE
# anything via registry and --uid
Expand Down Expand Up @@ -989,9 +989,9 @@ def update(
@click.option("--branch", type=str, default=None, help="A valid branch name or uid.")
@click.option(
"--registry",
type=click.Choice(["artifact", "transform", "record"]),
type=click.Choice(["artifact", "transform", "record", "project"]),
default=None,
help="Either 'artifact', 'transform', or 'record'. If not passed, chooses based on path suffix.",
help="Either 'artifact', 'transform', 'record', or 'project'. If not passed, chooses based on path.",
)
@click.option(
"--batch-size",
Expand All @@ -1014,11 +1014,11 @@ def save(
project: str,
space: str,
branch: str,
registry: Literal["artifact", "transform", "record"] | None,
registry: Literal["artifact", "transform", "record", "project"] | None,
batch_size: int | None,
store_kwargs: str | None,
):
"""Save a file or folder as an artifact, transform, or record.
"""Save a file or folder as an artifact, transform, record, or project.

Save a **dataset** or **model** as {class}`~lamindb.Artifact`:

Expand All @@ -1044,6 +1044,13 @@ def save(
lamin save my_script.py --key my_scripts/my_script.py
```

Save a **markdown note** in `Projects/` as {class}`~lamindb.Project`:

```
lamin save Projects/my-project.md
lamin save Projects/my-type/my-project.md # `my-type` is an existing project type
```

Save a **markdown note** as {class}`~lamindb.Record`:

```
Expand All @@ -1058,7 +1065,7 @@ def save(

The `save` command defaults to saving
`.py`, `.ipynb`, `.R`, `.Rmd`, and `.qmd` files as {class}`~lamindb.Transform`
and - if ommitting `--key` - `.md` files as {class}`~lamindb.Record`.
and - if ommitting `--key` - `.md` files as {class}`~lamindb.Record` or {class}`~lamindb.Project`.
You can enforce saving a file as an {class}`~lamindb.Artifact` by passing `--registry artifact`.

You can pass a project to `--project` to label the artifact by project.
Expand Down
109 changes: 87 additions & 22 deletions lamin_cli/_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@

from ._context import get_current_run_file
from ._notes import (
PROJECTS_FOLDER,
is_path_within,
is_project_note_target,
parse_note_target,
project_type_chain,
resolve_note_project,
resolve_note_record,
upsert_lamindb_record_uid,
)
Expand All @@ -23,6 +27,35 @@
from .urls import decompose_url


def _write_note_file(
*,
content: str,
uid: str,
note_name: str,
relative_parents: list[str],
active_dev_dir: Path | None,
) -> Path:
cwd = Path.cwd().resolve()
filename = f"{note_name}.md"
if active_dev_dir is not None and is_path_within(cwd, active_dev_dir):
target_path = active_dev_dir.joinpath(*relative_parents, filename)
else:
target_path = cwd / filename

target_path.parent.mkdir(parents=True, exist_ok=True)
if target_path.exists():
response = input(f"! {target_path} exists: replace? (y/n)")
if response != "y":
raise SystemExit("Aborted.")

target_path.write_text(
upsert_lamindb_record_uid(content or "", uid),
encoding="utf-8",
)
logger.important(f"note is here: {target_path}")
return target_path


def load(
entity: str | None = None,
uid: str | None = None,
Expand Down Expand Up @@ -65,7 +98,7 @@ def _note_type_chain_from_record(note_record) -> list[str]:
if entity is not None and uid is None and key is None:
note_target = parse_note_target(entity)
if note_target is not None:
entity = "record"
entity = "project" if is_project_note_target(note_target[0]) else "record"

if entity is None:
if key is None:
Expand All @@ -76,7 +109,7 @@ def _note_type_chain_from_record(note_record) -> list[str]:
if entity.startswith("https://") and "lamin" in entity:
url = entity
instance, entity, uid = decompose_url(url)
elif entity not in {"artifact", "transform", "collection", "record"}:
elif entity not in {"artifact", "transform", "collection", "record", "project"}:
raise SystemExit(
"Entity has to be a laminhub URL or 'artifact', 'collection', or 'transform'"
)
Expand Down Expand Up @@ -188,29 +221,61 @@ def script_to_notebook(
raise click.ClickException(
f"Record note '{note_name}' has no readme block to load."
)

cwd = Path.cwd().resolve()
if active_dev_dir is not None and is_path_within(cwd, active_dev_dir):
target_path = (
active_dev_dir / Path(*type_chain) / f"{note_name}.md"
if type_chain
else active_dev_dir / f"{note_name}.md"
return _write_note_file(
content=readme_block.content or "",
uid=note_record.uid,
note_name=note_name,
relative_parents=type_chain,
active_dev_dir=active_dev_dir,
)
case "project":
if note_target is not None:
type_chain, note_name = note_target
project = resolve_note_project(
ln=ln,
type_chain=project_type_chain(type_chain),
note_name=note_name,
create_if_missing=False,
)
if project is None:
note_path = (
"/".join([*type_chain, note_name]) if type_chain else note_name
)
raise click.ClickException(
f"Project note '{note_path}' does not exist. Save it first with `lamin save`."
)
relative_parents = type_chain
elif uid is not None:
projects = ln.Project.objects.filter(uid__startswith=uid)
if (n_projects := len(projects)) == 0:
raise click.ClickException(
f"Project with uid={uid} does not exist."
)
if n_projects > 1:
projects = projects.order_by("-created_at")
project = projects.first()
type_chain = _note_type_chain_from_record(project)
note_name = project.name
relative_parents = [PROJECTS_FOLDER, *type_chain]
else:
target_path = cwd / f"{note_name}.md"

target_path.parent.mkdir(parents=True, exist_ok=True)
if target_path.exists():
response = input(f"! {target_path} exists: replace? (y/n)")
if response != "y":
raise SystemExit("Aborted.")

target_path.write_text(
upsert_lamindb_record_uid(readme_block.content or "", note_record.uid),
encoding="utf-8",
raise click.ClickException(
"For project note loads, pass a note target like "
"Projects/<note>.md or Projects/<type>/<note>.md."
)
readme_block = (
project.ablocks.filter(kind="readme").order_by("-created_at").first()
)
if readme_block is None:
raise click.ClickException(
f"Project note '{note_name}' has no readme block to load."
)
return _write_note_file(
content=readme_block.content or "",
uid=project.uid,
note_name=note_name,
relative_parents=relative_parents,
active_dev_dir=active_dev_dir,
)
logger.important(f"note is here: {target_path}")
return target_path
case "transform":
if query_by_uid:
# we don't use .get here because DoesNotExist is hard to catch
Expand Down
47 changes: 44 additions & 3 deletions lamin_cli/_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,30 @@ def extract_note_target_from_path(
return parse_note_target(relative_path.as_posix(), allow_extensionless_single=True)


def resolve_note_type_parent(ln, type_chain: list[str]):
PROJECTS_FOLDER = "Projects"


def is_projects_folder(name: str) -> bool:
return name.casefold() == PROJECTS_FOLDER.casefold()


def is_project_note_target(type_chain: list[str]) -> bool:
"""True when the note lives under the dev-dir `Projects` folder."""
return bool(type_chain) and is_projects_folder(type_chain[0])


def project_type_chain(type_chain: list[str]) -> list[str]:
"""Project type path with the leading `Projects` folder removed."""
if is_project_note_target(type_chain):
return type_chain[1:]
return list(type_chain)


def _resolve_type_parent(model, type_chain: list[str], *, kind: str):
parent_type = None
resolved_path: list[str] = []
for type_name in type_chain:
type_record = ln.Record.filter(
type_record = model.filter(
name__iexact=type_name,
is_type=True,
type=parent_type,
Expand All @@ -232,14 +251,36 @@ def resolve_note_type_parent(ln, type_chain: list[str]):
parent_label = "/".join(resolved_path) if resolved_path else "<root>"
expected_path = "/".join([*resolved_path, type_name])
raise click.ClickException(
f"Record type '{type_name}' not found under '{parent_label}'. "
f"{kind} type '{type_name}' not found under '{parent_label}'. "
f"Expected hierarchy segment '{expected_path}'. Create it first."
)
parent_type = type_record
resolved_path.append(type_name)
return parent_type


def resolve_note_type_parent(ln, type_chain: list[str]):
return _resolve_type_parent(ln.Record, type_chain, kind="Record")


def resolve_project_type_parent(ln, type_chain: list[str]):
return _resolve_type_parent(ln.Project, type_chain, kind="Project")


def resolve_note_project(
*,
ln,
type_chain: list[str],
note_name: str,
create_if_missing: bool = False,
):
parent_type = resolve_project_type_parent(ln, type_chain)
project = ln.Project.filter(name=note_name, type=parent_type).one_or_none()
if project is None and create_if_missing:
project = ln.Project(name=note_name, type=parent_type).save()
return project


def resolve_note_record(
*,
ln,
Expand Down
Loading
Loading