fairfetched 0.0.1.post20260304__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.
- fairfetched-0.0.1.post20260304/LICENSE +21 -0
- fairfetched-0.0.1.post20260304/PKG-INFO +48 -0
- fairfetched-0.0.1.post20260304/README.md +26 -0
- fairfetched-0.0.1.post20260304/fairfetched/__init__.py +0 -0
- fairfetched-0.0.1.post20260304/fairfetched/get/__init__.py +3 -0
- fairfetched-0.0.1.post20260304/fairfetched/get/api.py +127 -0
- fairfetched-0.0.1.post20260304/fairfetched/get/chembl.py +195 -0
- fairfetched-0.0.1.post20260304/fairfetched/get/papyrus.py +130 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/__init__.py +14 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/_optional.py +30 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/_track.py +31 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/ensure.py +30 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/files.py +30 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/polars.py +96 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/storage.py +16 -0
- fairfetched-0.0.1.post20260304/fairfetched/utils/typing.py +24 -0
- fairfetched-0.0.1.post20260304/fairfetched/vis.py +92 -0
- fairfetched-0.0.1.post20260304/fairfetched.egg-info/PKG-INFO +48 -0
- fairfetched-0.0.1.post20260304/fairfetched.egg-info/SOURCES.txt +46 -0
- fairfetched-0.0.1.post20260304/fairfetched.egg-info/dependency_links.txt +1 -0
- fairfetched-0.0.1.post20260304/fairfetched.egg-info/requires.txt +15 -0
- fairfetched-0.0.1.post20260304/fairfetched.egg-info/top_level.txt +4 -0
- fairfetched-0.0.1.post20260304/pyproject.toml +36 -0
- fairfetched-0.0.1.post20260304/scripts/chembl.py +7 -0
- fairfetched-0.0.1.post20260304/setup.cfg +4 -0
- fairfetched-0.0.1.post20260304/tests/__init__.py +1 -0
- fairfetched-0.0.1.post20260304/tests/test_ensure.py +148 -0
- fairfetched-0.0.1.post20260304/tests/test_integration_chembl.py +305 -0
- fairfetched-0.0.1.post20260304/tests/test_utils.py +128 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lucina-may nollen
|
|
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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fairfetched
|
|
3
|
+
Version: 0.0.1.post20260304
|
|
4
|
+
Summary: data APIs for reproducible data fetching in cheminformatics
|
|
5
|
+
Project-URL: Homepage, https://github.com/lucinamay/fairfetched
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: polars>=1.38.1
|
|
10
|
+
Requires-Dist: pystow>=0.7.28
|
|
11
|
+
Requires-Dist: rich>=14.3.3
|
|
12
|
+
Provides-Extra: rdkit
|
|
13
|
+
Requires-Dist: rdkit>=2025.9.6; extra == "rdkit"
|
|
14
|
+
Provides-Extra: standardise
|
|
15
|
+
Requires-Dist: fairfetched[rdkit]; extra == "standardise"
|
|
16
|
+
Requires-Dist: papyrus-structure-pipeline>=0.0.5; extra == "standardise"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.0.46; extra == "dev"
|
|
20
|
+
Requires-Dist: fairfetched[standardise]; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# fairfetched
|
|
24
|
+
data APIs for reproducible data fetching in cheminformatics in line with FAIR principles
|
|
25
|
+
|
|
26
|
+
# installation
|
|
27
|
+
you can install this package through
|
|
28
|
+
`uv add fairfetched` (recommended)
|
|
29
|
+
|
|
30
|
+
or if you do not use the uv package manager:
|
|
31
|
+
`pip install fairfetched`
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# roadmap
|
|
35
|
+
- [ ] papyrus database support
|
|
36
|
+
- [x] papyrus latest version download
|
|
37
|
+
- [x] simple nested filtering
|
|
38
|
+
- [ ] efficient nested filtering
|
|
39
|
+
- [ ] all-version support
|
|
40
|
+
- [ ] built-in pivots
|
|
41
|
+
- [ ] chembl database support
|
|
42
|
+
- [x] database to tables
|
|
43
|
+
- [ ] intuitive pre-merged flat files
|
|
44
|
+
- [ ] database visualisation
|
|
45
|
+
- [ ] reproducible chembl and papyrus standardisation
|
|
46
|
+
- [ ] automated time-url logging and manifest files
|
|
47
|
+
- [ ] minimisation of dependencies
|
|
48
|
+
- [ ] support for other databases
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# fairfetched
|
|
2
|
+
data APIs for reproducible data fetching in cheminformatics in line with FAIR principles
|
|
3
|
+
|
|
4
|
+
# installation
|
|
5
|
+
you can install this package through
|
|
6
|
+
`uv add fairfetched` (recommended)
|
|
7
|
+
|
|
8
|
+
or if you do not use the uv package manager:
|
|
9
|
+
`pip install fairfetched`
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# roadmap
|
|
13
|
+
- [ ] papyrus database support
|
|
14
|
+
- [x] papyrus latest version download
|
|
15
|
+
- [x] simple nested filtering
|
|
16
|
+
- [ ] efficient nested filtering
|
|
17
|
+
- [ ] all-version support
|
|
18
|
+
- [ ] built-in pivots
|
|
19
|
+
- [ ] chembl database support
|
|
20
|
+
- [x] database to tables
|
|
21
|
+
- [ ] intuitive pre-merged flat files
|
|
22
|
+
- [ ] database visualisation
|
|
23
|
+
- [ ] reproducible chembl and papyrus standardisation
|
|
24
|
+
- [ ] automated time-url logging and manifest files
|
|
25
|
+
- [ ] minimisation of dependencies
|
|
26
|
+
- [ ] support for other databases
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
from polars import LazyFrame
|
|
8
|
+
|
|
9
|
+
import fairfetched.get.chembl as chembl
|
|
10
|
+
import fairfetched.get.papyrus as papyrus
|
|
11
|
+
from fairfetched.utils import BASE_DIR
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class _Base:
|
|
16
|
+
version: str
|
|
17
|
+
raw_paths: dict[str, Path]
|
|
18
|
+
lfs: dict[str, LazyFrame]
|
|
19
|
+
dir: Path
|
|
20
|
+
module: ModuleType
|
|
21
|
+
|
|
22
|
+
def __hash__(self):
|
|
23
|
+
return hash(
|
|
24
|
+
(self.version, str(self.sources), str(self.raw_paths), self.module.__name__)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@cached_property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return self.module.__name__.split(".")[-1]
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def sources(self) -> dict[str, str]:
|
|
33
|
+
return self.module.get_sources(self.version)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def composed(self) -> dict[str, LazyFrame]:
|
|
37
|
+
lfs = self.module.compose(self.lfs)
|
|
38
|
+
return lfs
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def bioactivity(self) -> LazyFrame:
|
|
42
|
+
return self.composed["bioactivity"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def compounds(self) -> LazyFrame:
|
|
46
|
+
return self.composed["compounds"]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
@cached_property
|
|
50
|
+
def available_versions(cls) -> Tuple[str]:
|
|
51
|
+
return cls.module.available_versions()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Chembl(_Base):
|
|
56
|
+
module: ModuleType = chembl
|
|
57
|
+
extracted_table_paths: dict[str, Path] | None = None
|
|
58
|
+
|
|
59
|
+
@cached_property
|
|
60
|
+
def activity(self) -> LazyFrame:
|
|
61
|
+
return self.composed["activity"]
|
|
62
|
+
|
|
63
|
+
@cached_property
|
|
64
|
+
def raw_sql_db_path(self) -> Path:
|
|
65
|
+
return self.raw_paths["sql_db"]
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_version(
|
|
69
|
+
cls,
|
|
70
|
+
version: str | int | float,
|
|
71
|
+
root_dir: Path | str = f"{BASE_DIR}/chembl",
|
|
72
|
+
):
|
|
73
|
+
"""Downloads Chembl for version if not yet present in the given cache directory"""
|
|
74
|
+
version = chembl.__version_formatter(version)
|
|
75
|
+
dir = Path(root_dir) / version
|
|
76
|
+
|
|
77
|
+
raw_paths: dict[str, Path] = cls.module.ensure_raw(
|
|
78
|
+
version, cache_dir=dir / "raw"
|
|
79
|
+
)
|
|
80
|
+
extracted_table_paths = cls.module.extract_sqlite(
|
|
81
|
+
raw_paths["sql_db"], cache_dir=dir / "extracted"
|
|
82
|
+
)
|
|
83
|
+
lfs = cls.module.clean(extracted_table_paths)
|
|
84
|
+
return Chembl(
|
|
85
|
+
version=version,
|
|
86
|
+
raw_paths=raw_paths,
|
|
87
|
+
lfs=lfs,
|
|
88
|
+
dir=dir,
|
|
89
|
+
module=cls.module,
|
|
90
|
+
extracted_table_paths=extracted_table_paths,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_latest(cls, root_dir):
|
|
95
|
+
return cls.from_version(version=cls.module.latest(), root_dir=root_dir)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Papyrus(_Base):
|
|
100
|
+
module: ModuleType = papyrus
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_version(
|
|
104
|
+
cls,
|
|
105
|
+
version: str,
|
|
106
|
+
root_dir: Path | str = f"{BASE_DIR}/papyrus",
|
|
107
|
+
):
|
|
108
|
+
"""Downloads Chembl for version if not yet present in the given cache directory"""
|
|
109
|
+
dir = Path(root_dir) / version
|
|
110
|
+
raw_paths: dict[str, Path] = cls.module.ensure_raw(
|
|
111
|
+
version, cache_dir=dir / "raw"
|
|
112
|
+
)
|
|
113
|
+
lfs = cls.module.clean(raw_paths)
|
|
114
|
+
return Papyrus(
|
|
115
|
+
version=version,
|
|
116
|
+
raw_paths=raw_paths,
|
|
117
|
+
lfs=lfs,
|
|
118
|
+
dir=dir,
|
|
119
|
+
module=cls.module,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_latest(
|
|
124
|
+
cls,
|
|
125
|
+
root_dir: Path | str = f"{BASE_DIR}/papyrus",
|
|
126
|
+
):
|
|
127
|
+
return cls.from_version(version=cls.module.latest(), root_dir=root_dir)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
from fairfetched.utils import (
|
|
7
|
+
BASE_DIR,
|
|
8
|
+
ensure_url,
|
|
9
|
+
file_suffix_from_url,
|
|
10
|
+
lowercase_columns,
|
|
11
|
+
sqlite_db_to_parquets,
|
|
12
|
+
untar_sqlite,
|
|
13
|
+
)
|
|
14
|
+
from fairfetched.utils.typing import ComposedLFDict
|
|
15
|
+
|
|
16
|
+
CHEMBL_DIR = BASE_DIR / "chembl"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __version_formatter(version: int | float | str) -> str:
|
|
20
|
+
if isinstance(version, int | float):
|
|
21
|
+
version = str(version)
|
|
22
|
+
if not isinstance(version, str):
|
|
23
|
+
try:
|
|
24
|
+
version = str(version)
|
|
25
|
+
finally:
|
|
26
|
+
raise TypeError(f"invalid version type: {type(version)}")
|
|
27
|
+
|
|
28
|
+
version = version.lstrip("0")
|
|
29
|
+
# for canonicalize the version number 22.1 and 24.1 and left pad with a zero if needed
|
|
30
|
+
return version.replace(".", "_").zfill(2)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_to_url(version: str):
|
|
34
|
+
base = "https://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/releases"
|
|
35
|
+
return f"{base}/chembl_{version}/chembl_{version}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
CHEMBL_VERSIONS: dict[str, dict[str, str]] = {
|
|
39
|
+
version: {"sql_db": _version_to_url(version)}
|
|
40
|
+
for version in sorted(
|
|
41
|
+
map(__version_formatter, list(range(1, 37)) + ["24_1", "22_1"])
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def available_versions() -> tuple[str, ...]:
|
|
47
|
+
return tuple(CHEMBL_VERSIONS.keys())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def latest() -> str:
|
|
51
|
+
return available_versions()[-1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_sources(version: str) -> dict[str, str]:
|
|
55
|
+
return CHEMBL_VERSIONS[str(version)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_raw(version: str, cache_dir: Path | str | None = None) -> dict[str, Path]:
|
|
59
|
+
"""downloads the original sql database, with its original name and compression"""
|
|
60
|
+
if cache_dir is None:
|
|
61
|
+
cache_dir = CHEMBL_DIR / version
|
|
62
|
+
cache_dir = Path(cache_dir)
|
|
63
|
+
return {
|
|
64
|
+
name: ensure_url(url=url, path=cache_dir / f"{file_suffix_from_url(url)}")
|
|
65
|
+
for name, url in get_sources(version).items()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_sqlite(
|
|
70
|
+
sql_tar_gz_path: Path | str, cache_dir: Path | str | Any | None = None
|
|
71
|
+
) -> dict[str, Path]:
|
|
72
|
+
if cache_dir is None:
|
|
73
|
+
cache_dir = Path(sql_tar_gz_path).parent / "extracted"
|
|
74
|
+
|
|
75
|
+
raw_sql = untar_sqlite(sql_tar_gz_path)
|
|
76
|
+
parquets = sqlite_db_to_parquets(raw_sql, cache_dir=cache_dir)
|
|
77
|
+
return parquets
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clean(extracted_sqlite_parquet_paths: dict[str, Path]) -> dict[str, pl.LazyFrame]:
|
|
81
|
+
return {
|
|
82
|
+
name: pl.scan_parquet(path_)
|
|
83
|
+
.pipe(lowercase_columns)
|
|
84
|
+
.fill_nan(None)
|
|
85
|
+
.with_columns(
|
|
86
|
+
pl.col(pl.String).replace({"": None}),
|
|
87
|
+
)
|
|
88
|
+
for name, path_ in extracted_sqlite_parquet_paths.items()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compose(lfs: dict[str, pl.LazyFrame]) -> ComposedLFDict:
|
|
93
|
+
"""Join/combine lazy frames. Optional, returns single LF."""
|
|
94
|
+
return {
|
|
95
|
+
"bioactivity": _bioactivities(lfs),
|
|
96
|
+
"compounds": _compounds(lfs),
|
|
97
|
+
"proteins": lfs["protein"],
|
|
98
|
+
"components": _components(lfs),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _bioactivities(lfs) -> pl.LazyFrame:
|
|
103
|
+
return (
|
|
104
|
+
# dfs["activity_properties"]
|
|
105
|
+
# .join(dfs["activities"], on="activity_id", how="left")
|
|
106
|
+
# .join(dfs["action_type"], on="action_type", how="left")
|
|
107
|
+
# .join
|
|
108
|
+
lfs["bioactivity"]
|
|
109
|
+
.join(
|
|
110
|
+
lfs["protein"],
|
|
111
|
+
on="target_id",
|
|
112
|
+
how="left",
|
|
113
|
+
maintain_order="left",
|
|
114
|
+
validate="m:1", # one unique protein only from right, can reoccur within compounds.
|
|
115
|
+
)
|
|
116
|
+
.join(lfs["action_type"], on="action_type", how="left", suffix="_action_type")
|
|
117
|
+
.join(
|
|
118
|
+
lfs["assays"], on="assay_id", how="left", suffix="_assay"
|
|
119
|
+
) # doc_ids to this
|
|
120
|
+
.join(lfs["assay_type"], on="assay_type", how="left", suffix="_assay_type")
|
|
121
|
+
.join(lfs, on="bao_format")
|
|
122
|
+
# .join(dfs[])
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _components(lfs) -> pl.LazyFrame:
|
|
127
|
+
return (
|
|
128
|
+
lfs["component_sequences"]
|
|
129
|
+
.join(
|
|
130
|
+
lfs["component_class"],
|
|
131
|
+
on="component_id",
|
|
132
|
+
how="left",
|
|
133
|
+
suffix="_class",
|
|
134
|
+
validate="1:m",
|
|
135
|
+
)
|
|
136
|
+
.join(
|
|
137
|
+
lfs["component_domains"].join(
|
|
138
|
+
lfs["domains"],
|
|
139
|
+
on="domain_id",
|
|
140
|
+
how="left",
|
|
141
|
+
suffix="_domains",
|
|
142
|
+
validate="m:1",
|
|
143
|
+
),
|
|
144
|
+
on="component_id",
|
|
145
|
+
how="left",
|
|
146
|
+
suffix="_domains",
|
|
147
|
+
validate="1:m",
|
|
148
|
+
)
|
|
149
|
+
# .join(
|
|
150
|
+
# dfs["component_synonyms"],
|
|
151
|
+
# on="component_id",
|
|
152
|
+
# how="left",
|
|
153
|
+
# suffix="_synonyms",
|
|
154
|
+
# validate="m:m",
|
|
155
|
+
# )
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _compounds(lfs) -> pl.LazyFrame:
|
|
160
|
+
return (
|
|
161
|
+
lfs["molecule_dictionary"]
|
|
162
|
+
.join(
|
|
163
|
+
lfs["compound_properties"],
|
|
164
|
+
on="molregno",
|
|
165
|
+
how="left",
|
|
166
|
+
suffix="_compound_properties",
|
|
167
|
+
validate="1:1",
|
|
168
|
+
)
|
|
169
|
+
.join(
|
|
170
|
+
lfs["compound_structures"],
|
|
171
|
+
on="molregno",
|
|
172
|
+
how="left",
|
|
173
|
+
suffix="_compound_structures",
|
|
174
|
+
validate="1:1",
|
|
175
|
+
)
|
|
176
|
+
.join(
|
|
177
|
+
lfs["compound_records"].join(
|
|
178
|
+
lfs["docs"], on="doc_id", how="left", suffix="_doc", validate="m:1"
|
|
179
|
+
),
|
|
180
|
+
on="molregno",
|
|
181
|
+
how="left",
|
|
182
|
+
suffix="_compound_records",
|
|
183
|
+
validate="1:m",
|
|
184
|
+
)
|
|
185
|
+
.join(
|
|
186
|
+
lfs["compound_structural_alerts"],
|
|
187
|
+
# .join(
|
|
188
|
+
# dfs["docs"], on="doc_id", how="left", suffix="_doc",validate="m:1"
|
|
189
|
+
# )
|
|
190
|
+
on="molregno",
|
|
191
|
+
how="left",
|
|
192
|
+
suffix="_compound_structural_alerts",
|
|
193
|
+
validate="1:m",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Papyrus dataset utilities for downloading, cleaning, and joining bioactivity and protein data.
|
|
2
|
+
|
|
3
|
+
This module provides functions to ensure the presence of raw and cleaned Papyrus dataset files,
|
|
4
|
+
and defines the Papyrus_57 database configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
|
|
12
|
+
from fairfetched.utils import (
|
|
13
|
+
BASE_DIR,
|
|
14
|
+
ensure_url,
|
|
15
|
+
file_suffix_from_url,
|
|
16
|
+
lowercase_columns,
|
|
17
|
+
scan_tsvxz,
|
|
18
|
+
)
|
|
19
|
+
from fairfetched.utils.typing import ComposedLFDict
|
|
20
|
+
|
|
21
|
+
PAPYRUS_VERSIONS: dict[str, dict[str, str]] = {
|
|
22
|
+
"05.7": {
|
|
23
|
+
"bioactivity": "https://zenodo.org/records/13987985/files/05.7_combined_set_with_stereochemistry.tsv.xz?download=1",
|
|
24
|
+
# "bioactivity_nostereochemistry": "https://zenodo.org/records/13987985/files/05.7_combined_set_without_stereochemistry.tsv.xz?download=1",
|
|
25
|
+
"readme": "https://zenodo.org/records/13987985/files/README.txt?download=1",
|
|
26
|
+
"protein": "https://zenodo.org/records/13987985/files/05.7_combined_set_protein_targets.tsv.xz?download=1",
|
|
27
|
+
},
|
|
28
|
+
"05.6": {
|
|
29
|
+
"bioactivity": "https://zenodo.org/records/7373214/files/05.6_combined_set_with_stereochemistry.tsv.xz?download=1",
|
|
30
|
+
# "bioactivity_nostereochemistry": "https://zenodo.org/records/7373214/files/05.6_combined_set_without_stereochemistry.tsv.xz?download=1",
|
|
31
|
+
"readme": "https://zenodo.org/records/7373214/files/README.txt?download=1",
|
|
32
|
+
"protein": "https://zenodo.org/records/7373214/files/05.6_combined_set_protein_targets.tsv.xz?download=1",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def available_versions() -> tuple[str, ...]:
|
|
38
|
+
return tuple(PAPYRUS_VERSIONS.keys())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def latest() -> str:
|
|
42
|
+
return available_versions()[-1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_sources(version: str) -> dict[str, str]:
|
|
46
|
+
return PAPYRUS_VERSIONS[str(version)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ensure_raw(
|
|
50
|
+
version: str, cache_dir: Path | str | Any | None = None
|
|
51
|
+
) -> dict[str, Path]:
|
|
52
|
+
"""Download if missing, return path to raw file."""
|
|
53
|
+
if cache_dir is None:
|
|
54
|
+
cache_dir = BASE_DIR / "papyrus" / version
|
|
55
|
+
cache_dir = Path(cache_dir)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: ensure_url(url=url, path=cache_dir / f"{file_suffix_from_url(url)}")
|
|
59
|
+
for name, url in get_sources(version).items()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clean(raw_filepath_dict: dict[str, Path]) -> dict[str, pl.LazyFrame]:
|
|
64
|
+
"""Transform raw → clean, return lazy frames. No I/O."""
|
|
65
|
+
schema_overrides = {
|
|
66
|
+
"Year": pl.Int32,
|
|
67
|
+
"pchembl_value_Mean": pl.Float64,
|
|
68
|
+
"pchembl_value_StdDev": pl.Float64,
|
|
69
|
+
"pchembl_value_SEM": pl.Float64,
|
|
70
|
+
"pchembl_value_N": pl.Float64,
|
|
71
|
+
"pchembl_value_Median": pl.Float64,
|
|
72
|
+
"pchembl_value_MAD": pl.Float64,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"protein": (
|
|
77
|
+
scan_tsvxz(raw_filepath_dict["protein"], separator="\t")
|
|
78
|
+
.pipe(lowercase_columns)
|
|
79
|
+
.rename({"uniprotid": "uniprot_id"})
|
|
80
|
+
),
|
|
81
|
+
"bioactivity": (
|
|
82
|
+
scan_tsvxz(
|
|
83
|
+
raw_filepath_dict["bioactivity"],
|
|
84
|
+
separator="\t",
|
|
85
|
+
infer_schema=False,
|
|
86
|
+
schema_overrides=schema_overrides,
|
|
87
|
+
null_values="NA", # which values to take as None
|
|
88
|
+
)
|
|
89
|
+
.pipe(lowercase_columns)
|
|
90
|
+
.cast({"pchembl_value_n": pl.Int64})
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compose(lfs: dict[str, pl.LazyFrame]) -> ComposedLFDict:
|
|
96
|
+
"""Join/combine lazy frames. Optional, returns single LF."""
|
|
97
|
+
return {
|
|
98
|
+
"bioactivity": lfs["bioactivity"].join(
|
|
99
|
+
lfs["protein"],
|
|
100
|
+
on="target_id",
|
|
101
|
+
how="left",
|
|
102
|
+
maintain_order="left",
|
|
103
|
+
validate="m:1", # one unique protein only from right, can reoccur within compounds.
|
|
104
|
+
),
|
|
105
|
+
"compounds": lfs["bioactivity"]
|
|
106
|
+
.drop(
|
|
107
|
+
"activity_id",
|
|
108
|
+
)
|
|
109
|
+
.unique(("connectivity", "inchikey", "inchi")),
|
|
110
|
+
"full": lfs["bioactivity"].join(
|
|
111
|
+
lfs["protein"],
|
|
112
|
+
on="target_id",
|
|
113
|
+
how="left",
|
|
114
|
+
maintain_order="left",
|
|
115
|
+
validate="m:1", # one unique protein only from right, can reoccur within compounds.
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def help() -> None:
|
|
121
|
+
"""prints out example usage"""
|
|
122
|
+
print("""
|
|
123
|
+
Example usage:
|
|
124
|
+
```
|
|
125
|
+
raw = ensure_raw("05.7")
|
|
126
|
+
lfs = clean(raw)
|
|
127
|
+
final_lf = compose(lfs)
|
|
128
|
+
final_lf.sink_parquet("output.parquet")
|
|
129
|
+
```
|
|
130
|
+
""")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .ensure import ensure_url
|
|
2
|
+
from .files import file_suffix_from_url, untar_sqlite
|
|
3
|
+
from .polars import lowercase_columns, scan_tsvxz, sqlite_db_to_parquets
|
|
4
|
+
from .storage import BASE_DIR
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BASE_DIR",
|
|
8
|
+
ensure_url,
|
|
9
|
+
lowercase_columns,
|
|
10
|
+
scan_tsvxz,
|
|
11
|
+
file_suffix_from_url,
|
|
12
|
+
sqlite_db_to_parquets,
|
|
13
|
+
untar_sqlite,
|
|
14
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from tqdm import ( # ty: ignore[unresolved-import,unused-ignore-comment] # ty: ignore[unused-ignore-comment]
|
|
3
|
+
tqdm as tqdm_track,
|
|
4
|
+
)
|
|
5
|
+
except ImportError:
|
|
6
|
+
|
|
7
|
+
def tqdm_track(iterable, *args, **kwargs):
|
|
8
|
+
return iter(iterable)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from rich.progress import ( # ty: ignore[unresolved-import,unused-ignore-comment] # ty: ignore[unused-ignore-comment]
|
|
13
|
+
track as rich_track,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
except ImportError:
|
|
17
|
+
|
|
18
|
+
def rich_track(iterable, *args, **kwargs):
|
|
19
|
+
return iter(iterable)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from marimo import ( # ty: ignore[unresolved-import,unused-ignore-comment] # ty: ignore[unused-ignore-comment]
|
|
24
|
+
running_in_notebook as in_marimo,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
except ImportError:
|
|
28
|
+
|
|
29
|
+
def in_marimo():
|
|
30
|
+
return False
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Iterable
|
|
2
|
+
|
|
3
|
+
from _optional import in_marimo, rich_track, tqdm_track
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def track(iterable: Iterable, desc: str = "", total: int | None = None):
|
|
7
|
+
"""Progress bar that adapts to the environment (marimo or terminal)."""
|
|
8
|
+
if not total:
|
|
9
|
+
try:
|
|
10
|
+
total = len(iterable) # ty: ignore[invalid-argument-type]
|
|
11
|
+
except TypeError:
|
|
12
|
+
pass
|
|
13
|
+
if in_marimo():
|
|
14
|
+
# Use tqdm with thin bar characters for a slimmer look
|
|
15
|
+
return tqdm_track(
|
|
16
|
+
iterable,
|
|
17
|
+
desc=desc,
|
|
18
|
+
total=total,
|
|
19
|
+
bar_format="{desc} {bar:20} {percentage:3.0f}% {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]",
|
|
20
|
+
ascii="─━",
|
|
21
|
+
leave=True,
|
|
22
|
+
)
|
|
23
|
+
return rich_track(
|
|
24
|
+
iterable,
|
|
25
|
+
description=desc,
|
|
26
|
+
total=total,
|
|
27
|
+
transient=False,
|
|
28
|
+
style="green",
|
|
29
|
+
complete_style="bold green",
|
|
30
|
+
console=None,
|
|
31
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging as lg
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ._track import track
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_url(url: str, path: Path, force: bool = False) -> Path:
|
|
11
|
+
"""Downloads url to path if not already existing. Makes path dirs if not existing"""
|
|
12
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
if path.exists() and not force:
|
|
15
|
+
lg.debug(f"File already exists at {path}. Skipping download.")
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
response = requests.get(url)
|
|
19
|
+
response.raise_for_status()
|
|
20
|
+
with open(path, "wb") as f:
|
|
21
|
+
total = int(response.headers.get("content-length", 0))
|
|
22
|
+
chunk_size = 8192
|
|
23
|
+
for chunk in track(
|
|
24
|
+
response.iter_content(chunk_size=chunk_size),
|
|
25
|
+
total=(total // chunk_size) + int(total % chunk_size != 0),
|
|
26
|
+
desc=f"downloading {url.split('/')[-1]}",
|
|
27
|
+
):
|
|
28
|
+
f.write(chunk)
|
|
29
|
+
lg.info(f"Downloaded {url} to {path} on {datetime.datetime.now()}")
|
|
30
|
+
return path
|