baseqdb 0.5.0__tar.gz → 0.9.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: baseqdb
3
- Version: 0.5.0
3
+ Version: 0.9.0
4
4
  Summary: Your baSeq datasets, analyses and results from Python, with lamindb's names
5
5
  Project-URL: Homepage, https://baseq.bio
6
6
  Project-URL: Documentation, https://baseq.bio/docs/python-and-r.html
@@ -28,6 +28,8 @@ import baseqdb as bq
28
28
  bq.login() # reads BASEQ_API_KEY
29
29
 
30
30
  bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
31
+ bq.Dataset.filter(organism="human", condition="dexamethasone") # by any column of the sample sheets
32
+ bq.Dataset.search("dexamethasone") # as the Data page's search
31
33
  bq.Analysis.filter(kind="bulk").to_dataframe() # the analyses you can open, newest first
32
34
  ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
33
35
  folder = ds.cache() # its files, downloaded once
@@ -7,6 +7,8 @@ import baseqdb as bq
7
7
  bq.login() # reads BASEQ_API_KEY
8
8
 
9
9
  bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
10
+ bq.Dataset.filter(organism="human", condition="dexamethasone") # by any column of the sample sheets
11
+ bq.Dataset.search("dexamethasone") # as the Data page's search
10
12
  bq.Analysis.filter(kind="bulk").to_dataframe() # the analyses you can open, newest first
11
13
  ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
12
14
  folder = ds.cache() # its files, downloaded once
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "baseqdb"
7
- version = "0.5.0"
7
+ version = "0.9.0"
8
8
  description = "Your baSeq datasets, analyses and results from Python, with lamindb's names"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,6 +4,7 @@ Python, with lamindb's names; and, with a key that may add data, new datasets an
4
4
  import baseqdb as bq
5
5
  bq.login() # reads BASEQ_API_KEY
6
6
  bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
7
+ bq.Dataset.filter(organism="human", condition="dexamethasone") # by any column of the sample sheets
7
8
  ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
8
9
  folder = ds.cache() # its files, downloaded once
9
10
  bq.Dataset(["counts.tsv"], name=ds.name, kind="bulk").save() # its next version
@@ -11,6 +12,10 @@ Python, with lamindb's names; and, with a key that may add data, new datasets an
11
12
  bq.track() # this script as one run: what it reads, adds and saves
12
13
  bq.Record("dge.csv").save() # a file of the run
13
14
  bq.finish() # or let the script end
15
+
16
+ For an AI agent: `baseqdb guide` (in a shell) says how to work with baSeq: each task as one run, the results kept
17
+ with it, what it found said at the end. `baseqdb init` writes that guide into a project's AGENTS.md and CLAUDE.md.
18
+ Docs: https://baseq.bio/docs/python-and-r.html
14
19
  """
15
20
  from baseqdb._client import VERSION as __version__, BaseqError, login, me
16
21
  from baseqdb._records import (Analysis, Dataset, File, Files, Finding, GeneHit, Record, Results, Run, Step, Table,
@@ -0,0 +1,164 @@
1
+ """The ``baseqdb`` command, for an AI agent's task (or yours) as one run on baSeq:
2
+
3
+ baseqdb track "Dexamethasone response in Airway" # begins the task's run; this folder's commands join it
4
+ python filter_counts.py # its requests name the task; bq.track() inside joins it
5
+ baseqdb finish --finding "ZBTB16 rose the most." # ends it, with what it found (--gene ZBTB16+, --errored)
6
+
7
+ The task's uid is kept in .baseqdb/run in this folder (BASEQ_RUN overrides it), which baseqdb in this folder and the
8
+ folders below it reads.
9
+
10
+ baseqdb guide # how an AI agent works with baSeq (guide.md)
11
+ baseqdb init # that guide into this folder's AGENTS.md; CLAUDE.md reads it
12
+ baseqdb login # keep your key on this computer, for every program and agent
13
+
14
+ AGENTS.md is where agents look for a project's instructions, and Claude Code reads CLAUDE.md, which imports AGENTS.md
15
+ with ``@AGENTS.md``. The guide sits between two marker lines, so running init again updates it and leaves the rest of
16
+ the file as it was."""
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from importlib import resources
22
+ from pathlib import Path
23
+
24
+ from baseqdb import _client as _c
25
+ from baseqdb import _track
26
+
27
+
28
+ def _here() -> Path:
29
+ return Path.cwd() / _c.RUN_FILE
30
+
31
+
32
+ def track(title: str, lab=None) -> int:
33
+ open_task = _c.task_run()
34
+ if open_task:
35
+ print(f"baseqdb: the task {open_task} is open here: finish it first (baseqdb finish).", file=sys.stderr)
36
+ return 1
37
+ _c.login()
38
+ run = _c.post("/runs", json={"title": title, "language": "python", "argv": ["baseqdb", "track", title],
39
+ "environment": _track.environment(), "lab": lab})
40
+ _here().parent.mkdir(parents=True, exist_ok=True)
41
+ _here().write_text(run["uid"] + "\n", encoding="utf-8")
42
+ print(f"baseqdb: tracking \"{title}\" as run {run['uid']}. Every command in this folder joins it; end it with "
43
+ "`baseqdb finish`.")
44
+ return 0
45
+
46
+
47
+ def finish(errored: bool = False, finding: str | None = None, genes: list[str] | None = None) -> int:
48
+ uid = _c.task_run()
49
+ if not uid:
50
+ print("baseqdb: no task is open here.", file=sys.stderr)
51
+ return 1
52
+ _c.login()
53
+ _c.set_run(None)
54
+ body = {"status": "errored" if errored else "completed"}
55
+ if finding:
56
+ body["finding"] = finding
57
+ if genes:
58
+ body["genes"] = list(genes)
59
+ run = _c.post(f"/runs/{uid}/finish", json=body)
60
+ if _here().is_file() and _here().read_text(encoding="utf-8").strip() == uid:
61
+ _here().unlink()
62
+ print(f"baseqdb: run {uid} ended ({run.get('status')}).")
63
+ return 0
64
+
65
+
66
+ START = "<!-- baseqdb guide: start (baseqdb init wrote this part; run it again to update it) -->"
67
+ END = "<!-- baseqdb guide: end -->"
68
+ IMPORT = "@AGENTS.md"
69
+
70
+
71
+ def guide_text() -> str:
72
+ return resources.files("baseqdb").joinpath("guide.md").read_text(encoding="utf-8")
73
+
74
+
75
+ def guide() -> int:
76
+ print(guide_text(), end="")
77
+ return 0
78
+
79
+
80
+ def _with_guide(text: str) -> str:
81
+ """``text`` (AGENTS.md as it is) with the guide between the markers: in their place when it has them, after it
82
+ otherwise."""
83
+ part = f"{START}\n{guide_text().rstrip()}\n{END}\n"
84
+ if START in text and END in text.split(START, 1)[1]:
85
+ before, rest = text.split(START, 1)
86
+ return before + part + rest.split(END, 1)[1].lstrip("\n")
87
+ return (text.rstrip("\n") + "\n\n" if text.strip() else "") + part
88
+
89
+
90
+ def init(folder: str = ".") -> int:
91
+ here = Path(folder)
92
+ agents, claude = here / "AGENTS.md", here / "CLAUDE.md"
93
+ agents.write_text(_with_guide(agents.read_text(encoding="utf-8") if agents.is_file() else ""), encoding="utf-8")
94
+ if not (claude.exists() and claude.resolve() == agents.resolve()):
95
+ text = claude.read_text(encoding="utf-8") if claude.is_file() else ""
96
+ if not any(line.strip() == IMPORT for line in text.splitlines()):
97
+ claude.write_text((text.rstrip("\n") + "\n\n" if text.strip() else "") + IMPORT + "\n", encoding="utf-8")
98
+ print(f"baseqdb: the guide is in {agents} (and {claude} reads it): AI agents that start in this folder follow it.")
99
+ return 0
100
+
101
+
102
+ def login(key: str | None = None) -> int:
103
+ """Keep the key on this computer (_client.key_file), once baSeq has said whose it is, so every program and AI agent
104
+ on it finds it without BASEQ_API_KEY and without the key in a chat."""
105
+ if not key:
106
+ import getpass
107
+ key = getpass.getpass("baSeq API key (from your Account page, under API keys): ")
108
+ key = (key or "").strip()
109
+ if not key.startswith("bsq_"):
110
+ print("baseqdb: that is not a baSeq API key (it begins with bsq_).", file=sys.stderr)
111
+ return 1
112
+ who = _c.login(key=key)
113
+ path = _c.save_key(key)
114
+ print(f"baseqdb: signed in as {who.get('email')} with the key {(who.get('key') or {}).get('name')}; the key is kept in "
115
+ f"{path}, for every program on this computer.")
116
+ return 0
117
+
118
+
119
+ def logout() -> int:
120
+ print("baseqdb: the key is no longer kept on this computer." if _c.forget_key() else "baseqdb: no key was kept here.")
121
+ return 0
122
+
123
+
124
+ def main(argv=None) -> int:
125
+ parser = argparse.ArgumentParser(
126
+ prog="baseqdb", description="An AI agent's task, or yours, as one run on baSeq.",
127
+ epilog="New to baSeq? `baseqdb login` keeps your key on this computer; `baseqdb guide` says how an AI agent works "
128
+ "with baSeq, and `baseqdb init` puts that guide in this folder's AGENTS.md and CLAUDE.md. Docs: "
129
+ "https://baseq.bio/docs/python-and-r.html")
130
+ sub = parser.add_subparsers(dest="command", required=True)
131
+ sub.add_parser("guide", help="how an AI agent works with baSeq and baseqdb")
132
+ li = sub.add_parser("login", help="keep your API key on this computer, for every program and AI agent on it")
133
+ li.add_argument("key", nargs="?", help="the key (asked for when left out)")
134
+ sub.add_parser("logout", help="forget the key kept on this computer")
135
+ i = sub.add_parser("init", help="write that guide into this folder's AGENTS.md, and have CLAUDE.md read it")
136
+ i.add_argument("folder", nargs="?", default=".", help="the project's folder (this one by default)")
137
+ t = sub.add_parser("track", help="begin a task's run; the commands in this folder join it")
138
+ t.add_argument("title", help="what the task is")
139
+ t.add_argument("--lab", help="one of your labs to share the run with")
140
+ f = sub.add_parser("finish", help="end the task's run")
141
+ f.add_argument("--errored", action="store_true", help="the task failed")
142
+ f.add_argument("--finding", help="what the task found, in a short paragraph (shown marked From code)")
143
+ f.add_argument("--gene", action="append", default=[], metavar="GENE",
144
+ help="a gene of the finding, with + or - for its direction (ZBTB16+); again for each")
145
+ args = parser.parse_args(argv)
146
+ if args.command == "guide":
147
+ return guide()
148
+ if args.command == "logout":
149
+ return logout()
150
+ if args.command == "init":
151
+ return init(args.folder)
152
+ try:
153
+ if args.command == "login":
154
+ return login(args.key)
155
+ if args.command == "track":
156
+ return track(args.title, args.lab)
157
+ return finish(args.errored, args.finding, args.gene)
158
+ except _c.BaseqError as exc:
159
+ print(f"baseqdb: {exc}", file=sys.stderr)
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import httpx
10
10
 
11
11
  DEFAULT_URL = "https://baseq.bio"
12
- VERSION = "0.5.0"
12
+ VERSION = "0.9.0"
13
13
  RUN_HEADER = "X-Baseq-Run"
14
14
  RUN_FILE = Path(".baseqdb") / "run" # an agent's task, begun with `baseqdb track`, for every command in the folder
15
15
  _state: dict = {"client": None, "base": None, "run": None}
@@ -23,13 +23,52 @@ class BaseqError(Exception):
23
23
  self.status = status
24
24
 
25
25
 
26
+ def key_file() -> Path:
27
+ """Where `baseqdb login` keeps the key on this computer: baseqdb/key in the user's own settings folder (APPDATA on
28
+ Windows, XDG_CONFIG_HOME or ~/.config elsewhere), readable by its owner only."""
29
+ if os.name == "nt" and os.environ.get("APPDATA"):
30
+ return Path(os.environ["APPDATA"]) / "baseqdb" / "key"
31
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "baseqdb" / "key"
32
+
33
+
34
+ def saved_key() -> str | None:
35
+ try:
36
+ return key_file().read_text(encoding="utf-8").strip() or None
37
+ except OSError:
38
+ return None
39
+
40
+
41
+ def save_key(key: str) -> Path:
42
+ """Keep ``key`` in key_file(), its folder and the file open to their owner only."""
43
+ path = key_file()
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ try:
46
+ os.chmod(path.parent, 0o700)
47
+ except OSError:
48
+ pass
49
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
50
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
51
+ fh.write(key.strip() + "\n")
52
+ os.chmod(path, 0o600)
53
+ return path
54
+
55
+
56
+ def forget_key() -> bool:
57
+ try:
58
+ key_file().unlink()
59
+ return True
60
+ except OSError:
61
+ return False
62
+
63
+
26
64
  def login(key: str | None = None, url: str | None = None, client: httpx.Client | None = None) -> dict:
27
65
  """Use your API key (make one on your Account page, under API keys). Without ``key``, the BASEQ_API_KEY
28
- environment variable; without ``url``, BASEQ_URL or https://baseq.bio. ``client``: an httpx.Client of your own.
29
- Returns whose key it is."""
30
- key = key or os.environ.get("BASEQ_API_KEY")
66
+ environment variable, else the key `baseqdb login` kept on this computer; without ``url``, BASEQ_URL or
67
+ https://baseq.bio. ``client``: an httpx.Client of your own. Returns whose key it is."""
68
+ key = key or os.environ.get("BASEQ_API_KEY") or saved_key()
31
69
  if not key:
32
- raise BaseqError(None, "No API key: pass key= or set BASEQ_API_KEY. Make one on your Account page, under API keys.")
70
+ raise BaseqError(None, "No API key: run `baseqdb login` in a terminal, which keeps your key on this computer (or "
71
+ "set BASEQ_API_KEY). Make a key on your baSeq Account page, under API keys.")
33
72
  base = (url or os.environ.get("BASEQ_URL") or DEFAULT_URL).rstrip("/") + "/api/v1"
34
73
  http = client or httpx.Client(timeout=httpx.Timeout(60.0, read=600.0))
35
74
  http.headers["Authorization"] = f"Bearer {key}"
@@ -74,7 +74,8 @@ class Dataset(_Thing):
74
74
 
75
75
  ``Dataset(files, name=..., kind=...)`` is one to add with ``save()``, as lamindb's ``Artifact(path, key=...)``:
76
76
  with the name and kind of a dataset you have, it is its next version."""
77
- fields = ("uid", "stem", "version", "name", "kind", "organism", "file_count", "size", "source", "created_at", "mine")
77
+ fields = ("uid", "stem", "version", "name", "kind", "organism", "file_count", "size", "source", "created_at", "mine",
78
+ "where")
78
79
 
79
80
  def __init__(self, data=None, *, name: str | None = None, kind: str | None = None, sample_sheet=None, lab=None):
80
81
  if isinstance(data, dict): # as the API describes one
@@ -129,10 +130,25 @@ class Dataset(_Thing):
129
130
  return cls(_c.get(f"/datasets/{quote(uid)}"))
130
131
 
131
132
  @classmethod
132
- def filter(cls, kind: str | None = None, name: str | None = None, all_versions: bool = False) -> Results:
133
- """The datasets you can open, newest first, each at its newest version you can open (or every version)."""
134
- return Results(cls(d) for d in _c.pages("/datasets", kind=kind, name=name,
135
- all_versions=str(bool(all_versions)).lower(), limit=1000))
133
+ def filter(cls, kind: str | None = None, name: str | None = None, organism: str | None = None,
134
+ search: str | None = None, all_versions: bool = False, **columns) -> Results:
135
+ """The datasets you can open, newest first, each at its newest version you can open (or every version).
136
+
137
+ ``kind`` and ``name``: part of each. ``organism``: "human", "Homo sapiens" or "NCBITaxon:9606". Any sample
138
+ sheet column by its name, ``condition="dexamethasone", donor="N61311"``: datasets with a sample that has them
139
+ all (tissue, cell type, disease and the other known kinds also find their synonyms and the terms under them;
140
+ a column whose name has spaces by its words joined with "_": ``time_point="24 h"``). ``search``: words in
141
+ names and in every value of the sample sheets. A dataset found says where in ``where``."""
142
+ return Results(cls(d) for d in _c.pages(
143
+ "/datasets", kind=kind, name=name, organism=organism, search=search,
144
+ where=[f"{column}={value}" for column, value in columns.items()] or None,
145
+ all_versions=str(bool(all_versions)).lower(), limit=1000))
146
+
147
+ @classmethod
148
+ def search(cls, text: str, **filters) -> Results:
149
+ """The datasets with these words in their names or in any value of their sample sheets, as the Data page's
150
+ search finds them; ``filters`` as ``filter`` takes them."""
151
+ return cls.filter(search=text, **filters)
136
152
 
137
153
  def _detail(self) -> dict:
138
154
  if "files" not in self._data:
@@ -0,0 +1,235 @@
1
+ """Your code as a run on baSeq, as lamindb's ``ln.track()``: ``bq.track()`` at the top of a script makes it one run,
2
+ with its code (kept as a version of the script), its command line, its parameters and its environment; the files it
3
+ downloads from baSeq while it runs are what it read, what it adds was added in it, and ``bq.Record(path).save()``
4
+ keeps a file with it. ``bq.finish()`` ends it, with what it found when you say (``finding=``, a finding from code),
5
+ and a script that ends without it ends its run anyway: completed, or errored when an exception stopped it.
6
+
7
+ Inside an agent's task (``baseqdb track "<task>"``, _cli.py) a tracked script's run is part of the task's run, and code
8
+ that does not track itself still names the task in its requests, so whatever the task does is one run.
9
+
10
+ In a Jupyter notebook (JupyterLab, Notebook 7, VS Code; anywhere else with ``notebook=``) the notebook is the code it
11
+ ran: its cells without their outputs, a version of the notebook as a script's are. ``bq.finish()``, in a cell of its
12
+ own at the end, keeps the notebook as saved, with its outputs, as the run's report (baSeq draws it, read-only); it asks
13
+ to save first when the saved file stops before the cells the run went through, as lamindb's ``ln.finish()`` does."""
14
+ from __future__ import annotations
15
+
16
+ import atexit
17
+ import json
18
+ import os
19
+ import platform
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from baseqdb import _client as _c
24
+
25
+ SOURCE_MAX = 2 * 1024 * 1024
26
+ _state: dict = {"run": None, "task": None, "errored": False, "hooked": False, "finished": False}
27
+
28
+
29
+ def _script() -> tuple[str, str | None]:
30
+ """(the code's file name, its code): the script running; '-c' and its code for ``python -c``; '-' when there is no
31
+ file (standard input, an interactive session)."""
32
+ orig = list(getattr(sys, "orig_argv", None) or [])
33
+ if "-c" in orig and orig.index("-c") + 1 < len(orig):
34
+ return "-c", orig[orig.index("-c") + 1]
35
+ main = sys.modules.get("__main__")
36
+ path = getattr(main, "__file__", None)
37
+ if path and os.path.isfile(path) and os.path.getsize(path) <= SOURCE_MAX:
38
+ try:
39
+ with open(path, encoding="utf-8", errors="replace") as fh:
40
+ return os.path.basename(path), fh.read()
41
+ except OSError:
42
+ pass
43
+ return "-", None
44
+
45
+
46
+ class Begun(dict):
47
+ """The run track() began, as baSeq describes it; a notebook shows it in one line."""
48
+
49
+ def __repr__(self):
50
+ return f"<run {self.get('uid')}: {self.get('title')}>"
51
+
52
+ def _repr_pretty_(self, p, cycle):
53
+ p.text(repr(self))
54
+
55
+
56
+ # ─────────────────────────── notebooks ───────────────────────────
57
+
58
+ def _kernel():
59
+ """The IPython kernel the code runs in (a notebook's), or None."""
60
+ shell = sys.modules.get("IPython")
61
+ try:
62
+ ip = shell.get_ipython() if shell is not None else None
63
+ except Exception:
64
+ return None
65
+ return ip if ip is not None and hasattr(ip, "kernel") else None
66
+
67
+
68
+ def _interactive(ip) -> bool:
69
+ """A notebook a person has open (JupyterLab, Notebook 7, VS Code), not one run by nbconvert or papermill."""
70
+ return bool(os.environ.get("JPY_SESSION_NAME") or (ip is not None and ip.user_ns.get("__vsc_ipynb_file__")))
71
+
72
+
73
+ def notebook_path(ip, given=None) -> Path | None:
74
+ """The notebook the kernel runs: ``given`` (``bq.track(notebook=...)``), JupyterLab's and Notebook 7's
75
+ JPY_SESSION_NAME, or VS Code's __vsc_ipynb_file__."""
76
+ found = [Path(given)] if given else []
77
+ session = os.environ.get("JPY_SESSION_NAME")
78
+ if session:
79
+ found += [Path(session), Path.cwd() / Path(session).name]
80
+ vsc = ip.user_ns.get("__vsc_ipynb_file__") if ip is not None else None
81
+ if vsc:
82
+ found.append(Path(vsc))
83
+ for path in found:
84
+ if path.suffix.lower() == ".ipynb" and path.is_file():
85
+ return path.resolve()
86
+ return None
87
+
88
+
89
+ def _text(source) -> str:
90
+ return "".join(source) if isinstance(source, list) else str(source or "")
91
+
92
+
93
+ def notebook_code(notebook: dict) -> tuple[str, str]:
94
+ """(the notebook's cells without their outputs, as the code it ran; its language): the same text for the same
95
+ cells, so a version changes only when a cell does."""
96
+ meta = notebook.get("metadata") or {}
97
+ lang = str((meta.get("kernelspec") or {}).get("language") or (meta.get("language_info") or {}).get("name") or "")
98
+ language = "r" if lang.lower() == "r" else "python"
99
+ cells = [{"cell_type": str(c.get("cell_type") or "code"), "source": _text(c.get("source"))}
100
+ for c in notebook.get("cells") or [] if isinstance(c, dict)]
101
+ return json.dumps({"cells": cells, "metadata": {"language": language}, "nbformat": 4, "nbformat_minor": 5},
102
+ sort_keys=True, indent=1, ensure_ascii=False), language
103
+
104
+
105
+ def saved_up_to(notebook: dict) -> int:
106
+ """The highest cell number in the saved notebook (0 when no cell has run)."""
107
+ counts = [c.get("execution_count") for c in notebook.get("cells") or [] if isinstance(c, dict)]
108
+ return max([n for n in counts if isinstance(n, int)] or [0])
109
+
110
+
111
+ def _read(path: Path) -> dict:
112
+ with open(path, encoding="utf-8") as fh:
113
+ return json.load(fh)
114
+
115
+
116
+ def environment() -> dict:
117
+ """Python and every installed distribution with its version, as baSeq keeps an analysis kernel's."""
118
+ from importlib import metadata
119
+ packages = {}
120
+ for dist in metadata.distributions():
121
+ name = (dist.metadata.get("Name") or "").strip()
122
+ if name:
123
+ packages.setdefault(name, dist.version)
124
+ return {"python": {"version": platform.python_version(), "build": sys.version, "packages": packages}}
125
+
126
+
127
+ def imported() -> list[str]:
128
+ """The distributions the code imported, for the run's page."""
129
+ from importlib import metadata
130
+ try:
131
+ owners = metadata.packages_distributions()
132
+ except Exception:
133
+ return []
134
+ names = set()
135
+ for module in list(sys.modules):
136
+ top = module.split(".", 1)[0]
137
+ for dist in owners.get(top, []):
138
+ names.add(dist)
139
+ return sorted(names)
140
+
141
+
142
+ def track(title: str | None = None, params: dict | None = None, lab=None, notebook=None) -> dict:
143
+ """This script as one run on baSeq (a key that may add data). ``title``: what it is, its file's name without it;
144
+ ``params``: the settings it ran with; ``lab``: one of your labs to share the run with; ``notebook``: the notebook's
145
+ file, where baseqdb cannot find it itself. Inside an agent's task, its run is part of the task's. The run, as baSeq
146
+ describes it."""
147
+ if _state["run"] and not _state["finished"]:
148
+ return Begun({"uid": _state["run"]})
149
+ _c._http()
150
+ ip = _kernel()
151
+ nb = notebook_path(ip, notebook) if ip is not None or notebook else None
152
+ language = "python"
153
+ if nb is not None:
154
+ code, language = notebook_code(_read(nb))
155
+ filename, argv = nb.name, [nb.name]
156
+ elif ip is not None:
157
+ filename, code, argv = "-", None, []
158
+ print("baseqdb: this notebook's file was not found, so its code is not kept: "
159
+ 'bq.track(notebook="name.ipynb") names it.', file=sys.stderr)
160
+ else:
161
+ (filename, code), argv = _script(), list(sys.argv)
162
+ task = _c.task_run()
163
+ run = _c.post("/runs", json={"title": title, "language": language, "filename": filename, "source": code,
164
+ "argv": argv, "params": params or {}, "environment": environment(),
165
+ "lab": None if task else lab, "parent": task})
166
+ run = Begun(run)
167
+ _state.update(run=run["uid"], task=task, errored=False, finished=False, notebook=nb, language=language,
168
+ in_kernel=ip is not None)
169
+ _c.set_run(run["uid"])
170
+ if not _state["hooked"]:
171
+ _state["hooked"] = True
172
+ previous = sys.excepthook
173
+
174
+ def hook(kind, value, tb):
175
+ _state["errored"] = True
176
+ previous(kind, value, tb)
177
+ sys.excepthook = hook
178
+ atexit.register(_at_exit)
179
+ print(f"baseqdb: tracking this run as {run['uid']}", file=sys.stderr)
180
+ return run
181
+
182
+
183
+ def finish(status: str | None = None, finding: str | None = None, genes: list[str] | None = None) -> dict | None:
184
+ """End the run begun with track(): ``completed`` (or ``errored``). ``finding``: what it found, in a short paragraph,
185
+ with its ``genes`` ("ZBTB16+", "FKBP5+"): kept with the run (the task's, inside a task) and shown on its page and
186
+ in your lab's findings, marked From code. The requests after it belong to the task again, when there is one."""
187
+ if not _state["run"] or _state["finished"]:
188
+ return None
189
+ status = status or ("errored" if _state["errored"] else "completed")
190
+ body = {"status": status, "imported": imported()}
191
+ if finding:
192
+ body["finding"] = str(finding)
193
+ if genes:
194
+ body["genes"] = [str(g) for g in genes]
195
+ if _state.get("notebook") is not None:
196
+ body.update(_notebook_end(_state["notebook"]))
197
+ _state["finished"] = True
198
+ try:
199
+ answer = _c.post(f"/runs/{_state['run']}/finish", json=body)
200
+ except Exception:
201
+ _state["finished"] = False # still open on baSeq: finish() again, or the end of the script ends it
202
+ raise
203
+ _c.set_run(_state["task"])
204
+ return answer
205
+
206
+
207
+ def _notebook_end(path: Path) -> dict:
208
+ """At finish() in a notebook: the notebook as saved, with its outputs, kept as the run's report, and its cells as
209
+ the code the run ended with (the finish's body). Refused, the run left open, when a person's open notebook was not
210
+ saved after the cells this run went through."""
211
+ ip = _kernel()
212
+ notebook = _read(path)
213
+ now = (ip.execution_count - 1) if ip is not None else 0 # the cell running finish()
214
+ behind = now > 1 and saved_up_to(notebook) < now - 1
215
+ if behind and _interactive(ip):
216
+ raise _c.BaseqError(None, f"Save the notebook first (Ctrl+S, or Cmd+S on a Mac): the saved {path.name} stops at "
217
+ f"cell [{saved_up_to(notebook)}] and this run went on to [{now - 1}]. Then run "
218
+ "bq.finish() again.")
219
+ code, _language = notebook_code(notebook)
220
+ if behind: # nbconvert or papermill: the file on disk is written after the last cell
221
+ print(f"baseqdb: the saved {path.name} does not hold this run's outputs yet, so the run keeps its code but no "
222
+ "report.", file=sys.stderr)
223
+ else:
224
+ upload, sums, ids = _c.send([path])
225
+ _c.post(f"/uploads/{upload}/record", json={"name": path.name, "sha256": sums[ids[0]], "report": True})
226
+ return {"filename": path.name, "source": code}
227
+
228
+
229
+ def _at_exit() -> None:
230
+ if _state.get("in_kernel"): # a notebook's run ends with bq.finish(): one left open stops after a day, never "completed"
231
+ return
232
+ try:
233
+ finish()
234
+ except Exception as exc: # the interpreter is ending: say so, never raise
235
+ print(f"baseqdb: the run was not ended on baSeq: {exc}", file=sys.stderr)
@@ -0,0 +1,80 @@
1
+ # baSeq and baseqdb: a guide for AI agents
2
+
3
+ baSeq (https://baseq.bio) is where this lab keeps its omics data and analyses: datasets in versions, analyses with
4
+ their steps, what each step found and its result tables, and where every file came from. The baseqdb package reads
5
+ them and adds to them from Python, R and the shell, as the person whose API key it uses.
6
+
7
+ ## Before you start
8
+
9
+ - `bq.login()` finds the key: `BASEQ_API_KEY`, or the key `baseqdb login` kept on this computer. When neither is
10
+ there, ask the person to run `baseqdb login` in a terminal; it asks for a key they make on their baSeq Account page,
11
+ under API keys. Never print the key, write it to a file or put it in a command.
12
+ - What you download or print leaves baSeq, and baSeq's data processing agreement does not cover what happens to it
13
+ then. Read what the task needs: look at a dataset's description and sample sheet before its files, and print
14
+ summaries rather than whole tables.
15
+
16
+ ## One task, one run
17
+
18
+ baSeq keeps each task as one run, shown to the person and their lab: its code, what it read, what it added and what
19
+ it found. In the task's folder:
20
+
21
+ 1. `baseqdb track "what the task is, in a few words"` begins it. Everything baseqdb does in this folder, and in the
22
+ folders under it, joins it.
23
+ 2. Every Python script you write starts with `import baseqdb as bq` and `bq.track()` (R: `library(baseqdb)` and
24
+ `bq_track()`), so its code and command line are kept as part of the task.
25
+ 3. `bq.Record("path").save()` keeps a result file with the run (R: `Record$add("path")`). Keep what the person will
26
+ want to see, such as tables and plots, not scratch files.
27
+ 4. `baseqdb finish --finding "..." --gene SYMBOL+ --gene SYMBOL-` ends it. The finding is one short paragraph on what
28
+ the data showed, with the numbers and how you got them (for example "two donors, no statistical test"); `+` and
29
+ `-` give each gene's direction. Leave the finding out when the task found nothing, and end with
30
+ `baseqdb finish --errored` when it failed.
31
+
32
+ ## In a notebook
33
+
34
+ In Jupyter or VS Code, `bq.track()` in the first cell makes the notebook one run and keeps its cells as the code it
35
+ ran. `bq.finish()` at the end, in a cell of its own, keeps the notebook as saved, with its outputs: save it before that
36
+ cell runs. Where the notebook cannot be found, name it: `bq.track(notebook="analysis.ipynb")`. In R Markdown and
37
+ Quarto, `bq_track()` in the first chunk keeps the file as the code.
38
+
39
+ ## Reading
40
+
41
+ import baseqdb as bq
42
+ bq.login()
43
+ bq.Dataset.filter(name="Airway").to_dataframe() # datasets you can open, the newest version of each
44
+ bq.Dataset.filter(organism="human", kind="bulk", condition="dexamethasone", donor="N61311")
45
+ # by any column of their sample sheets: one sample has them all
46
+ bq.Dataset.search("dexamethasone") # words in names and in every value of the sample sheets
47
+ ds = bq.Dataset.get("<uid>") # one version (its stem gives the newest)
48
+ ds.versions, ds.sample_sheet # the versions you can open; the samples as a DataFrame
49
+ counts = ds.files["counts.tsv"].load() # a table file as a DataFrame (cache() gives its path)
50
+ bq.Analysis.filter(kind="bulk").to_dataframe() # analyses you can open (ds.analyses: those of a dataset)
51
+ a = bq.Analysis.get(1026)
52
+ a.steps.to_dataframe(), a.findings.to_dataframe() # its steps; what each step found
53
+ a.tables["dge_condition_treated_vs_control.csv"].load()
54
+ a.genes().to_dataframe() # its significant genes
55
+ bq.Record.get("<uid>").run.inputs # where a file came from: the run that wrote it, what it read
56
+ ds.run.initiated_by # the run a version added from code was added in
57
+
58
+ A dataset a filter or a search found says where in `where` ("Condition dexamethasone: 2 of 4 samples"). Look for
59
+ data this way before asking the person which dataset they mean. In R, the same names with `$`:
60
+ `Dataset$get(uid)$cache()`, `ds$sample_sheet`, `Dataset$filter(condition = "dexamethasone")`, `Dataset$search("dex")`,
61
+ `Analysis$filter()`.
62
+
63
+ ## Adding
64
+
65
+ A new version of a dataset: the name and kind of one the person has, with a key that may add data.
66
+
67
+ bq.Dataset(["counts_filtered.tsv"], name=ds.name, kind="bulk", sample_sheet="samples.csv").save()
68
+
69
+ Add data only when the task asks for it. A version stays (the API deletes nothing), and it starts what the dataset's
70
+ "When a new version is added" setting says.
71
+
72
+ ## Good to know
73
+
74
+ - A refused request changes nothing, and its error says why.
75
+ - A key opens what its person may open on baSeq and nothing more. Each key may send 1,000 requests an hour, and add
76
+ 100 datasets and begin 500 runs a day.
77
+ - The person finds the run under Scripts, Runs outside baSeq, and its finding in their lab's Findings, marked From
78
+ code.
79
+ - The docs: https://baseq.bio/docs/python-and-r.html. This guide: `baseqdb guide`; `baseqdb init` writes it into a
80
+ folder's AGENTS.md and CLAUDE.md, which agents read when they start there.
@@ -1,79 +0,0 @@
1
- """The ``baseqdb`` command, for an AI agent's task (or yours) as one run on baSeq:
2
-
3
- baseqdb track "Dexamethasone response in Airway" # begins the task's run; this folder's commands join it
4
- python filter_counts.py # its requests name the task; bq.track() inside joins it
5
- baseqdb finish --finding "ZBTB16 rose the most." # ends it, with what it found (--gene ZBTB16+, --errored)
6
-
7
- The task's uid is kept in .baseqdb/run in this folder (BASEQ_RUN overrides it), which baseqdb in this folder and the
8
- folders below it reads."""
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import sys
13
- from pathlib import Path
14
-
15
- from baseqdb import _client as _c
16
- from baseqdb import _track
17
-
18
-
19
- def _here() -> Path:
20
- return Path.cwd() / _c.RUN_FILE
21
-
22
-
23
- def track(title: str, lab=None) -> int:
24
- open_task = _c.task_run()
25
- if open_task:
26
- print(f"baseqdb: the task {open_task} is open here: finish it first (baseqdb finish).", file=sys.stderr)
27
- return 1
28
- _c.login()
29
- run = _c.post("/runs", json={"title": title, "language": "python", "argv": ["baseqdb", "track", title],
30
- "environment": _track.environment(), "lab": lab})
31
- _here().parent.mkdir(parents=True, exist_ok=True)
32
- _here().write_text(run["uid"] + "\n", encoding="utf-8")
33
- print(f"baseqdb: tracking \"{title}\" as run {run['uid']}. Every command in this folder joins it; end it with "
34
- "`baseqdb finish`.")
35
- return 0
36
-
37
-
38
- def finish(errored: bool = False, finding: str | None = None, genes: list[str] | None = None) -> int:
39
- uid = _c.task_run()
40
- if not uid:
41
- print("baseqdb: no task is open here.", file=sys.stderr)
42
- return 1
43
- _c.login()
44
- _c.set_run(None)
45
- body = {"status": "errored" if errored else "completed"}
46
- if finding:
47
- body["finding"] = finding
48
- if genes:
49
- body["genes"] = list(genes)
50
- run = _c.post(f"/runs/{uid}/finish", json=body)
51
- if _here().is_file() and _here().read_text(encoding="utf-8").strip() == uid:
52
- _here().unlink()
53
- print(f"baseqdb: run {uid} ended ({run.get('status')}).")
54
- return 0
55
-
56
-
57
- def main(argv=None) -> int:
58
- parser = argparse.ArgumentParser(prog="baseqdb", description="An AI agent's task, or yours, as one run on baSeq.")
59
- sub = parser.add_subparsers(dest="command", required=True)
60
- t = sub.add_parser("track", help="begin a task's run; the commands in this folder join it")
61
- t.add_argument("title", help="what the task is")
62
- t.add_argument("--lab", help="one of your labs to share the run with")
63
- f = sub.add_parser("finish", help="end the task's run")
64
- f.add_argument("--errored", action="store_true", help="the task failed")
65
- f.add_argument("--finding", help="what the task found, in a short paragraph (shown marked From code)")
66
- f.add_argument("--gene", action="append", default=[], metavar="GENE",
67
- help="a gene of the finding, with + or - for its direction (ZBTB16+); again for each")
68
- args = parser.parse_args(argv)
69
- try:
70
- if args.command == "track":
71
- return track(args.title, args.lab)
72
- return finish(args.errored, args.finding, args.gene)
73
- except _c.BaseqError as exc:
74
- print(f"baseqdb: {exc}", file=sys.stderr)
75
- return 1
76
-
77
-
78
- if __name__ == "__main__":
79
- sys.exit(main())
@@ -1,118 +0,0 @@
1
- """Your code as a run on baSeq, as lamindb's ``ln.track()``: ``bq.track()`` at the top of a script makes it one run,
2
- with its code (kept as a version of the script), its command line, its parameters and its environment; the files it
3
- downloads from baSeq while it runs are what it read, what it adds was added in it, and ``bq.Record(path).save()``
4
- keeps a file with it. ``bq.finish()`` ends it, with what it found when you say (``finding=``, a finding from code),
5
- and a script that ends without it ends its run anyway: completed, or errored when an exception stopped it.
6
-
7
- Inside an agent's task (``baseqdb track "<task>"``, _cli.py) a tracked script's run is part of the task's run, and code
8
- that does not track itself still names the task in its requests, so whatever the task does is one run."""
9
- from __future__ import annotations
10
-
11
- import atexit
12
- import os
13
- import platform
14
- import sys
15
-
16
- from baseqdb import _client as _c
17
-
18
- SOURCE_MAX = 2 * 1024 * 1024
19
- _state: dict = {"run": None, "task": None, "errored": False, "hooked": False, "finished": False}
20
-
21
-
22
- def _script() -> tuple[str, str | None]:
23
- """(the code's file name, its code): the script running; '-c' and its code for ``python -c``; '-' when there is no
24
- file (standard input, an interactive session)."""
25
- orig = list(getattr(sys, "orig_argv", None) or [])
26
- if "-c" in orig and orig.index("-c") + 1 < len(orig):
27
- return "-c", orig[orig.index("-c") + 1]
28
- main = sys.modules.get("__main__")
29
- path = getattr(main, "__file__", None)
30
- if path and os.path.isfile(path) and os.path.getsize(path) <= SOURCE_MAX:
31
- try:
32
- with open(path, encoding="utf-8", errors="replace") as fh:
33
- return os.path.basename(path), fh.read()
34
- except OSError:
35
- pass
36
- return "-", None
37
-
38
-
39
- def environment() -> dict:
40
- """Python and every installed distribution with its version, as baSeq keeps an analysis kernel's."""
41
- from importlib import metadata
42
- packages = {}
43
- for dist in metadata.distributions():
44
- name = (dist.metadata.get("Name") or "").strip()
45
- if name:
46
- packages.setdefault(name, dist.version)
47
- return {"python": {"version": platform.python_version(), "build": sys.version, "packages": packages}}
48
-
49
-
50
- def imported() -> list[str]:
51
- """The distributions the code imported, for the run's page."""
52
- from importlib import metadata
53
- try:
54
- owners = metadata.packages_distributions()
55
- except Exception:
56
- return []
57
- names = set()
58
- for module in list(sys.modules):
59
- top = module.split(".", 1)[0]
60
- for dist in owners.get(top, []):
61
- names.add(dist)
62
- return sorted(names)
63
-
64
-
65
- def track(title: str | None = None, params: dict | None = None, lab=None) -> dict:
66
- """This script as one run on baSeq (a key that may add data). ``title``: what it is, its file's name without it;
67
- ``params``: the settings it ran with; ``lab``: one of your labs to share the run with. Inside an agent's task, its
68
- run is part of the task's. The run, as baSeq describes it."""
69
- if _state["run"] and not _state["finished"]:
70
- return {"uid": _state["run"]}
71
- _c._http()
72
- filename, code = _script()
73
- task = _c.task_run()
74
- run = _c.post("/runs", json={"title": title, "language": "python", "filename": filename, "source": code,
75
- "argv": list(sys.argv), "params": params or {}, "environment": environment(),
76
- "lab": None if task else lab, "parent": task})
77
- _state.update(run=run["uid"], task=task, errored=False, finished=False)
78
- _c.set_run(run["uid"])
79
- if not _state["hooked"]:
80
- _state["hooked"] = True
81
- previous = sys.excepthook
82
-
83
- def hook(kind, value, tb):
84
- _state["errored"] = True
85
- previous(kind, value, tb)
86
- sys.excepthook = hook
87
- atexit.register(_at_exit)
88
- print(f"baseqdb: tracking this run as {run['uid']}", file=sys.stderr)
89
- return run
90
-
91
-
92
- def finish(status: str | None = None, finding: str | None = None, genes: list[str] | None = None) -> dict | None:
93
- """End the run begun with track(): ``completed`` (or ``errored``). ``finding``: what it found, in a short paragraph,
94
- with its ``genes`` ("ZBTB16+", "FKBP5+"): kept with the run (the task's, inside a task) and shown on its page and
95
- in your lab's findings, marked From code. The requests after it belong to the task again, when there is one."""
96
- if not _state["run"] or _state["finished"]:
97
- return None
98
- status = status or ("errored" if _state["errored"] else "completed")
99
- body = {"status": status, "imported": imported()}
100
- if finding:
101
- body["finding"] = str(finding)
102
- if genes:
103
- body["genes"] = [str(g) for g in genes]
104
- _state["finished"] = True
105
- try:
106
- answer = _c.post(f"/runs/{_state['run']}/finish", json=body)
107
- except Exception:
108
- _state["finished"] = False # still open on baSeq: finish() again, or the end of the script ends it
109
- raise
110
- _c.set_run(_state["task"])
111
- return answer
112
-
113
-
114
- def _at_exit() -> None:
115
- try:
116
- finish()
117
- except Exception as exc: # the interpreter is ending: say so, never raise
118
- print(f"baseqdb: the run was not ended on baSeq: {exc}", file=sys.stderr)
File without changes