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.
- {baseqdb-0.5.0 → baseqdb-0.9.0}/PKG-INFO +3 -1
- {baseqdb-0.5.0 → baseqdb-0.9.0}/README.md +2 -0
- {baseqdb-0.5.0 → baseqdb-0.9.0}/pyproject.toml +1 -1
- {baseqdb-0.5.0 → baseqdb-0.9.0}/src/baseqdb/__init__.py +5 -0
- baseqdb-0.9.0/src/baseqdb/_cli.py +164 -0
- {baseqdb-0.5.0 → baseqdb-0.9.0}/src/baseqdb/_client.py +44 -5
- {baseqdb-0.5.0 → baseqdb-0.9.0}/src/baseqdb/_records.py +21 -5
- baseqdb-0.9.0/src/baseqdb/_track.py +235 -0
- baseqdb-0.9.0/src/baseqdb/guide.md +80 -0
- baseqdb-0.5.0/src/baseqdb/_cli.py +0 -79
- baseqdb-0.5.0/src/baseqdb/_track.py +0 -118
- {baseqdb-0.5.0 → baseqdb-0.9.0}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: baseqdb
|
|
3
|
-
Version: 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,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.
|
|
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
|
|
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:
|
|
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,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|