baseqdb 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
baseqdb/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """baseqdb: your baSeq datasets, the analyses built on them, what they found and where a result came from, from
2
+ Python, with lamindb's names; and, with a key that may add data, new datasets and versions.
3
+
4
+ import baseqdb as bq
5
+ bq.login() # reads BASEQ_API_KEY
6
+ bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
7
+ ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
8
+ folder = ds.cache() # its files, downloaded once
9
+ bq.Dataset(["counts.tsv"], name=ds.name, kind="bulk").save() # its next version
10
+
11
+ bq.track() # this script as one run: what it reads, adds and saves
12
+ bq.Record("dge.csv").save() # a file of the run
13
+ bq.finish() # or let the script end
14
+ """
15
+ from baseqdb._client import VERSION as __version__, BaseqError, login, me
16
+ from baseqdb._records import (Analysis, Dataset, File, Files, Finding, GeneHit, Record, Results, Run, Step, Table,
17
+ Tables)
18
+ from baseqdb._track import finish, track
19
+
20
+ __all__ = ["login", "me", "BaseqError", "Dataset", "File", "Files", "Analysis", "Step", "Finding", "GeneHit", "Table",
21
+ "Tables", "Record", "Run", "Results", "track", "finish", "__version__"]
baseqdb/_cli.py ADDED
@@ -0,0 +1,79 @@
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())
baseqdb/_client.py ADDED
@@ -0,0 +1,206 @@
1
+ """The connection to baSeq's API (/api/v1): your key, requests, pages of a list, downloads kept in a cache, files sent
2
+ in parts for a dataset you add, and the run the requests belong to (an agent's task or a tracked script, _track.py)."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ DEFAULT_URL = "https://baseq.bio"
12
+ VERSION = "0.5.0"
13
+ RUN_HEADER = "X-Baseq-Run"
14
+ RUN_FILE = Path(".baseqdb") / "run" # an agent's task, begun with `baseqdb track`, for every command in the folder
15
+ _state: dict = {"client": None, "base": None, "run": None}
16
+
17
+
18
+ class BaseqError(Exception):
19
+ """What the API refused, in its own words, with the HTTP status (None when nothing was asked yet)."""
20
+
21
+ def __init__(self, status: int | None, message: str):
22
+ super().__init__(message)
23
+ self.status = status
24
+
25
+
26
+ def login(key: str | None = None, url: str | None = None, client: httpx.Client | None = None) -> dict:
27
+ """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")
31
+ 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.")
33
+ base = (url or os.environ.get("BASEQ_URL") or DEFAULT_URL).rstrip("/") + "/api/v1"
34
+ http = client or httpx.Client(timeout=httpx.Timeout(60.0, read=600.0))
35
+ http.headers["Authorization"] = f"Bearer {key}"
36
+ http.headers["User-Agent"] = f"baseqdb/{VERSION}"
37
+ _state.update(client=http, base=base)
38
+ set_run(task_run())
39
+ return me()
40
+
41
+
42
+ def task_run() -> str | None:
43
+ """The open task this code is part of: BASEQ_RUN, or the uid in .baseqdb/run here or in a folder above."""
44
+ if os.environ.get("BASEQ_RUN"):
45
+ return os.environ["BASEQ_RUN"].strip() or None
46
+ here = Path.cwd()
47
+ for folder in (here, *here.parents):
48
+ found = folder / RUN_FILE
49
+ if found.is_file():
50
+ return found.read_text(encoding="utf-8").strip() or None
51
+ return None
52
+
53
+
54
+ def set_run(uid: str | None) -> None:
55
+ """The run the requests from now on belong to (None: no run)."""
56
+ _state["run"] = uid
57
+ http = _state["client"]
58
+ if http is None:
59
+ return
60
+ if uid:
61
+ http.headers[RUN_HEADER] = uid
62
+ else:
63
+ http.headers.pop(RUN_HEADER, None)
64
+
65
+
66
+ def current_run() -> str | None:
67
+ return _state["run"]
68
+
69
+
70
+ def me() -> dict:
71
+ """Whose key this is, what it may do and when it ends."""
72
+ return get("/me")
73
+
74
+
75
+ def _http() -> tuple[httpx.Client, str]:
76
+ if _state["client"] is None:
77
+ login()
78
+ return _state["client"], _state["base"]
79
+
80
+
81
+ def _raise(response: httpx.Response) -> None:
82
+ if response.status_code < 400:
83
+ return
84
+ try:
85
+ detail = response.json().get("detail")
86
+ except Exception:
87
+ detail = response.text[:200]
88
+ if isinstance(detail, list): # a request the API could not read: which parameter, and why
89
+ detail = "; ".join(f"{'.'.join(map(str, d.get('loc', [])))}: {d.get('msg')}" for d in detail)
90
+ raise BaseqError(response.status_code, f"{detail} (HTTP {response.status_code})")
91
+
92
+
93
+ def get(path: str, **params) -> dict:
94
+ http, base = _http()
95
+ response = http.get(base + path, params={k: v for k, v in params.items() if v is not None})
96
+ _raise(response)
97
+ return response.json()
98
+
99
+
100
+ def post(path: str, json: dict | None = None) -> dict:
101
+ http, base = _http()
102
+ response = http.post(base + path, json=json)
103
+ _raise(response)
104
+ return response.json() if response.content else {}
105
+
106
+
107
+ def put(path: str, content: bytes, **params) -> None:
108
+ http, base = _http()
109
+ response = http.put(base + path, content=content, params={k: v for k, v in params.items() if v is not None},
110
+ headers={"Content-Type": "application/octet-stream"})
111
+ _raise(response)
112
+
113
+
114
+ def delete(path: str) -> None:
115
+ http, base = _http()
116
+ _raise(http.delete(base + path))
117
+
118
+
119
+ def send(paths) -> tuple[str, dict, list]:
120
+ """``paths`` sent in parts to a new upload: its id, each file's SHA-256 by the id it went under ("f0", "f1", ...)
121
+ and those ids in order. An upload that fails on the way is thrown away."""
122
+ began = post("/uploads")
123
+ upload, size = began["upload"], int(began["part_size"])
124
+ sums, ids = {}, []
125
+ try:
126
+ for i, path in enumerate(paths):
127
+ fid, h, n = f"f{i}", hashlib.sha256(), 0
128
+ with open(path, "rb") as fh:
129
+ while True:
130
+ chunk = fh.read(size)
131
+ if not chunk and n:
132
+ break
133
+ h.update(chunk)
134
+ put(f"/uploads/{upload}/files/{fid}/parts/{n}", chunk, name=Path(path).name if n == 0 else None)
135
+ n += 1
136
+ if len(chunk) < size:
137
+ break
138
+ sums[fid] = h.hexdigest()
139
+ ids.append(fid)
140
+ except BaseException:
141
+ try:
142
+ delete(f"/uploads/{upload}")
143
+ except Exception:
144
+ pass
145
+ raise
146
+ return upload, sums, ids
147
+
148
+
149
+ def pages(path: str, **params):
150
+ """Every item of a list the API gives in pages."""
151
+ offset = 0
152
+ while True:
153
+ page = get(path, offset=offset, **params)
154
+ yield from page["items"]
155
+ offset += len(page["items"])
156
+ if not page["items"] or offset >= page["total"]:
157
+ return
158
+
159
+
160
+ def sha256_of(path: Path) -> str:
161
+ h = hashlib.sha256()
162
+ with open(path, "rb") as fh:
163
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
164
+ h.update(chunk)
165
+ return h.hexdigest()
166
+
167
+
168
+ def download(path: str, target: Path, sha256: str | None = None, read: dict | None = None) -> Path:
169
+ """``path`` saved as ``target``, once: a file already there is kept when it has the right SHA-256 (or, when none
170
+ is known, at all). A download whose bytes do not match is thrown away. ``read`` names the file for a run: a copy
171
+ kept from before is still read by the run, and baSeq is told so."""
172
+ if target.exists() and (sha256 is None or sha256_of(target) == sha256):
173
+ if read and _state["run"]:
174
+ note_read(read)
175
+ return target
176
+ target.parent.mkdir(parents=True, exist_ok=True)
177
+ part = target.with_name(target.name + ".part")
178
+ http, base = _http()
179
+ h = hashlib.sha256()
180
+ with http.stream("GET", base + path) as response:
181
+ if response.status_code >= 400:
182
+ response.read()
183
+ _raise(response)
184
+ with open(part, "wb") as fh:
185
+ for chunk in response.iter_bytes(1 << 20):
186
+ fh.write(chunk)
187
+ h.update(chunk)
188
+ if sha256 and h.hexdigest() != sha256:
189
+ part.unlink(missing_ok=True)
190
+ raise BaseqError(None, f"{target.name} arrived damaged (its SHA-256 is not the dataset's); try again.")
191
+ os.replace(part, target)
192
+ return target
193
+
194
+
195
+ def cache_dir() -> Path:
196
+ """Where downloads are kept: BASEQDB_CACHE, or ~/.cache/baseqdb."""
197
+ return Path(os.environ.get("BASEQDB_CACHE") or Path.home() / ".cache" / "baseqdb")
198
+
199
+
200
+ def note_read(item: dict) -> None:
201
+ """The open run read this file from baseqdb's cache: baSeq notes it as it notes a download. Best effort."""
202
+ try:
203
+ post(f"/runs/{_state['run']}/inputs", json={"items": [item]})
204
+ except BaseqError as exc:
205
+ import logging
206
+ logging.getLogger("baseqdb").warning("baSeq was not told the run read %s: %s", item, exc)
baseqdb/_records.py ADDED
@@ -0,0 +1,367 @@
1
+ """baSeq's datasets, analyses, records and runs, with lamindb's names: ``get`` one, ``filter`` many, ``cache`` the
2
+ files, ``load`` a table, ``to_dataframe`` a list. A Dataset is lamindb's Collection (its versions share a stem), a
3
+ Record its Artifact, a Run its Run."""
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import os
8
+ from datetime import datetime
9
+ from pathlib import Path, PurePosixPath
10
+ from urllib.parse import quote
11
+
12
+ from baseqdb import _client as _c
13
+
14
+ _log = logging.getLogger("baseqdb")
15
+
16
+
17
+ def _when(value):
18
+ return datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
19
+
20
+
21
+ def _pandas():
22
+ try:
23
+ import pandas
24
+ except ImportError:
25
+ raise ImportError("this needs pandas: pip install pandas") from None
26
+ return pandas
27
+
28
+
29
+ def _inside(folder: Path, name: str) -> Path:
30
+ """``name`` (which may hold a folder, "data_summary/counts.tsv") under ``folder``, never above it."""
31
+ parts = [p for p in PurePosixPath(name).parts if p not in ("", ".", "..", "/")]
32
+ return folder.joinpath(*parts)
33
+
34
+
35
+ class Results(list):
36
+ """A list of baSeq things. ``to_dataframe()`` gives their fields as a pandas DataFrame."""
37
+
38
+ def to_dataframe(self):
39
+ return _pandas().DataFrame([x.row() for x in self])
40
+
41
+
42
+ class Files(Results):
43
+ """A dataset's files, by position or by name: ``ds.files["counts.tsv"]``."""
44
+
45
+ def __getitem__(self, key):
46
+ if isinstance(key, str):
47
+ for f in self:
48
+ if f.name == key:
49
+ return f
50
+ raise KeyError(f"no file {key!r}; there are {[f.name for f in self]}")
51
+ return super().__getitem__(key)
52
+
53
+
54
+ class _Thing:
55
+ fields: tuple = ()
56
+
57
+ def __init__(self, data: dict):
58
+ self._data = dict(data)
59
+ for f in self.fields:
60
+ setattr(self, f, _when(data.get(f)) if f.endswith("_at") else data.get(f))
61
+
62
+ def row(self) -> dict:
63
+ return {f: getattr(self, f) for f in self.fields}
64
+
65
+ def __repr__(self):
66
+ shown = ", ".join(f"{f}={getattr(self, f)!r}" for f in self.fields[:4])
67
+ return f"{type(self).__name__}({shown})"
68
+
69
+
70
+ # ---------------------------------------------------------------------------------------------- datasets
71
+
72
+ class Dataset(_Thing):
73
+ """A dataset: one version (``uid``) of a family (``stem``).
74
+
75
+ ``Dataset(files, name=..., kind=...)`` is one to add with ``save()``, as lamindb's ``Artifact(path, key=...)``:
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")
78
+
79
+ def __init__(self, data=None, *, name: str | None = None, kind: str | None = None, sample_sheet=None, lab=None):
80
+ if isinstance(data, dict): # as the API describes one
81
+ super().__init__(data)
82
+ self._draft = None
83
+ return
84
+ files = [data] if isinstance(data, (str, os.PathLike)) else list(data or [])
85
+ if not files or not name or not kind:
86
+ raise TypeError("Dataset(files, name=..., kind=...): the files to add, the dataset's name and its kind "
87
+ "(a kind of data your plan includes, or part of its name, such as 'bulk')")
88
+ super().__init__({"name": name, "kind": kind})
89
+ self._draft = {"files": [Path(f) for f in files], "sample_sheet": Path(sample_sheet) if sample_sheet else None,
90
+ "lab": lab, "sent": None}
91
+ self.notes: list[str] = []
92
+
93
+ def save(self) -> "Dataset":
94
+ """Add it, with a key that may add data: its files sent in parts, each checked by its SHA-256 on arrival. With
95
+ the name and kind of a dataset you have, its next version, which starts what that dataset's "When a new version
96
+ is added" says; with the very files of one you have, that one. What else to know is in ``notes``. Itself."""
97
+ draft = getattr(self, "_draft", None)
98
+ if draft is None:
99
+ return self
100
+ paths = draft["files"] + ([draft["sample_sheet"]] if draft["sample_sheet"] else [])
101
+ for path in paths:
102
+ if not path.is_file():
103
+ raise _c.BaseqError(None, f"{path} is not a file.")
104
+ for attempt in (1, 2):
105
+ if draft["sent"] is None:
106
+ draft["sent"] = _c.send(paths)
107
+ upload, sums, ids = draft["sent"]
108
+ body = {"name": self.name, "kind": self.kind, "lab": draft["lab"], "sha256": sums,
109
+ "sample_sheet": ids[-1] if draft["sample_sheet"] else None}
110
+ try:
111
+ answer = _c.post(f"/uploads/{upload}/finish", json=body)
112
+ break
113
+ except _c.BaseqError as exc:
114
+ # A refusal that can be put right keeps the upload for another save(); one gone is sent again, once
115
+ if exc.status in (404, 422) or attempt == 2:
116
+ draft["sent"] = None
117
+ if exc.status != 404 or attempt == 2:
118
+ raise
119
+ _Thing.__init__(self, answer)
120
+ self._draft = None
121
+ self.notes = list(answer.get("notes") or [])
122
+ for note in self.notes:
123
+ _log.warning(note)
124
+ return self
125
+
126
+ @classmethod
127
+ def get(cls, uid: str) -> "Dataset":
128
+ """By its uid (that version) or its stem (its newest version you can open)."""
129
+ return cls(_c.get(f"/datasets/{quote(uid)}"))
130
+
131
+ @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))
136
+
137
+ def _detail(self) -> dict:
138
+ if "files" not in self._data:
139
+ self._data.update(_c.get(f"/datasets/{self.uid}"))
140
+ return self._data
141
+
142
+ @property
143
+ def files(self) -> Results:
144
+ return Files(File(f, self) for f in self._detail()["files"])
145
+
146
+ @property
147
+ def run(self) -> "Run | None":
148
+ """The run that made this version, as lamindb's collection.run, when you can open it: its import, whose
149
+ initiated_by is the run it was added in when it came from code."""
150
+ uid = self._detail().get("run")
151
+ return Run.get(uid) if uid else None
152
+
153
+ @property
154
+ def versions(self) -> Results:
155
+ """Every version of this dataset you can open, oldest first."""
156
+ return Results(self if v["uid"] == self.uid else Dataset.get(v["uid"]) for v in self._detail()["versions"])
157
+
158
+ @property
159
+ def sample_sheet(self):
160
+ """The sample sheet, as a pandas DataFrame (the sample first)."""
161
+ sheet = _c.get(f"/datasets/{self.uid}/sample-sheet")
162
+ return _pandas().DataFrame(sheet["rows"], columns=sheet["columns"])
163
+
164
+ @property
165
+ def analyses(self) -> Results:
166
+ """The analyses of this version you can open."""
167
+ return Results(Analysis(a) for a in _c.get(f"/datasets/{self.uid}/analyses")["items"])
168
+
169
+ def cache(self, folder: str | Path | None = None) -> Path:
170
+ """Its files, downloaded once into ``folder`` (by default baseqdb's cache, datasets/<uid>), each checked
171
+ against its SHA-256. The folder."""
172
+ root = Path(folder) if folder else _c.cache_dir() / "datasets" / self.uid
173
+ for f in self.files:
174
+ f.cache(root)
175
+ return root
176
+
177
+
178
+ class File(_Thing):
179
+ """One of a dataset's files."""
180
+ fields = ("name", "size", "sha256")
181
+
182
+ def __init__(self, data: dict, dataset: Dataset):
183
+ super().__init__(data)
184
+ self.dataset = dataset
185
+
186
+ def cache(self, folder: str | Path | None = None) -> Path:
187
+ root = Path(folder) if folder else _c.cache_dir() / "datasets" / self.dataset.uid
188
+ return _c.download(f"/datasets/{self.dataset.uid}/files/{quote(self.name)}", _inside(root, self.name), self.sha256,
189
+ read={"dataset": self.dataset.uid, "file": self.name})
190
+
191
+ def load(self, **read_options):
192
+ """A table as a pandas DataFrame, as lamindb's ``artifact.load()``: a .csv file read comma-separated, a .tsv
193
+ or .txt file tab-separated (``read_options`` go to pandas.read_csv). Any other file: ``cache()`` for its path."""
194
+ name = self.name.lower().removesuffix(".gz")
195
+ if not name.endswith((".csv", ".tsv", ".txt")):
196
+ raise _c.BaseqError(None, f"load() reads tables (.csv, .tsv, .txt); for {self.name}, cache() gives its path.")
197
+ sep = "," if name.endswith(".csv") else "\t"
198
+ return _pandas().read_csv(self.cache(), **{"sep": sep, **read_options})
199
+
200
+
201
+ # ---------------------------------------------------------------------------------------------- analyses
202
+
203
+ class Analysis(_Thing):
204
+ """An analysis: its steps, what they found, its significant genes and its tables."""
205
+ fields = ("id", "name", "kind", "project", "dataset", "organism", "created_at", "updated_at", "frozen", "mine")
206
+
207
+ @classmethod
208
+ def get(cls, id: int) -> "Analysis":
209
+ return cls(_c.get(f"/analyses/{int(id)}"))
210
+
211
+ @classmethod
212
+ def filter(cls, kind: str | None = None, name: str | None = None) -> Results:
213
+ """The analyses you can open, newest first, with the dataset each was made from when it was."""
214
+ return Results(cls(a) for a in _c.pages("/analyses", kind=kind, name=name, limit=1000))
215
+
216
+ def _detail(self) -> dict:
217
+ if "steps" not in self._data:
218
+ self._data.update(_c.get(f"/analyses/{self.id}"))
219
+ return self._data
220
+
221
+ @property
222
+ def steps(self) -> Results:
223
+ """Each step in the order it was added, with its settings and when it ran."""
224
+ return Results(Step(s) for s in self._detail()["steps"])
225
+
226
+ @property
227
+ def findings(self) -> Results:
228
+ """What each step found."""
229
+ return Results(Finding(f) for f in _c.get(f"/analyses/{self.id}/findings")["items"])
230
+
231
+ def genes(self, gene: str | None = None, step_id: int | None = None) -> Results:
232
+ """The significant genes of its result tables (adjusted p 0.05 or less), or one gene's."""
233
+ return Results(GeneHit(g) for g in _c.pages(f"/analyses/{self.id}/genes", gene=gene, step_id=step_id,
234
+ limit=10000))
235
+
236
+ @property
237
+ def tables(self) -> "Tables":
238
+ """Its tables by name ("dge_condition_treated_vs_control.csv", "data_summary/counts.tsv"); when two steps
239
+ wrote one of the same name, the later step's. ``tables.all`` lists every one."""
240
+ return Tables(Table(t, self) for t in _c.get(f"/analyses/{self.id}/tables")["items"])
241
+
242
+
243
+ class Tables(dict):
244
+ def __init__(self, tables):
245
+ super().__init__()
246
+ self.all = Results(tables)
247
+ for t in self.all:
248
+ self[t.name] = t
249
+
250
+ def __missing__(self, name):
251
+ raise KeyError(f"no table {name!r}; there are {sorted(self)}")
252
+
253
+ def to_dataframe(self):
254
+ return self.all.to_dataframe()
255
+
256
+
257
+ class Step(_Thing):
258
+ fields = ("id", "method", "title", "status", "settings", "created_at", "parent", "tables")
259
+
260
+ def row(self) -> dict:
261
+ return {**super().row(), "run": (self._data.get("run") or {}).get("uid")}
262
+
263
+ @property
264
+ def run(self) -> "Run | None":
265
+ found = self._data.get("run")
266
+ return Run.get(found["uid"]) if found else None
267
+
268
+
269
+ class Finding(_Thing):
270
+ fields = ("step_id", "method", "title", "finding", "genes", "terms", "conditions")
271
+
272
+
273
+ class GeneHit(_Thing):
274
+ fields = ("step_id", "gene", "direction", "log2fc", "padj", "contrast", "table")
275
+
276
+
277
+ class Table(_Thing):
278
+ """A table a step wrote."""
279
+ fields = ("step_id", "method", "name", "size")
280
+
281
+ def __init__(self, data: dict, analysis: Analysis):
282
+ super().__init__(data)
283
+ self.analysis = analysis
284
+
285
+ def cache(self, folder: str | Path | None = None) -> Path:
286
+ root = Path(folder) if folder else _c.cache_dir() / "analyses" / str(self.analysis.id) / str(self.step_id)
287
+ path = f"/analyses/{self.analysis.id}/tables/{self.step_id}/{quote(self.name)}"
288
+ return _c.download(path, _inside(root, self.name),
289
+ read={"analysis": self.analysis.id, "step": self.step_id, "table": self.name})
290
+
291
+ def load(self, **read_options):
292
+ """The table as a pandas DataFrame (``read_options`` go to pandas.read_csv)."""
293
+ sep = "\t" if self.name.lower().endswith(".tsv") else ","
294
+ return _pandas().read_csv(self.cache(), **{"sep": sep, **read_options})
295
+
296
+
297
+ # ---------------------------------------------------------------------------------------------- records and runs
298
+
299
+ class Record(_Thing):
300
+ """A record: one version of a file the platform keeps (lamindb's Artifact).
301
+
302
+ ``Record(path).save()`` keeps a file your tracked code made with its run (bq.track(), or an agent's task), as
303
+ lamindb's ``Artifact(path).save()`` inside ``ln.track()``: saved again under the same name, its next version."""
304
+ fields = ("uid", "stem", "version", "is_latest", "name", "suffix", "role", "kind", "size", "sha256", "lamin_hash",
305
+ "created_at", "superseded_at", "gone_at", "analysis", "step")
306
+
307
+ def __init__(self, data=None, *, name: str | None = None):
308
+ if isinstance(data, dict):
309
+ super().__init__(data)
310
+ self._path = None
311
+ return
312
+ if data is None:
313
+ raise TypeError("Record(path): the file to keep with the run")
314
+ self._path = Path(data)
315
+ super().__init__({"name": name or self._path.name})
316
+
317
+ def save(self) -> "Record":
318
+ """Keep the file with the open run, checked by its SHA-256 on arrival. Itself."""
319
+ if self._path is None:
320
+ return self
321
+ if not _c.current_run():
322
+ raise _c.BaseqError(None, "A file is kept with a run: begin one with bq.track() (or `baseqdb track`) first.")
323
+ if not self._path.is_file():
324
+ raise _c.BaseqError(None, f"{self._path} is not a file.")
325
+ upload, sums, ids = _c.send([self._path])
326
+ answer = _c.post(f"/uploads/{upload}/record", json={"name": self.name, "sha256": sums[ids[0]]})
327
+ _Thing.__init__(self, {**answer, "is_latest": True})
328
+ self._path = None
329
+ return self
330
+
331
+ @classmethod
332
+ def get(cls, uid: str) -> "Record":
333
+ return cls(_c.get(f"/records/{quote(uid)}"))
334
+
335
+ def row(self) -> dict:
336
+ return {**super().row(), "run": self._data.get("run")}
337
+
338
+ @property
339
+ def run(self) -> "Run | None":
340
+ """The run that wrote it, when you can open it."""
341
+ return Run.get(self._data["run"]) if self._data.get("run") else None
342
+
343
+
344
+ class Run(_Thing):
345
+ """A run: what ran, with which settings, in which environment, what it read and what it wrote."""
346
+ fields = ("uid", "kind", "status", "status_code", "transform", "settings", "created_at", "started_at",
347
+ "finished_at", "analysis", "step", "dataset", "initiated_by", "environment")
348
+
349
+ @classmethod
350
+ def get(cls, uid: str) -> "Run":
351
+ return cls(_c.get(f"/runs/{quote(uid)}"))
352
+
353
+ @property
354
+ def inputs(self) -> Results:
355
+ """The records it read (those you can open)."""
356
+ return Results(Record(r) for r in self._data.get("inputs") or [])
357
+
358
+ @property
359
+ def outputs(self) -> Results:
360
+ """The records it wrote (those you can open)."""
361
+ return Results(Record(r) for r in self._data.get("outputs") or [])
362
+
363
+ @property
364
+ def initiated_runs(self) -> Results:
365
+ """The runs it started, as lamindb's run.initiated_runs: an agent's task's scripts, the import of a dataset it
366
+ added (those you can open)."""
367
+ return Results(Run.get(uid) for uid in self._data.get("initiated_runs") or [])
baseqdb/_track.py ADDED
@@ -0,0 +1,118 @@
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)
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: baseqdb
3
+ Version: 0.5.0
4
+ Summary: Your baSeq datasets, analyses and results from Python, with lamindb's names
5
+ Project-URL: Homepage, https://baseq.bio
6
+ Project-URL: Documentation, https://baseq.bio/docs/python-and-r.html
7
+ Author-email: baSeq ApS <info@baseq.dk>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: baseq,bioinformatics,lineage,provenance,rna-seq,single-cell
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.24
18
+ Provides-Extra: pandas
19
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # baseqdb
23
+
24
+ Your baSeq datasets, the analyses built on them, what they found and where a result came from, from Python.
25
+
26
+ ```python
27
+ import baseqdb as bq
28
+ bq.login() # reads BASEQ_API_KEY
29
+
30
+ bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
31
+ bq.Analysis.filter(kind="bulk").to_dataframe() # the analyses you can open, newest first
32
+ ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
33
+ folder = ds.cache() # its files, downloaded once
34
+ sheet = ds.sample_sheet # a DataFrame
35
+
36
+ analysis = ds.analyses[0]
37
+ analysis.steps.to_dataframe() # each step, its settings, when it ran
38
+ analysis.findings.to_dataframe() # what each step found
39
+ table = analysis.tables["dge_condition_dexamethasone_vs_untreated.csv"].load()
40
+
41
+ record = bq.Record.get("Z3HMY4V1wC6goA380000") # a file a step wrote
42
+ record.run.inputs # the records it read
43
+ ds.run.initiated_by # the run a version added from code was added in
44
+ ```
45
+
46
+ With a key that may add data ("Read and add data"), add a dataset. With the name and kind of one you have, it is its
47
+ next version, which starts what that dataset's "When a new version is added" says:
48
+
49
+ ```python
50
+ new = bq.Dataset(["counts_filtered.tsv"], name="Airway demo", kind="bulk", sample_sheet="samples.csv").save()
51
+ new.version # 2: the next version
52
+ new.notes # what else to know, such as what the new version started
53
+ ```
54
+
55
+ ## Track your code
56
+
57
+ One line at the top makes a script one run on baSeq, as lamindb's `ln.track()`: its code (kept as a version of the
58
+ script), its command line and environment, the files it downloads from baSeq (what it read), the datasets it adds and
59
+ the files it saves, and what it found when it says. Reads are counted, not listed one by one; everything a run adds is
60
+ kept. Its page on baSeq is under Scripts, Runs outside baSeq; what it found is in your lab's findings too, marked From
61
+ code, never taken for a step's checked finding.
62
+
63
+ ```python
64
+ bq.track() # this script, as one run
65
+ counts = ds.files["counts.tsv"].load() # noted as what the run read
66
+ bq.Record("dge_dex.csv").save() # a file of the run
67
+ bq.finish(finding="Dexamethasone raised ZBTB16 and FKBP5 the most.",
68
+ genes=["ZBTB16+", "FKBP5+"]) # what it found; or let the script end: an exception ends it as errored
69
+ ```
70
+
71
+ ## With an AI agent
72
+
73
+ An agent such as Claude Code can use baseqdb with your key. Ask it to begin each task with `baseqdb track` and end it
74
+ with `baseqdb finish`: everything it runs in that folder in between is one run, however many commands it takes.
75
+
76
+ ```sh
77
+ baseqdb track "Dexamethasone response in Airway"
78
+ python filter_counts.py --min-reads 10 # joins the run
79
+ python dge_dex.py --padj 0.01 # joins the run
80
+ baseqdb finish --finding "ZBTB16 and FKBP5 rose the most." --gene ZBTB16+ --gene FKBP5+
81
+ ```
82
+
83
+ If you are an agent: begin the task with `baseqdb track "<what the task is>"` in its folder, call `bq.track()` at the
84
+ top of each script you write, save results you keep with `bq.Record(path).save()`, and end with `baseqdb finish`,
85
+ saying in one short paragraph what the task found (`--finding`, with `--gene` for each gene; `--errored` when the task
86
+ failed).
87
+
88
+ Data you fetch with a key leaves baSeq. Our Data Processing Agreement does not cover what an AI agent or another
89
+ service then does with it.
90
+
91
+ Make an API key on your baSeq Account page, under API keys. Install pandas too for DataFrames:
92
+ `pip install "baseqdb[pandas]"`.
93
+
94
+ The whole guide, with R: https://baseq.bio/docs/python-and-r.html
@@ -0,0 +1,10 @@
1
+ baseqdb/__init__.py,sha256=MVkgnbzTQWC9wPMpS7xp9PQt6P1mbY9pWtxGdccNNQg,1358
2
+ baseqdb/_cli.py,sha256=syCu1lNLrdxlLwMhpZAuN6LTz2JjHpwO463w1rx1nr0,3334
3
+ baseqdb/_client.py,sha256=imapqnjv3wyGjLg_btgEHzUhe_niPOfuEb8sA5rKi-U,7585
4
+ baseqdb/_records.py,sha256=ujXmIjMmP29QfLE88aIe82Ba3VpXlS0Zju068oY2Jfw,15788
5
+ baseqdb/_track.py,sha256=JRd82T-_YUlhPPmnJXmoUnvkQCEr0oOoM53j9zHBbOw,5283
6
+ baseqdb-0.5.0.dist-info/METADATA,sha256=mmVdYghRp3yHasnSDYEoHuUHlSEAW-sMv18x4HqP288,4609
7
+ baseqdb-0.5.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ baseqdb-0.5.0.dist-info/entry_points.txt,sha256=1_HvebZ3sD9BTtIfQXH46kkFR5Oj5zR-Oy__bt3nNyU,46
9
+ baseqdb-0.5.0.dist-info/licenses/LICENSE,sha256=QXwxFkSnSc_RVYZhE2HNaaBUWqQkaEadiM3RAKfNNRc,1066
10
+ baseqdb-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ baseqdb = baseqdb._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 baSeq ApS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.