replicatescience 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ )
@@ -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,10 @@
1
+ replicatescience/__init__.py,sha256=AdfSuuiX7LilnPaySbA_fjR4RoDcly1_ECgCwurnL3w,3156
2
+ replicatescience/cli.py,sha256=Nui5B8iCJGQRnxMo16Hlx7-CxLgzZGRIEM40izpNUn4,4930
3
+ replicatescience/client.py,sha256=G04ROvMQbHSCaKCYXESS4t62TNiimeFeml4iKfDMaN8,6772
4
+ replicatescience/export.py,sha256=t5Ucthh24hhPfrJ6tDBkr_x9jO_SElPLH1ZJv7hsM48,1326
5
+ replicatescience/models.py,sha256=2JNdRaWCgNfxu0ADx5yM0tAHbC16sJWduDaITbecnFg,13711
6
+ replicatescience-0.1.0.dist-info/METADATA,sha256=ZUgFFTbmM4fVkeQcTowDQPZlYMtyrJdTkeZ1BNwdHsE,2964
7
+ replicatescience-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ replicatescience-0.1.0.dist-info/entry_points.txt,sha256=39OfaAqYnZhdIekG_J2zi46QhQyz5-rQy3TcAXj37-8,49
9
+ replicatescience-0.1.0.dist-info/licenses/LICENSE,sha256=SdX8nSr4zEuQl-0t6Tav14CVZaqpWjtDNMHdwZbEvGE,1076
10
+ replicatescience-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rs = replicatescience.cli:main
@@ -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.