replicatescience 0.1.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.
- replicatescience-0.1.0/.gitignore +15 -0
- replicatescience-0.1.0/CLAUDE.md +36 -0
- replicatescience-0.1.0/LICENSE +21 -0
- replicatescience-0.1.0/PKG-INFO +96 -0
- replicatescience-0.1.0/README.md +66 -0
- replicatescience-0.1.0/pyproject.toml +43 -0
- replicatescience-0.1.0/src/replicatescience/__init__.py +135 -0
- replicatescience-0.1.0/src/replicatescience/cli.py +149 -0
- replicatescience-0.1.0/src/replicatescience/client.py +223 -0
- replicatescience-0.1.0/src/replicatescience/export.py +42 -0
- replicatescience-0.1.0/src/replicatescience/models.py +483 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ReplicateScience Python SDK
|
|
2
|
+
|
|
3
|
+
## What This Is
|
|
4
|
+
Python SDK + CLI for the ReplicateScience API. Wraps https://replicatescience.com/api/v1/ endpoints. Published to PyPI as `replicatescience`.
|
|
5
|
+
|
|
6
|
+
**PyPI:** https://pypi.org/project/replicatescience/
|
|
7
|
+
**GitHub:** https://github.com/ShuhanCS/replicatescience-python
|
|
8
|
+
**API docs:** https://replicatescience.com/developers
|
|
9
|
+
|
|
10
|
+
## Structure
|
|
11
|
+
- `src/replicatescience/` — package source (src layout)
|
|
12
|
+
- `src/replicatescience/__init__.py` — top-level convenience functions (search, get, diff, save, load)
|
|
13
|
+
- `src/replicatescience/client.py` — HTTP client using httpx
|
|
14
|
+
- `src/replicatescience/models.py` — dataclass models matching API types
|
|
15
|
+
- `src/replicatescience/export.py` — YAML/JSON file export/import
|
|
16
|
+
- `src/replicatescience/cli.py` — Click CLI (`rs` command)
|
|
17
|
+
|
|
18
|
+
## Key Decisions
|
|
19
|
+
- Models match `src/types/api.ts` from the main replicatescience repo (snake_case)
|
|
20
|
+
- Uses httpx (not requests) for modern async-ready HTTP
|
|
21
|
+
- src layout for clean packaging
|
|
22
|
+
- Hatchling build backend (fast, modern)
|
|
23
|
+
- CLI entry point: `rs` (registered in pyproject.toml)
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e ".[dev]" # editable install
|
|
28
|
+
python -m pytest tests/ # run tests
|
|
29
|
+
python -m build # build dist
|
|
30
|
+
twine upload dist/* # publish to PyPI
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Rules
|
|
34
|
+
- Version in both `pyproject.toml` and `__init__.py` — keep in sync
|
|
35
|
+
- Models must stay in sync with the main replicatescience API types
|
|
36
|
+
- Never store or log API keys
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ConductScience Inc.
|
|
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,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replicatescience
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for ReplicateScience — programmable protocol library for reproducible science
|
|
5
|
+
Project-URL: Homepage, https://replicatescience.com
|
|
6
|
+
Project-URL: Documentation, https://replicatescience.com/developers
|
|
7
|
+
Project-URL: Repository, https://github.com/ShuhanCS/replicatescience-python
|
|
8
|
+
Project-URL: Issues, https://github.com/ShuhanCS/replicatescience-python/issues
|
|
9
|
+
Author-email: ReplicateScience <support@replicatescience.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: biology,equipment,protocols,reproducibility,science
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: httpx>=0.25.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# ReplicateScience Python SDK
|
|
32
|
+
|
|
33
|
+
Programmable protocol library for reproducible science. Search, compare, and export experimental protocols extracted from published papers.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install replicatescience
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import replicatescience as rs
|
|
45
|
+
|
|
46
|
+
# Configure your API key (or set RS_API_KEY env var)
|
|
47
|
+
rs.configure(api_key="rs_live_YOUR_KEY")
|
|
48
|
+
|
|
49
|
+
# Search protocols by keyword + species
|
|
50
|
+
results = rs.search("fear conditioning", species="mouse")
|
|
51
|
+
for p in results.protocols:
|
|
52
|
+
print(f"{p.slug}: {p.name} ({p.step_count} steps)")
|
|
53
|
+
|
|
54
|
+
# Get full protocol detail
|
|
55
|
+
protocol = rs.get("smith-fear-conditioning-2024")
|
|
56
|
+
|
|
57
|
+
# Compare two protocols
|
|
58
|
+
diff = rs.diff(
|
|
59
|
+
rs.get("smith-fear-conditioning-2024"),
|
|
60
|
+
rs.get("jones-fear-conditioning-2023"),
|
|
61
|
+
)
|
|
62
|
+
print(diff.summary)
|
|
63
|
+
print(diff.to_markdown())
|
|
64
|
+
|
|
65
|
+
# Export to YAML
|
|
66
|
+
rs.save(protocol, "protocols/fear-conditioning.yaml")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CLI
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Search from terminal
|
|
73
|
+
rs search "pcr" --species mouse --limit 5
|
|
74
|
+
|
|
75
|
+
# Get a protocol
|
|
76
|
+
rs get smith-fear-conditioning-2024 --format yaml > protocol.yaml
|
|
77
|
+
|
|
78
|
+
# Diff two protocols
|
|
79
|
+
rs diff smith-2024 jones-2023
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API Key
|
|
83
|
+
|
|
84
|
+
Get your free API key at [replicatescience.com/developers](https://replicatescience.com/developers).
|
|
85
|
+
|
|
86
|
+
| Plan | Requests/Day | Exports/Day |
|
|
87
|
+
|------|-------------|-------------|
|
|
88
|
+
| Free | 100 | 10 |
|
|
89
|
+
| Pro | 5,000 | Unlimited |
|
|
90
|
+
| Institutional | 50,000 | Unlimited |
|
|
91
|
+
|
|
92
|
+
## Links
|
|
93
|
+
|
|
94
|
+
- [Documentation](https://replicatescience.com/developers)
|
|
95
|
+
- [API Reference (OpenAPI)](https://replicatescience.com/api/v1/openapi.json)
|
|
96
|
+
- [GitHub](https://github.com/ShuhanCS/replicatescience-python)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ReplicateScience Python SDK
|
|
2
|
+
|
|
3
|
+
Programmable protocol library for reproducible science. Search, compare, and export experimental protocols extracted from published papers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install replicatescience
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import replicatescience as rs
|
|
15
|
+
|
|
16
|
+
# Configure your API key (or set RS_API_KEY env var)
|
|
17
|
+
rs.configure(api_key="rs_live_YOUR_KEY")
|
|
18
|
+
|
|
19
|
+
# Search protocols by keyword + species
|
|
20
|
+
results = rs.search("fear conditioning", species="mouse")
|
|
21
|
+
for p in results.protocols:
|
|
22
|
+
print(f"{p.slug}: {p.name} ({p.step_count} steps)")
|
|
23
|
+
|
|
24
|
+
# Get full protocol detail
|
|
25
|
+
protocol = rs.get("smith-fear-conditioning-2024")
|
|
26
|
+
|
|
27
|
+
# Compare two protocols
|
|
28
|
+
diff = rs.diff(
|
|
29
|
+
rs.get("smith-fear-conditioning-2024"),
|
|
30
|
+
rs.get("jones-fear-conditioning-2023"),
|
|
31
|
+
)
|
|
32
|
+
print(diff.summary)
|
|
33
|
+
print(diff.to_markdown())
|
|
34
|
+
|
|
35
|
+
# Export to YAML
|
|
36
|
+
rs.save(protocol, "protocols/fear-conditioning.yaml")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CLI
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Search from terminal
|
|
43
|
+
rs search "pcr" --species mouse --limit 5
|
|
44
|
+
|
|
45
|
+
# Get a protocol
|
|
46
|
+
rs get smith-fear-conditioning-2024 --format yaml > protocol.yaml
|
|
47
|
+
|
|
48
|
+
# Diff two protocols
|
|
49
|
+
rs diff smith-2024 jones-2023
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API Key
|
|
53
|
+
|
|
54
|
+
Get your free API key at [replicatescience.com/developers](https://replicatescience.com/developers).
|
|
55
|
+
|
|
56
|
+
| Plan | Requests/Day | Exports/Day |
|
|
57
|
+
|------|-------------|-------------|
|
|
58
|
+
| Free | 100 | 10 |
|
|
59
|
+
| Pro | 5,000 | Unlimited |
|
|
60
|
+
| Institutional | 50,000 | Unlimited |
|
|
61
|
+
|
|
62
|
+
## Links
|
|
63
|
+
|
|
64
|
+
- [Documentation](https://replicatescience.com/developers)
|
|
65
|
+
- [API Reference (OpenAPI)](https://replicatescience.com/api/v1/openapi.json)
|
|
66
|
+
- [GitHub](https://github.com/ShuhanCS/replicatescience-python)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "replicatescience"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for ReplicateScience — programmable protocol library for reproducible science"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "ReplicateScience", email = "support@replicatescience.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["science", "protocols", "reproducibility", "biology", "equipment"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Scientific/Engineering",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"httpx>=0.25.0",
|
|
32
|
+
"click>=8.0",
|
|
33
|
+
"pyyaml>=6.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://replicatescience.com"
|
|
38
|
+
Documentation = "https://replicatescience.com/developers"
|
|
39
|
+
Repository = "https://github.com/ShuhanCS/replicatescience-python"
|
|
40
|
+
Issues = "https://github.com/ShuhanCS/replicatescience-python/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
rs = "replicatescience.cli:main"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""ReplicateScience Python SDK — programmable protocol library for reproducible science."""
|
|
2
|
+
|
|
3
|
+
from replicatescience.client import Client
|
|
4
|
+
from replicatescience.models import (
|
|
5
|
+
Equipment,
|
|
6
|
+
EquipmentDetail,
|
|
7
|
+
Pagination,
|
|
8
|
+
Protocol,
|
|
9
|
+
ProtocolDetail,
|
|
10
|
+
ProtocolDiff,
|
|
11
|
+
RateLimit,
|
|
12
|
+
SearchResult,
|
|
13
|
+
SearchResults,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"configure",
|
|
19
|
+
"search",
|
|
20
|
+
"get",
|
|
21
|
+
"diff",
|
|
22
|
+
"save",
|
|
23
|
+
"load",
|
|
24
|
+
"get_equipment",
|
|
25
|
+
"search_equipment",
|
|
26
|
+
"me",
|
|
27
|
+
"Client",
|
|
28
|
+
"Protocol",
|
|
29
|
+
"ProtocolDetail",
|
|
30
|
+
"ProtocolDiff",
|
|
31
|
+
"Equipment",
|
|
32
|
+
"EquipmentDetail",
|
|
33
|
+
"SearchResult",
|
|
34
|
+
"SearchResults",
|
|
35
|
+
"Pagination",
|
|
36
|
+
"RateLimit",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_default_client: Client | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_client() -> Client:
|
|
43
|
+
global _default_client
|
|
44
|
+
if _default_client is None:
|
|
45
|
+
_default_client = Client()
|
|
46
|
+
return _default_client
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configure(
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
base_url: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Configure the default client.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
api_key: Your ReplicateScience API key (rs_live_...).
|
|
57
|
+
Falls back to RS_API_KEY environment variable.
|
|
58
|
+
base_url: API base URL. Defaults to https://replicatescience.com.
|
|
59
|
+
"""
|
|
60
|
+
global _default_client
|
|
61
|
+
_default_client = Client(api_key=api_key, base_url=base_url)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def search(
|
|
65
|
+
query: str,
|
|
66
|
+
*,
|
|
67
|
+
species: str | None = None,
|
|
68
|
+
strain: str | None = None,
|
|
69
|
+
type: str | None = None,
|
|
70
|
+
year_min: int | None = None,
|
|
71
|
+
year_max: int | None = None,
|
|
72
|
+
sort: str | None = None,
|
|
73
|
+
page: int = 1,
|
|
74
|
+
limit: int = 20,
|
|
75
|
+
) -> SearchResults:
|
|
76
|
+
"""Search protocols by keyword and filters."""
|
|
77
|
+
return _get_client().search_protocols(
|
|
78
|
+
query,
|
|
79
|
+
species=species,
|
|
80
|
+
strain=strain,
|
|
81
|
+
type=type,
|
|
82
|
+
year_min=year_min,
|
|
83
|
+
year_max=year_max,
|
|
84
|
+
sort=sort,
|
|
85
|
+
page=page,
|
|
86
|
+
limit=limit,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get(slug: str) -> ProtocolDetail:
|
|
91
|
+
"""Get full protocol detail by slug."""
|
|
92
|
+
return _get_client().get_protocol(slug)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def diff(a: ProtocolDetail, b: ProtocolDetail) -> ProtocolDiff:
|
|
96
|
+
"""Compare two protocols and return their differences."""
|
|
97
|
+
return ProtocolDiff.compute(a, b)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def save(protocol: ProtocolDetail, path: str) -> None:
|
|
101
|
+
"""Export a protocol to a YAML or JSON file."""
|
|
102
|
+
from replicatescience.export import save_protocol
|
|
103
|
+
|
|
104
|
+
save_protocol(protocol, path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load(path: str) -> ProtocolDetail:
|
|
108
|
+
"""Load a protocol from a YAML or JSON file."""
|
|
109
|
+
from replicatescience.export import load_protocol
|
|
110
|
+
|
|
111
|
+
return load_protocol(path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_equipment(slug: str) -> EquipmentDetail:
|
|
115
|
+
"""Get full equipment detail by slug."""
|
|
116
|
+
return _get_client().get_equipment(slug)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def search_equipment(
|
|
120
|
+
query: str | None = None,
|
|
121
|
+
*,
|
|
122
|
+
category: str | None = None,
|
|
123
|
+
manufacturer: str | None = None,
|
|
124
|
+
page: int = 1,
|
|
125
|
+
limit: int = 20,
|
|
126
|
+
) -> SearchResults:
|
|
127
|
+
"""Search equipment."""
|
|
128
|
+
return _get_client().search_equipment(
|
|
129
|
+
query, category=category, manufacturer=manufacturer, page=page, limit=limit
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def me() -> dict:
|
|
134
|
+
"""Get API key info and rate limits."""
|
|
135
|
+
return _get_client().me()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""CLI interface for ReplicateScience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
import replicatescience as rs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option(rs.__version__, prog_name="replicatescience")
|
|
16
|
+
@click.option("--api-key", envvar="RS_API_KEY", help="API key (or set RS_API_KEY)")
|
|
17
|
+
def main(api_key: str | None):
|
|
18
|
+
"""ReplicateScience CLI — search, compare, and export scientific protocols."""
|
|
19
|
+
if api_key:
|
|
20
|
+
rs.configure(api_key=api_key)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@main.command()
|
|
24
|
+
@click.argument("query")
|
|
25
|
+
@click.option("--species", help="Filter by species")
|
|
26
|
+
@click.option("--strain", help="Filter by strain")
|
|
27
|
+
@click.option("--type", "exp_type", help="Filter by experiment type")
|
|
28
|
+
@click.option("--limit", default=20, help="Max results", show_default=True)
|
|
29
|
+
@click.option("--page", default=1, help="Page number", show_default=True)
|
|
30
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
31
|
+
def search(
|
|
32
|
+
query: str,
|
|
33
|
+
species: str | None,
|
|
34
|
+
strain: str | None,
|
|
35
|
+
exp_type: str | None,
|
|
36
|
+
limit: int,
|
|
37
|
+
page: int,
|
|
38
|
+
as_json: bool,
|
|
39
|
+
):
|
|
40
|
+
"""Search protocols by keyword."""
|
|
41
|
+
results = rs.search(
|
|
42
|
+
query, species=species, strain=strain, type=exp_type, limit=limit, page=page
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if as_json:
|
|
46
|
+
click.echo(json.dumps([p.to_dict() for p in results.protocols], indent=2))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not results.protocols:
|
|
50
|
+
click.echo("No protocols found.")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
for p in results.protocols:
|
|
54
|
+
click.echo(f" {p.slug}")
|
|
55
|
+
click.echo(f" {p.name}")
|
|
56
|
+
meta = []
|
|
57
|
+
if p.species:
|
|
58
|
+
meta.append(p.species)
|
|
59
|
+
if p.strain:
|
|
60
|
+
meta.append(p.strain)
|
|
61
|
+
meta.append(f"{p.step_count} steps")
|
|
62
|
+
meta.append(f"{p.equipment_count} equipment")
|
|
63
|
+
click.echo(f" {' | '.join(meta)}")
|
|
64
|
+
click.echo()
|
|
65
|
+
|
|
66
|
+
if results.pagination:
|
|
67
|
+
pg = results.pagination
|
|
68
|
+
click.echo(f"Page {pg.page} of {(pg.total + pg.limit - 1) // pg.limit} ({pg.total} total)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@main.command("get")
|
|
72
|
+
@click.argument("slug")
|
|
73
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json", "yaml"]), default="text", show_default=True)
|
|
74
|
+
def get_protocol(slug: str, fmt: str):
|
|
75
|
+
"""Get full protocol detail by slug."""
|
|
76
|
+
protocol = rs.get(slug)
|
|
77
|
+
|
|
78
|
+
if fmt == "json":
|
|
79
|
+
click.echo(json.dumps(protocol.to_dict(), indent=2))
|
|
80
|
+
elif fmt == "yaml":
|
|
81
|
+
click.echo(yaml.dump(protocol.to_dict(), default_flow_style=False, sort_keys=False))
|
|
82
|
+
else:
|
|
83
|
+
click.echo(f"# {protocol.name}")
|
|
84
|
+
click.echo(f"Paper: {protocol.paper.title}")
|
|
85
|
+
if protocol.paper.doi:
|
|
86
|
+
click.echo(f"DOI: {protocol.paper.doi}")
|
|
87
|
+
click.echo(f"Species: {protocol.species or 'N/A'} | Strain: {protocol.strain or 'N/A'}")
|
|
88
|
+
click.echo(f"Tags: {', '.join(protocol.tags) if protocol.tags else 'None'}")
|
|
89
|
+
click.echo()
|
|
90
|
+
|
|
91
|
+
if protocol.steps:
|
|
92
|
+
click.echo("## Steps")
|
|
93
|
+
for step in protocol.steps:
|
|
94
|
+
title = f" — {step.title}" if step.title else ""
|
|
95
|
+
duration = f" ({step.duration})" if step.duration else ""
|
|
96
|
+
click.echo(f" {step.number}. {step.description[:100]}{title}{duration}")
|
|
97
|
+
click.echo()
|
|
98
|
+
|
|
99
|
+
if protocol.equipment:
|
|
100
|
+
click.echo("## Equipment")
|
|
101
|
+
for eq in protocol.equipment:
|
|
102
|
+
mfr = f" ({eq.manufacturer})" if eq.manufacturer else ""
|
|
103
|
+
click.echo(f" - {eq.name}{mfr}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@main.command()
|
|
107
|
+
@click.argument("slug_a")
|
|
108
|
+
@click.argument("slug_b")
|
|
109
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
110
|
+
def diff(slug_a: str, slug_b: str, as_json: bool):
|
|
111
|
+
"""Compare two protocols."""
|
|
112
|
+
# Load from file or API
|
|
113
|
+
a = _load_or_fetch(slug_a)
|
|
114
|
+
b = _load_or_fetch(slug_b)
|
|
115
|
+
|
|
116
|
+
result = rs.diff(a, b)
|
|
117
|
+
|
|
118
|
+
if as_json:
|
|
119
|
+
out = {
|
|
120
|
+
"protocol_a": result.protocol_a,
|
|
121
|
+
"protocol_b": result.protocol_b,
|
|
122
|
+
"summary": result.summary,
|
|
123
|
+
"changes": [
|
|
124
|
+
{"field": c.field, "label": c.label, "old": c.old, "new": c.new}
|
|
125
|
+
for c in result.changes
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
click.echo(json.dumps(out, indent=2))
|
|
129
|
+
else:
|
|
130
|
+
click.echo(result.to_markdown())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_or_fetch(slug_or_path: str) -> rs.ProtocolDetail:
|
|
134
|
+
"""Load a protocol from a local file or fetch by slug."""
|
|
135
|
+
if slug_or_path.endswith((".yaml", ".yml", ".json")):
|
|
136
|
+
return rs.load(slug_or_path)
|
|
137
|
+
return rs.get(slug_or_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@main.command()
|
|
141
|
+
def whoami():
|
|
142
|
+
"""Show API key info and rate limits."""
|
|
143
|
+
info = rs.me()
|
|
144
|
+
click.echo(f"Email: {info.get('email', 'N/A')}")
|
|
145
|
+
click.echo(f"Name: {info.get('name', 'N/A')}")
|
|
146
|
+
click.echo(f"Tier: {info.get('tier', 'N/A')}")
|
|
147
|
+
rl = info.get("rate_limit", {})
|
|
148
|
+
click.echo(f"Rate limit: {rl.get('remaining', '?')}/{rl.get('limit', '?')} remaining")
|
|
149
|
+
click.echo(f"Resets at: {rl.get('resets_at', 'N/A')}")
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""HTTP client for the ReplicateScience API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from replicatescience.models import (
|
|
10
|
+
Equipment,
|
|
11
|
+
EquipmentDetail,
|
|
12
|
+
Pagination,
|
|
13
|
+
Protocol,
|
|
14
|
+
ProtocolDetail,
|
|
15
|
+
RateLimit,
|
|
16
|
+
SearchResult,
|
|
17
|
+
SearchResults,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://replicatescience.com"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReplicateScienceError(Exception):
|
|
24
|
+
"""Base exception for ReplicateScience API errors."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(ReplicateScienceError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(ReplicateScienceError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitError(ReplicateScienceError):
|
|
40
|
+
def __init__(self, message: str, resets_at: str | None = None):
|
|
41
|
+
super().__init__(message, status_code=429)
|
|
42
|
+
self.resets_at = resets_at
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Client:
|
|
46
|
+
"""ReplicateScience API client."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
base_url: str | None = None,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
):
|
|
54
|
+
self.api_key = api_key or os.environ.get("RS_API_KEY", "")
|
|
55
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
56
|
+
self._http = httpx.Client(
|
|
57
|
+
base_url=self.base_url,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
headers=self._headers(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _headers(self) -> dict[str, str]:
|
|
63
|
+
h: dict[str, str] = {"User-Agent": "replicatescience-python/0.1.0"}
|
|
64
|
+
if self.api_key:
|
|
65
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
66
|
+
return h
|
|
67
|
+
|
|
68
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
69
|
+
resp = self._http.request(method, path, **kwargs)
|
|
70
|
+
if resp.status_code == 401:
|
|
71
|
+
raise AuthenticationError(
|
|
72
|
+
"Missing or invalid API key. Set RS_API_KEY or call rs.configure(api_key=...).",
|
|
73
|
+
status_code=401,
|
|
74
|
+
)
|
|
75
|
+
if resp.status_code == 404:
|
|
76
|
+
raise NotFoundError(
|
|
77
|
+
f"Resource not found: {path}",
|
|
78
|
+
status_code=404,
|
|
79
|
+
)
|
|
80
|
+
if resp.status_code == 429:
|
|
81
|
+
body = resp.json()
|
|
82
|
+
raise RateLimitError(
|
|
83
|
+
body.get("error", "Rate limit exceeded."),
|
|
84
|
+
resets_at=resp.headers.get("X-RateLimit-Reset"),
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
90
|
+
return self._request("GET", path, params=params)
|
|
91
|
+
|
|
92
|
+
def search_protocols(
|
|
93
|
+
self,
|
|
94
|
+
query: str,
|
|
95
|
+
*,
|
|
96
|
+
species: str | None = None,
|
|
97
|
+
strain: str | None = None,
|
|
98
|
+
type: str | None = None,
|
|
99
|
+
year_min: int | None = None,
|
|
100
|
+
year_max: int | None = None,
|
|
101
|
+
sort: str | None = None,
|
|
102
|
+
page: int = 1,
|
|
103
|
+
limit: int = 20,
|
|
104
|
+
) -> SearchResults:
|
|
105
|
+
params: dict = {"q": query, "page": page, "limit": limit}
|
|
106
|
+
if species:
|
|
107
|
+
params["species"] = species
|
|
108
|
+
if strain:
|
|
109
|
+
params["strain"] = strain
|
|
110
|
+
if type:
|
|
111
|
+
params["type"] = type
|
|
112
|
+
if year_min is not None:
|
|
113
|
+
params["year_min"] = year_min
|
|
114
|
+
if year_max is not None:
|
|
115
|
+
params["year_max"] = year_max
|
|
116
|
+
if sort:
|
|
117
|
+
params["sort"] = sort
|
|
118
|
+
|
|
119
|
+
data = self._get("/api/v1/protocols", params=params)
|
|
120
|
+
protocols = [Protocol.from_dict(p) for p in data.get("data", [])]
|
|
121
|
+
pagination = (
|
|
122
|
+
Pagination.from_dict(data["pagination"]) if "pagination" in data else None
|
|
123
|
+
)
|
|
124
|
+
rate_limit = (
|
|
125
|
+
RateLimit.from_dict(data["rate_limit"]) if "rate_limit" in data else None
|
|
126
|
+
)
|
|
127
|
+
return SearchResults(
|
|
128
|
+
protocols=protocols,
|
|
129
|
+
equipment=[],
|
|
130
|
+
results=[],
|
|
131
|
+
pagination=pagination,
|
|
132
|
+
rate_limit=rate_limit,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def get_protocol(self, slug: str) -> ProtocolDetail:
|
|
136
|
+
data = self._get(f"/api/v1/protocols/{slug}")
|
|
137
|
+
return ProtocolDetail.from_dict(data["data"])
|
|
138
|
+
|
|
139
|
+
def get_protocol_equipment(self, slug: str) -> list[Equipment]:
|
|
140
|
+
data = self._get(f"/api/v1/protocols/{slug}/equipment")
|
|
141
|
+
return [Equipment.from_dict(e) for e in data.get("data", [])]
|
|
142
|
+
|
|
143
|
+
def export_protocol(self, slug: str, format: str = "json") -> dict | str:
|
|
144
|
+
data = self._get(f"/api/v1/protocols/{slug}/export", params={"format": format})
|
|
145
|
+
return data
|
|
146
|
+
|
|
147
|
+
def search_equipment(
|
|
148
|
+
self,
|
|
149
|
+
query: str | None = None,
|
|
150
|
+
*,
|
|
151
|
+
category: str | None = None,
|
|
152
|
+
manufacturer: str | None = None,
|
|
153
|
+
page: int = 1,
|
|
154
|
+
limit: int = 20,
|
|
155
|
+
) -> SearchResults:
|
|
156
|
+
params: dict = {"page": page, "limit": limit}
|
|
157
|
+
if query:
|
|
158
|
+
params["q"] = query
|
|
159
|
+
if category:
|
|
160
|
+
params["category"] = category
|
|
161
|
+
if manufacturer:
|
|
162
|
+
params["manufacturer"] = manufacturer
|
|
163
|
+
|
|
164
|
+
data = self._get("/api/v1/equipment", params=params)
|
|
165
|
+
equipment = [EquipmentDetail.from_dict(e) for e in data.get("data", [])]
|
|
166
|
+
pagination = (
|
|
167
|
+
Pagination.from_dict(data["pagination"]) if "pagination" in data else None
|
|
168
|
+
)
|
|
169
|
+
rate_limit = (
|
|
170
|
+
RateLimit.from_dict(data["rate_limit"]) if "rate_limit" in data else None
|
|
171
|
+
)
|
|
172
|
+
return SearchResults(
|
|
173
|
+
protocols=[],
|
|
174
|
+
equipment=equipment,
|
|
175
|
+
results=[],
|
|
176
|
+
pagination=pagination,
|
|
177
|
+
rate_limit=rate_limit,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def get_equipment(self, slug: str) -> EquipmentDetail:
|
|
181
|
+
data = self._get(f"/api/v1/equipment/{slug}")
|
|
182
|
+
return EquipmentDetail.from_dict(data["data"])
|
|
183
|
+
|
|
184
|
+
def unified_search(
|
|
185
|
+
self,
|
|
186
|
+
query: str,
|
|
187
|
+
*,
|
|
188
|
+
type: str | None = None,
|
|
189
|
+
page: int = 1,
|
|
190
|
+
limit: int = 20,
|
|
191
|
+
) -> SearchResults:
|
|
192
|
+
params: dict = {"q": query, "page": page, "limit": limit}
|
|
193
|
+
if type:
|
|
194
|
+
params["type"] = type
|
|
195
|
+
|
|
196
|
+
data = self._get("/api/v1/search", params=params)
|
|
197
|
+
results = [SearchResult.from_dict(r) for r in data.get("data", [])]
|
|
198
|
+
pagination = (
|
|
199
|
+
Pagination.from_dict(data["pagination"]) if "pagination" in data else None
|
|
200
|
+
)
|
|
201
|
+
rate_limit = (
|
|
202
|
+
RateLimit.from_dict(data["rate_limit"]) if "rate_limit" in data else None
|
|
203
|
+
)
|
|
204
|
+
return SearchResults(
|
|
205
|
+
protocols=[],
|
|
206
|
+
equipment=[],
|
|
207
|
+
results=results,
|
|
208
|
+
pagination=pagination,
|
|
209
|
+
rate_limit=rate_limit,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def me(self) -> dict:
|
|
213
|
+
data = self._get("/api/v1/me")
|
|
214
|
+
return data["data"]
|
|
215
|
+
|
|
216
|
+
def close(self) -> None:
|
|
217
|
+
self._http.close()
|
|
218
|
+
|
|
219
|
+
def __enter__(self):
|
|
220
|
+
return self
|
|
221
|
+
|
|
222
|
+
def __exit__(self, *args):
|
|
223
|
+
self.close()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Export and import protocols as YAML/JSON files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from replicatescience.models import ProtocolDetail
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def save_protocol(protocol: ProtocolDetail, path: str) -> None:
|
|
14
|
+
"""Save a protocol to a YAML or JSON file."""
|
|
15
|
+
p = Path(path)
|
|
16
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
data = protocol.to_dict()
|
|
18
|
+
|
|
19
|
+
if p.suffix in (".yaml", ".yml"):
|
|
20
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
21
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
22
|
+
elif p.suffix == ".json":
|
|
23
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
24
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
25
|
+
else:
|
|
26
|
+
raise ValueError(f"Unsupported format: {p.suffix}. Use .yaml, .yml, or .json")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_protocol(path: str) -> ProtocolDetail:
|
|
30
|
+
"""Load a protocol from a YAML or JSON file."""
|
|
31
|
+
p = Path(path)
|
|
32
|
+
|
|
33
|
+
if p.suffix in (".yaml", ".yml"):
|
|
34
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
elif p.suffix == ".json":
|
|
37
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
38
|
+
data = json.load(f)
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"Unsupported format: {p.suffix}. Use .yaml, .yml, or .json")
|
|
41
|
+
|
|
42
|
+
return ProtocolDetail.from_dict(data)
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Data models for ReplicateScience API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Pagination:
|
|
11
|
+
page: int
|
|
12
|
+
limit: int
|
|
13
|
+
total: int
|
|
14
|
+
next: str | None = None
|
|
15
|
+
previous: str | None = None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, d: dict) -> Pagination:
|
|
19
|
+
return cls(
|
|
20
|
+
page=d["page"],
|
|
21
|
+
limit=d["limit"],
|
|
22
|
+
total=d["total"],
|
|
23
|
+
next=d.get("next"),
|
|
24
|
+
previous=d.get("previous"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RateLimit:
|
|
30
|
+
limit: int
|
|
31
|
+
remaining: int
|
|
32
|
+
resets_at: str
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, d: dict) -> RateLimit:
|
|
36
|
+
return cls(
|
|
37
|
+
limit=d["limit"],
|
|
38
|
+
remaining=d["remaining"],
|
|
39
|
+
resets_at=d["resets_at"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Paper:
|
|
45
|
+
title: str
|
|
46
|
+
doi: str | None = None
|
|
47
|
+
authors: list[str] = field(default_factory=list)
|
|
48
|
+
journal: str | None = None
|
|
49
|
+
year: int | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, d: dict) -> Paper:
|
|
53
|
+
return cls(
|
|
54
|
+
title=d["title"],
|
|
55
|
+
doi=d.get("doi"),
|
|
56
|
+
authors=d.get("authors", []),
|
|
57
|
+
journal=d.get("journal"),
|
|
58
|
+
year=d.get("year"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"title": self.title,
|
|
64
|
+
"doi": self.doi,
|
|
65
|
+
"authors": self.authors,
|
|
66
|
+
"journal": self.journal,
|
|
67
|
+
"year": self.year,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ProcedureStep:
|
|
73
|
+
number: int
|
|
74
|
+
title: str | None
|
|
75
|
+
description: str
|
|
76
|
+
duration: str | None = None
|
|
77
|
+
evidence: str | None = None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: dict) -> ProcedureStep:
|
|
81
|
+
return cls(
|
|
82
|
+
number=d["number"],
|
|
83
|
+
title=d.get("title"),
|
|
84
|
+
description=d["description"],
|
|
85
|
+
duration=d.get("duration"),
|
|
86
|
+
evidence=d.get("evidence"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"number": self.number,
|
|
92
|
+
"title": self.title,
|
|
93
|
+
"description": self.description,
|
|
94
|
+
"duration": self.duration,
|
|
95
|
+
"evidence": self.evidence,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class EvidenceQuote:
|
|
101
|
+
quote: str
|
|
102
|
+
context: str
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, d: dict) -> EvidenceQuote:
|
|
106
|
+
return cls(quote=d["quote"], context=d["context"])
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict:
|
|
109
|
+
return {"quote": self.quote, "context": self.context}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Equipment:
|
|
114
|
+
id: str
|
|
115
|
+
name: str
|
|
116
|
+
slug: str
|
|
117
|
+
category: str | None = None
|
|
118
|
+
manufacturer: str | None = None
|
|
119
|
+
model_number: str | None = None
|
|
120
|
+
rrid: str | None = None
|
|
121
|
+
product_url: str | None = None
|
|
122
|
+
price_estimate: float | None = None
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_dict(cls, d: dict) -> Equipment:
|
|
126
|
+
return cls(
|
|
127
|
+
id=d["id"],
|
|
128
|
+
name=d["name"],
|
|
129
|
+
slug=d["slug"],
|
|
130
|
+
category=d.get("category"),
|
|
131
|
+
manufacturer=d.get("manufacturer"),
|
|
132
|
+
model_number=d.get("model_number"),
|
|
133
|
+
rrid=d.get("rrid"),
|
|
134
|
+
product_url=d.get("product_url"),
|
|
135
|
+
price_estimate=d.get("price_estimate"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict:
|
|
139
|
+
return {
|
|
140
|
+
"id": self.id,
|
|
141
|
+
"name": self.name,
|
|
142
|
+
"slug": self.slug,
|
|
143
|
+
"category": self.category,
|
|
144
|
+
"manufacturer": self.manufacturer,
|
|
145
|
+
"model_number": self.model_number,
|
|
146
|
+
"rrid": self.rrid,
|
|
147
|
+
"product_url": self.product_url,
|
|
148
|
+
"price_estimate": self.price_estimate,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Product:
|
|
154
|
+
name: str
|
|
155
|
+
url: str
|
|
156
|
+
price: float | None
|
|
157
|
+
sku: str
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_dict(cls, d: dict) -> Product:
|
|
161
|
+
return cls(
|
|
162
|
+
name=d["name"],
|
|
163
|
+
url=d["url"],
|
|
164
|
+
price=d.get("price"),
|
|
165
|
+
sku=d["sku"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict:
|
|
169
|
+
return {
|
|
170
|
+
"name": self.name,
|
|
171
|
+
"url": self.url,
|
|
172
|
+
"price": self.price,
|
|
173
|
+
"sku": self.sku,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class EquipmentDetail(Equipment):
|
|
179
|
+
aliases: list[str] = field(default_factory=list)
|
|
180
|
+
description: str | None = None
|
|
181
|
+
product: Product | None = None
|
|
182
|
+
protocol_count: int = 0
|
|
183
|
+
url: str = ""
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_dict(cls, d: dict) -> EquipmentDetail:
|
|
187
|
+
product = None
|
|
188
|
+
if d.get("product"):
|
|
189
|
+
product = Product.from_dict(d["product"])
|
|
190
|
+
return cls(
|
|
191
|
+
id=d["id"],
|
|
192
|
+
name=d["name"],
|
|
193
|
+
slug=d["slug"],
|
|
194
|
+
category=d.get("category"),
|
|
195
|
+
manufacturer=d.get("manufacturer"),
|
|
196
|
+
model_number=d.get("model_number"),
|
|
197
|
+
rrid=d.get("rrid"),
|
|
198
|
+
product_url=d.get("product_url"),
|
|
199
|
+
price_estimate=d.get("price_estimate"),
|
|
200
|
+
aliases=d.get("aliases", []),
|
|
201
|
+
description=d.get("description"),
|
|
202
|
+
product=product,
|
|
203
|
+
protocol_count=d.get("protocol_count", 0),
|
|
204
|
+
url=d.get("url", ""),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class Protocol:
|
|
210
|
+
id: str
|
|
211
|
+
name: str
|
|
212
|
+
slug: str
|
|
213
|
+
paper: Paper
|
|
214
|
+
type: str | None = None
|
|
215
|
+
species: str | None = None
|
|
216
|
+
strain: str | None = None
|
|
217
|
+
equipment_count: int = 0
|
|
218
|
+
step_count: int = 0
|
|
219
|
+
evidence_score: float | None = None
|
|
220
|
+
tags: list[str] = field(default_factory=list)
|
|
221
|
+
url: str = ""
|
|
222
|
+
created_at: str = ""
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_dict(cls, d: dict) -> Protocol:
|
|
226
|
+
return cls(
|
|
227
|
+
id=d["id"],
|
|
228
|
+
name=d["name"],
|
|
229
|
+
slug=d["slug"],
|
|
230
|
+
paper=Paper.from_dict(d["paper"]),
|
|
231
|
+
type=d.get("type"),
|
|
232
|
+
species=d.get("species"),
|
|
233
|
+
strain=d.get("strain"),
|
|
234
|
+
equipment_count=d.get("equipment_count", 0),
|
|
235
|
+
step_count=d.get("step_count", 0),
|
|
236
|
+
evidence_score=d.get("evidence_score"),
|
|
237
|
+
tags=d.get("tags", []),
|
|
238
|
+
url=d.get("url", ""),
|
|
239
|
+
created_at=d.get("created_at", ""),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def to_dict(self) -> dict:
|
|
243
|
+
return {
|
|
244
|
+
"id": self.id,
|
|
245
|
+
"name": self.name,
|
|
246
|
+
"slug": self.slug,
|
|
247
|
+
"paper": self.paper.to_dict(),
|
|
248
|
+
"type": self.type,
|
|
249
|
+
"species": self.species,
|
|
250
|
+
"strain": self.strain,
|
|
251
|
+
"equipment_count": self.equipment_count,
|
|
252
|
+
"step_count": self.step_count,
|
|
253
|
+
"evidence_score": self.evidence_score,
|
|
254
|
+
"tags": self.tags,
|
|
255
|
+
"url": self.url,
|
|
256
|
+
"created_at": self.created_at,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class ProtocolDetail(Protocol):
|
|
262
|
+
brain_regions: list[str] = field(default_factory=list)
|
|
263
|
+
steps: list[ProcedureStep] = field(default_factory=list)
|
|
264
|
+
equipment: list[Equipment] = field(default_factory=list)
|
|
265
|
+
subjects: dict[str, Any] | None = None
|
|
266
|
+
analysis: dict[str, Any] | None = None
|
|
267
|
+
evidence_quotes: list[EvidenceQuote] = field(default_factory=list)
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_dict(cls, d: dict) -> ProtocolDetail:
|
|
271
|
+
return cls(
|
|
272
|
+
id=d["id"],
|
|
273
|
+
name=d["name"],
|
|
274
|
+
slug=d["slug"],
|
|
275
|
+
paper=Paper.from_dict(d["paper"]),
|
|
276
|
+
type=d.get("type"),
|
|
277
|
+
species=d.get("species"),
|
|
278
|
+
strain=d.get("strain"),
|
|
279
|
+
equipment_count=d.get("equipment_count", 0),
|
|
280
|
+
step_count=d.get("step_count", 0),
|
|
281
|
+
evidence_score=d.get("evidence_score"),
|
|
282
|
+
tags=d.get("tags", []),
|
|
283
|
+
url=d.get("url", ""),
|
|
284
|
+
created_at=d.get("created_at", ""),
|
|
285
|
+
brain_regions=d.get("brain_regions", []),
|
|
286
|
+
steps=[ProcedureStep.from_dict(s) for s in d.get("steps", [])],
|
|
287
|
+
equipment=[Equipment.from_dict(e) for e in d.get("equipment", [])],
|
|
288
|
+
subjects=d.get("subjects"),
|
|
289
|
+
analysis=d.get("analysis"),
|
|
290
|
+
evidence_quotes=[
|
|
291
|
+
EvidenceQuote.from_dict(q) for q in d.get("evidence_quotes", [])
|
|
292
|
+
],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def to_dict(self) -> dict:
|
|
296
|
+
base = super().to_dict()
|
|
297
|
+
base.update(
|
|
298
|
+
{
|
|
299
|
+
"brain_regions": self.brain_regions,
|
|
300
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
301
|
+
"equipment": [e.to_dict() for e in self.equipment],
|
|
302
|
+
"subjects": self.subjects,
|
|
303
|
+
"analysis": self.analysis,
|
|
304
|
+
"evidence_quotes": [q.to_dict() for q in self.evidence_quotes],
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
return base
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class SearchResult:
|
|
312
|
+
type: str
|
|
313
|
+
id: str
|
|
314
|
+
name: str
|
|
315
|
+
slug: str
|
|
316
|
+
description: str
|
|
317
|
+
url: str
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def from_dict(cls, d: dict) -> SearchResult:
|
|
321
|
+
return cls(
|
|
322
|
+
type=d["type"],
|
|
323
|
+
id=d["id"],
|
|
324
|
+
name=d["name"],
|
|
325
|
+
slug=d["slug"],
|
|
326
|
+
description=d.get("description", ""),
|
|
327
|
+
url=d.get("url", ""),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass
|
|
332
|
+
class SearchResults:
|
|
333
|
+
protocols: list[Protocol]
|
|
334
|
+
equipment: list[Equipment]
|
|
335
|
+
results: list[SearchResult]
|
|
336
|
+
pagination: Pagination | None = None
|
|
337
|
+
rate_limit: RateLimit | None = None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass
|
|
341
|
+
class DiffChange:
|
|
342
|
+
field: str
|
|
343
|
+
label: str
|
|
344
|
+
old: Any
|
|
345
|
+
new: Any
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass
|
|
349
|
+
class ProtocolDiff:
|
|
350
|
+
protocol_a: str
|
|
351
|
+
protocol_b: str
|
|
352
|
+
changes: list[DiffChange] = field(default_factory=list)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def summary(self) -> str:
|
|
356
|
+
n = len(self.changes)
|
|
357
|
+
sections = len({c.field.split(".")[0] for c in self.changes})
|
|
358
|
+
if n == 0:
|
|
359
|
+
return "No differences found."
|
|
360
|
+
return f"{n} change{'s' if n != 1 else ''} across {sections} section{'s' if sections != 1 else ''}"
|
|
361
|
+
|
|
362
|
+
def to_markdown(self) -> str:
|
|
363
|
+
lines = [
|
|
364
|
+
f"# Protocol Diff",
|
|
365
|
+
f"**A:** {self.protocol_a}",
|
|
366
|
+
f"**B:** {self.protocol_b}",
|
|
367
|
+
f"**Summary:** {self.summary}",
|
|
368
|
+
"",
|
|
369
|
+
]
|
|
370
|
+
for c in self.changes:
|
|
371
|
+
lines.append(f"## {c.label}")
|
|
372
|
+
lines.append(f"- **A:** {c.old}")
|
|
373
|
+
lines.append(f"- **B:** {c.new}")
|
|
374
|
+
lines.append("")
|
|
375
|
+
return "\n".join(lines)
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def compute(cls, a: ProtocolDetail, b: ProtocolDetail) -> ProtocolDiff:
|
|
379
|
+
changes: list[DiffChange] = []
|
|
380
|
+
|
|
381
|
+
# Compare metadata fields
|
|
382
|
+
for field_name, label in [
|
|
383
|
+
("name", "Name"),
|
|
384
|
+
("type", "Type"),
|
|
385
|
+
("species", "Species"),
|
|
386
|
+
("strain", "Strain"),
|
|
387
|
+
]:
|
|
388
|
+
va = getattr(a, field_name)
|
|
389
|
+
vb = getattr(b, field_name)
|
|
390
|
+
if va != vb:
|
|
391
|
+
changes.append(DiffChange(field=field_name, label=label, old=va, new=vb))
|
|
392
|
+
|
|
393
|
+
# Compare tags
|
|
394
|
+
if set(a.tags) != set(b.tags):
|
|
395
|
+
changes.append(
|
|
396
|
+
DiffChange(field="tags", label="Tags", old=a.tags, new=b.tags)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Compare brain regions
|
|
400
|
+
if set(a.brain_regions) != set(b.brain_regions):
|
|
401
|
+
changes.append(
|
|
402
|
+
DiffChange(
|
|
403
|
+
field="brain_regions",
|
|
404
|
+
label="Brain Regions",
|
|
405
|
+
old=a.brain_regions,
|
|
406
|
+
new=b.brain_regions,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Compare step count
|
|
411
|
+
if a.step_count != b.step_count:
|
|
412
|
+
changes.append(
|
|
413
|
+
DiffChange(
|
|
414
|
+
field="steps.count",
|
|
415
|
+
label="Step Count",
|
|
416
|
+
old=a.step_count,
|
|
417
|
+
new=b.step_count,
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Compare individual steps
|
|
422
|
+
max_steps = max(len(a.steps), len(b.steps))
|
|
423
|
+
for i in range(max_steps):
|
|
424
|
+
sa = a.steps[i] if i < len(a.steps) else None
|
|
425
|
+
sb = b.steps[i] if i < len(b.steps) else None
|
|
426
|
+
if sa is None:
|
|
427
|
+
changes.append(
|
|
428
|
+
DiffChange(
|
|
429
|
+
field=f"steps.{i + 1}",
|
|
430
|
+
label=f"Step {i + 1}",
|
|
431
|
+
old="(missing)",
|
|
432
|
+
new=sb.description if sb else "",
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
elif sb is None:
|
|
436
|
+
changes.append(
|
|
437
|
+
DiffChange(
|
|
438
|
+
field=f"steps.{i + 1}",
|
|
439
|
+
label=f"Step {i + 1}",
|
|
440
|
+
old=sa.description,
|
|
441
|
+
new="(missing)",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
elif sa.description != sb.description:
|
|
445
|
+
changes.append(
|
|
446
|
+
DiffChange(
|
|
447
|
+
field=f"steps.{i + 1}",
|
|
448
|
+
label=f"Step {i + 1}: {sa.title or sb.title or ''}".strip(": "),
|
|
449
|
+
old=sa.description,
|
|
450
|
+
new=sb.description,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Compare equipment
|
|
455
|
+
equip_a = {e.slug for e in a.equipment}
|
|
456
|
+
equip_b = {e.slug for e in b.equipment}
|
|
457
|
+
if equip_a != equip_b:
|
|
458
|
+
only_a = equip_a - equip_b
|
|
459
|
+
only_b = equip_b - equip_a
|
|
460
|
+
if only_a:
|
|
461
|
+
changes.append(
|
|
462
|
+
DiffChange(
|
|
463
|
+
field="equipment.removed",
|
|
464
|
+
label="Equipment (only in A)",
|
|
465
|
+
old=sorted(only_a),
|
|
466
|
+
new="(not present)",
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
if only_b:
|
|
470
|
+
changes.append(
|
|
471
|
+
DiffChange(
|
|
472
|
+
field="equipment.added",
|
|
473
|
+
label="Equipment (only in B)",
|
|
474
|
+
old="(not present)",
|
|
475
|
+
new=sorted(only_b),
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return cls(
|
|
480
|
+
protocol_a=a.slug,
|
|
481
|
+
protocol_b=b.slug,
|
|
482
|
+
changes=changes,
|
|
483
|
+
)
|