baseqdb 0.5.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/LICENSE +21 -0
- baseqdb-0.5.0/PKG-INFO +94 -0
- baseqdb-0.5.0/README.md +73 -0
- baseqdb-0.5.0/pyproject.toml +35 -0
- baseqdb-0.5.0/src/baseqdb/__init__.py +21 -0
- baseqdb-0.5.0/src/baseqdb/_cli.py +79 -0
- baseqdb-0.5.0/src/baseqdb/_client.py +206 -0
- baseqdb-0.5.0/src/baseqdb/_records.py +367 -0
- baseqdb-0.5.0/src/baseqdb/_track.py +118 -0
baseqdb-0.5.0/LICENSE
ADDED
|
@@ -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.
|
baseqdb-0.5.0/PKG-INFO
ADDED
|
@@ -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
|
baseqdb-0.5.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# baseqdb
|
|
2
|
+
|
|
3
|
+
Your baSeq datasets, the analyses built on them, what they found and where a result came from, from Python.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import baseqdb as bq
|
|
7
|
+
bq.login() # reads BASEQ_API_KEY
|
|
8
|
+
|
|
9
|
+
bq.Dataset.filter(kind="bulk").to_dataframe() # yours, newest versions
|
|
10
|
+
bq.Analysis.filter(kind="bulk").to_dataframe() # the analyses you can open, newest first
|
|
11
|
+
ds = bq.Dataset.get("kTIqMTTm89P2gbNU0000") # one version, by its uid
|
|
12
|
+
folder = ds.cache() # its files, downloaded once
|
|
13
|
+
sheet = ds.sample_sheet # a DataFrame
|
|
14
|
+
|
|
15
|
+
analysis = ds.analyses[0]
|
|
16
|
+
analysis.steps.to_dataframe() # each step, its settings, when it ran
|
|
17
|
+
analysis.findings.to_dataframe() # what each step found
|
|
18
|
+
table = analysis.tables["dge_condition_dexamethasone_vs_untreated.csv"].load()
|
|
19
|
+
|
|
20
|
+
record = bq.Record.get("Z3HMY4V1wC6goA380000") # a file a step wrote
|
|
21
|
+
record.run.inputs # the records it read
|
|
22
|
+
ds.run.initiated_by # the run a version added from code was added in
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
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
|
|
26
|
+
next version, which starts what that dataset's "When a new version is added" says:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
new = bq.Dataset(["counts_filtered.tsv"], name="Airway demo", kind="bulk", sample_sheet="samples.csv").save()
|
|
30
|
+
new.version # 2: the next version
|
|
31
|
+
new.notes # what else to know, such as what the new version started
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Track your code
|
|
35
|
+
|
|
36
|
+
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
|
|
37
|
+
script), its command line and environment, the files it downloads from baSeq (what it read), the datasets it adds and
|
|
38
|
+
the files it saves, and what it found when it says. Reads are counted, not listed one by one; everything a run adds is
|
|
39
|
+
kept. Its page on baSeq is under Scripts, Runs outside baSeq; what it found is in your lab's findings too, marked From
|
|
40
|
+
code, never taken for a step's checked finding.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
bq.track() # this script, as one run
|
|
44
|
+
counts = ds.files["counts.tsv"].load() # noted as what the run read
|
|
45
|
+
bq.Record("dge_dex.csv").save() # a file of the run
|
|
46
|
+
bq.finish(finding="Dexamethasone raised ZBTB16 and FKBP5 the most.",
|
|
47
|
+
genes=["ZBTB16+", "FKBP5+"]) # what it found; or let the script end: an exception ends it as errored
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## With an AI agent
|
|
51
|
+
|
|
52
|
+
An agent such as Claude Code can use baseqdb with your key. Ask it to begin each task with `baseqdb track` and end it
|
|
53
|
+
with `baseqdb finish`: everything it runs in that folder in between is one run, however many commands it takes.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
baseqdb track "Dexamethasone response in Airway"
|
|
57
|
+
python filter_counts.py --min-reads 10 # joins the run
|
|
58
|
+
python dge_dex.py --padj 0.01 # joins the run
|
|
59
|
+
baseqdb finish --finding "ZBTB16 and FKBP5 rose the most." --gene ZBTB16+ --gene FKBP5+
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If you are an agent: begin the task with `baseqdb track "<what the task is>"` in its folder, call `bq.track()` at the
|
|
63
|
+
top of each script you write, save results you keep with `bq.Record(path).save()`, and end with `baseqdb finish`,
|
|
64
|
+
saying in one short paragraph what the task found (`--finding`, with `--gene` for each gene; `--errored` when the task
|
|
65
|
+
failed).
|
|
66
|
+
|
|
67
|
+
Data you fetch with a key leaves baSeq. Our Data Processing Agreement does not cover what an AI agent or another
|
|
68
|
+
service then does with it.
|
|
69
|
+
|
|
70
|
+
Make an API key on your baSeq Account page, under API keys. Install pandas too for DataFrames:
|
|
71
|
+
`pip install "baseqdb[pandas]"`.
|
|
72
|
+
|
|
73
|
+
The whole guide, with R: https://baseq.bio/docs/python-and-r.html
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "baseqdb"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Your baSeq datasets, analyses and results from Python, with lamindb's names"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "baSeq ApS", email = "info@baseq.dk" }]
|
|
14
|
+
keywords = ["bioinformatics", "rna-seq", "single-cell", "lineage", "provenance", "baseq"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["httpx>=0.24"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
baseqdb = "baseqdb._cli:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
pandas = ["pandas>=1.5"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://baseq.bio"
|
|
32
|
+
Documentation = "https://baseq.bio/docs/python-and-r.html"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/baseqdb"]
|
|
@@ -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__"]
|
|
@@ -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())
|
|
@@ -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)
|
|
@@ -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 [])
|
|
@@ -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)
|