sciencehub 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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ uv.lock
4
+ __pycache__/
5
+ .pytest_cache/
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: sciencehub
3
+ Version: 0.1.0
4
+ Summary: Load the functions and data of ScienceHub projects in Python.
5
+ Project-URL: Homepage, https://sciencehub.sh
6
+ Project-URL: Documentation, https://sciencehub.sh/docs/python
7
+ Author: ScienceHub
8
+ License-Expression: Apache-2.0
9
+ Keywords: datasets,marimo,notebooks,reproducibility,science
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: marimo>=0.13
16
+ Description-Content-Type: text/markdown
17
+
18
+ # sciencehub
19
+
20
+ Use the functions and data of [ScienceHub](https://sciencehub.sh) projects
21
+ from Python.
22
+
23
+ A ScienceHub project is a [marimo](https://marimo.io) notebook. Every function
24
+ its author wrote as a reusable function (`@app.function`) can be called from
25
+ your own code, and every file kept beside the notebook can be downloaded. Each
26
+ project page lists them under **Functions** and **Files**.
27
+
28
+ ```sh
29
+ pip install sciencehub
30
+ ```
31
+
32
+ ## Load a project
33
+
34
+ ```python
35
+ import sciencehub as sh
36
+
37
+ ocean = sh.load("ada/ocean-heat")
38
+ ocean
39
+ ```
40
+
41
+ ```
42
+ ada/ocean-heat · Ocean heat content
43
+ version 7Q2M4XKD, the current one · https://sciencehub.sh/ada/ocean-heat
44
+
45
+ Functions
46
+ heat_content(depth_m: float = 700) -> float
47
+ Heat content above a depth, in joules per square metre.
48
+ Files
49
+ data/argo.csv 14.2 kB
50
+
51
+ Pin this version: sh.load("ada/ocean-heat", version="01K7…7Q2M4XKD")
52
+ ```
53
+
54
+ `sh.load` takes the name from the project's address: `"owner/project"` for
55
+ its main notebook, `"owner/project/notebook"` for another one, or the address
56
+ itself.
57
+
58
+ ## Call a function
59
+
60
+ ```python
61
+ help(ocean.heat_content)
62
+ ocean.heat_content(depth_m=700)
63
+ ```
64
+
65
+ `help()`, `inspect.signature`, Jupyter's `?` and editor completion show the
66
+ signature and docstring the author wrote, before anything is downloaded.
67
+
68
+ The first call downloads the notebook and its files into a cache, imports the
69
+ notebook, and calls the function on your computer. The function runs with the
70
+ notebook's folder as its working directory, so a function that opens
71
+ `data/argo.csv` finds it, as it does on ScienceHub.
72
+
73
+ The notebook's own dependencies have to be installed in your Python. If one is
74
+ missing, the error names it; `ocean.install()` installs everything the
75
+ notebook declares, with pip or uv.
76
+
77
+ Calling a function runs its author's code, as installing their package would.
78
+
79
+ ## Read a file
80
+
81
+ ```python
82
+ path = ocean.path("data/argo.csv") # downloaded once, checked, then read from disk
83
+ with ocean.open("data/argo.csv") as handle:
84
+ ...
85
+ ocean.download() # the notebook and every file, in one folder
86
+ ```
87
+
88
+ Files of public projects also have an address any program can read:
89
+
90
+ ```python
91
+ import pandas as pd
92
+
93
+ df = pd.read_csv("https://sciencehub.sh/ada/ocean-heat/raw/data/argo.csv")
94
+ ```
95
+
96
+ ## Pin a version
97
+
98
+ A load is always one version, and `ocean.version` says which. Pin it so your
99
+ results do not change when the author saves a new one:
100
+
101
+ ```python
102
+ sh.load("ada/ocean-heat", version="01K7…") # one saved version
103
+ sh.load("ada/ocean-heat", release=2) # what DOI release 2 names, files included
104
+ ```
105
+
106
+ ## Private projects
107
+
108
+ Make a personal access token with the read scope at
109
+ [sciencehub.sh/settings/tokens](https://sciencehub.sh/settings/tokens) and
110
+ set it as `SCIENCEHUB_TOKEN`, or pass `token=` to `sh.load`.
111
+
112
+ ## Settings
113
+
114
+ | Variable | What it does |
115
+ | ------------------ | ---------------------------------------------------- |
116
+ | `SCIENCEHUB_TOKEN` | A personal access token, for private projects |
117
+ | `SCIENCEHUB_URL` | Another ScienceHub site than `https://sciencehub.sh` |
118
+ | `SCIENCEHUB_CACHE` | Where versions are kept; the user cache by default |
119
+
120
+ Apache 2.0.
@@ -0,0 +1,103 @@
1
+ # sciencehub
2
+
3
+ Use the functions and data of [ScienceHub](https://sciencehub.sh) projects
4
+ from Python.
5
+
6
+ A ScienceHub project is a [marimo](https://marimo.io) notebook. Every function
7
+ its author wrote as a reusable function (`@app.function`) can be called from
8
+ your own code, and every file kept beside the notebook can be downloaded. Each
9
+ project page lists them under **Functions** and **Files**.
10
+
11
+ ```sh
12
+ pip install sciencehub
13
+ ```
14
+
15
+ ## Load a project
16
+
17
+ ```python
18
+ import sciencehub as sh
19
+
20
+ ocean = sh.load("ada/ocean-heat")
21
+ ocean
22
+ ```
23
+
24
+ ```
25
+ ada/ocean-heat · Ocean heat content
26
+ version 7Q2M4XKD, the current one · https://sciencehub.sh/ada/ocean-heat
27
+
28
+ Functions
29
+ heat_content(depth_m: float = 700) -> float
30
+ Heat content above a depth, in joules per square metre.
31
+ Files
32
+ data/argo.csv 14.2 kB
33
+
34
+ Pin this version: sh.load("ada/ocean-heat", version="01K7…7Q2M4XKD")
35
+ ```
36
+
37
+ `sh.load` takes the name from the project's address: `"owner/project"` for
38
+ its main notebook, `"owner/project/notebook"` for another one, or the address
39
+ itself.
40
+
41
+ ## Call a function
42
+
43
+ ```python
44
+ help(ocean.heat_content)
45
+ ocean.heat_content(depth_m=700)
46
+ ```
47
+
48
+ `help()`, `inspect.signature`, Jupyter's `?` and editor completion show the
49
+ signature and docstring the author wrote, before anything is downloaded.
50
+
51
+ The first call downloads the notebook and its files into a cache, imports the
52
+ notebook, and calls the function on your computer. The function runs with the
53
+ notebook's folder as its working directory, so a function that opens
54
+ `data/argo.csv` finds it, as it does on ScienceHub.
55
+
56
+ The notebook's own dependencies have to be installed in your Python. If one is
57
+ missing, the error names it; `ocean.install()` installs everything the
58
+ notebook declares, with pip or uv.
59
+
60
+ Calling a function runs its author's code, as installing their package would.
61
+
62
+ ## Read a file
63
+
64
+ ```python
65
+ path = ocean.path("data/argo.csv") # downloaded once, checked, then read from disk
66
+ with ocean.open("data/argo.csv") as handle:
67
+ ...
68
+ ocean.download() # the notebook and every file, in one folder
69
+ ```
70
+
71
+ Files of public projects also have an address any program can read:
72
+
73
+ ```python
74
+ import pandas as pd
75
+
76
+ df = pd.read_csv("https://sciencehub.sh/ada/ocean-heat/raw/data/argo.csv")
77
+ ```
78
+
79
+ ## Pin a version
80
+
81
+ A load is always one version, and `ocean.version` says which. Pin it so your
82
+ results do not change when the author saves a new one:
83
+
84
+ ```python
85
+ sh.load("ada/ocean-heat", version="01K7…") # one saved version
86
+ sh.load("ada/ocean-heat", release=2) # what DOI release 2 names, files included
87
+ ```
88
+
89
+ ## Private projects
90
+
91
+ Make a personal access token with the read scope at
92
+ [sciencehub.sh/settings/tokens](https://sciencehub.sh/settings/tokens) and
93
+ set it as `SCIENCEHUB_TOKEN`, or pass `token=` to `sh.load`.
94
+
95
+ ## Settings
96
+
97
+ | Variable | What it does |
98
+ | ------------------ | ---------------------------------------------------- |
99
+ | `SCIENCEHUB_TOKEN` | A personal access token, for private projects |
100
+ | `SCIENCEHUB_URL` | Another ScienceHub site than `https://sciencehub.sh` |
101
+ | `SCIENCEHUB_CACHE` | Where versions are kept; the user cache by default |
102
+
103
+ Apache 2.0.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "sciencehub"
3
+ version = "0.1.0"
4
+ description = "Load the functions and data of ScienceHub projects in Python."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "ScienceHub" }]
9
+ keywords = ["science", "notebooks", "marimo", "reproducibility", "datasets"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Scientific/Engineering",
15
+ ]
16
+ # A notebook imports marimo itself, so loading its functions needs it.
17
+ dependencies = ["marimo>=0.13"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://sciencehub.sh"
21
+ Documentation = "https://sciencehub.sh/docs/python"
22
+
23
+ [build-system]
24
+ requires = ["hatchling>=1.25"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/sciencehub"]
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,28 @@
1
+ """Use the functions and data of ScienceHub projects from Python.
2
+
3
+ import sciencehub as sh
4
+
5
+ ocean = sh.load("ada/ocean-heat") # the current version; print it to see what it offers
6
+ help(ocean.heat_content) # the author's docstring and signature
7
+ ocean.heat_content(depth_m=700) # runs on this computer
8
+ ocean.path("data/argo.csv") # downloaded once, then read from disk
9
+ sh.load("ada/ocean-heat", release=2) # exactly what DOI release 2 names
10
+
11
+ Loading a project runs its author's code when a function is first called, as
12
+ installing their package would.
13
+ """
14
+
15
+ from ._http import AccessError, NotFoundError, ScienceHubError
16
+ from ._project import File, MissingDependencyError, Project, Version, load
17
+
18
+ __all__ = [
19
+ "AccessError",
20
+ "File",
21
+ "MissingDependencyError",
22
+ "NotFoundError",
23
+ "Project",
24
+ "ScienceHubError",
25
+ "Version",
26
+ "load",
27
+ ]
28
+ __version__ = "0.1.0"
@@ -0,0 +1,82 @@
1
+ """Requests to a ScienceHub site, with the standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ DEFAULT_URL = "https://sciencehub.sh"
12
+ USER_AGENT = "sciencehub-python/0.1.0"
13
+
14
+
15
+ class ScienceHubError(Exception):
16
+ """A request to ScienceHub did not succeed."""
17
+
18
+
19
+ class NotFoundError(ScienceHubError, LookupError):
20
+ """The project, notebook, version or file does not exist, or the token cannot read it."""
21
+
22
+
23
+ class AccessError(ScienceHubError, PermissionError):
24
+ """The site refused the token, or the request needs one."""
25
+
26
+
27
+ def base_url(explicit: str | None = None) -> str:
28
+ return (explicit or os.environ.get("SCIENCEHUB_URL") or DEFAULT_URL).rstrip("/")
29
+
30
+
31
+ def _token(explicit: str | None) -> str | None:
32
+ return explicit or os.environ.get("SCIENCEHUB_TOKEN") or None
33
+
34
+
35
+ def _request(url: str, token: str | None) -> urllib.request.Request:
36
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json, */*"}
37
+ if token:
38
+ headers["Authorization"] = f"Bearer {token}"
39
+ return urllib.request.Request(url, headers=headers)
40
+
41
+
42
+ def _explain(error: urllib.error.HTTPError, url: str) -> ScienceHubError:
43
+ message = ""
44
+ try:
45
+ body = json.loads(error.read().decode("utf-8"))
46
+ message = body.get("error", {}).get("message") or body.get("error_description") or ""
47
+ except (ValueError, AttributeError):
48
+ pass
49
+ if error.code == 404:
50
+ return NotFoundError(message or f"Not found: {url}")
51
+ if error.code in (401, 403):
52
+ hint = (
53
+ "Set SCIENCEHUB_TOKEN to a personal access token with the read scope, "
54
+ "made at https://sciencehub.sh/settings/tokens."
55
+ )
56
+ return AccessError(f"{message} {hint}".strip())
57
+ if error.code == 429:
58
+ return ScienceHubError(message or "Too many requests. Wait a minute and try again.")
59
+ return ScienceHubError(message or f"{error.code} {error.reason} from {url}")
60
+
61
+
62
+ def get_json(url: str, token: str | None = None) -> dict[str, Any]:
63
+ try:
64
+ with urllib.request.urlopen(_request(url, _token(token)), timeout=30) as response:
65
+ body = json.loads(response.read().decode("utf-8"))
66
+ except urllib.error.HTTPError as error:
67
+ raise _explain(error, url) from None
68
+ except urllib.error.URLError as error:
69
+ raise ScienceHubError(f"Could not reach {url}: {error.reason}") from None
70
+ if not body.get("success"):
71
+ raise ScienceHubError(body.get("error", {}).get("message", f"No answer from {url}"))
72
+ return body["data"]
73
+
74
+
75
+ def get_bytes(url: str, token: str | None = None) -> bytes:
76
+ try:
77
+ with urllib.request.urlopen(_request(url, _token(token)), timeout=300) as response:
78
+ return response.read()
79
+ except urllib.error.HTTPError as error:
80
+ raise _explain(error, url) from None
81
+ except urllib.error.URLError as error:
82
+ raise ScienceHubError(f"Could not reach {url}: {error.reason}") from None
@@ -0,0 +1,456 @@
1
+ """A version of a ScienceHub notebook, loaded for use from Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import difflib
7
+ import hashlib
8
+ import html
9
+ import importlib.util
10
+ import inspect
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from types import ModuleType
22
+ from typing import Any
23
+ from urllib.parse import quote, urlsplit
24
+
25
+ from . import _http
26
+
27
+ _CHDIR_LOCK = threading.RLock()
28
+
29
+
30
+ class MissingDependencyError(ImportError):
31
+ """The notebook imports a package that is not installed in this Python."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class File:
36
+ """A file kept beside the notebook, at the version that was loaded."""
37
+
38
+ path: str
39
+ size: int
40
+ sha256: str
41
+ url: str
42
+
43
+ def __repr__(self) -> str:
44
+ return f"<File {self.path} {_size(self.size)}>"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Version:
49
+ id: str
50
+ saved_at: str | None
51
+ current: bool
52
+ release: int | None
53
+ doi: str | None
54
+ url: str
55
+
56
+
57
+ def _size(count: int) -> str:
58
+ value = float(count)
59
+ for unit in ("B", "kB", "MB", "GB"):
60
+ if value < 1000 or unit == "GB":
61
+ return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
62
+ value /= 1000
63
+ return f"{count} B"
64
+
65
+
66
+ def _cache_root() -> Path:
67
+ explicit = os.environ.get("SCIENCEHUB_CACHE")
68
+ if explicit:
69
+ return Path(explicit)
70
+ if sys.platform == "darwin":
71
+ return Path.home() / "Library" / "Caches" / "sciencehub"
72
+ if sys.platform == "win32":
73
+ return Path(os.environ.get("LOCALAPPDATA", Path.home())) / "sciencehub" / "cache"
74
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "sciencehub"
75
+
76
+
77
+ class _Written:
78
+ """Source text shown as written, so `help()` prints `g: float = G`, not `g: 'float' = G`."""
79
+
80
+ def __init__(self, source: str) -> None:
81
+ self.source = source
82
+
83
+ def __repr__(self) -> str:
84
+ return self.source
85
+
86
+
87
+ def _signature(parameters: list[dict[str, Any]], returns: str | None) -> inspect.Signature:
88
+ kinds = {
89
+ "positional-only": inspect.Parameter.POSITIONAL_ONLY,
90
+ "positional-or-keyword": inspect.Parameter.POSITIONAL_OR_KEYWORD,
91
+ "keyword-only": inspect.Parameter.KEYWORD_ONLY,
92
+ "var-positional": inspect.Parameter.VAR_POSITIONAL,
93
+ "var-keyword": inspect.Parameter.VAR_KEYWORD,
94
+ }
95
+ built = [
96
+ inspect.Parameter(
97
+ parameter["name"],
98
+ kinds[parameter["kind"]],
99
+ default=inspect.Parameter.empty
100
+ if parameter.get("default") is None
101
+ else _Written(parameter["default"]),
102
+ annotation=inspect.Parameter.empty
103
+ if parameter.get("annotation") is None
104
+ else _Written(parameter["annotation"]),
105
+ )
106
+ for parameter in parameters
107
+ ]
108
+ try:
109
+ return inspect.Signature(
110
+ built,
111
+ return_annotation=inspect.Signature.empty if returns is None else _Written(returns),
112
+ )
113
+ except ValueError:
114
+ return inspect.Signature()
115
+
116
+
117
+ def _parse_ref(ref: str) -> tuple[str, str, str | None]:
118
+ """`owner/project`, `owner/project/notebook`, or a page address on the site."""
119
+ path = urlsplit(ref.strip()).path if "://" in ref else ref.strip()
120
+ segments = [segment for segment in path.strip("/").split("/") if segment]
121
+ if len(segments) >= 4 and segments[2] == "notebooks":
122
+ return segments[0], segments[1], segments[3]
123
+ if len(segments) == 2:
124
+ return segments[0], segments[1], None
125
+ if len(segments) == 3:
126
+ return segments[0], segments[1], segments[2]
127
+ raise ValueError(
128
+ f'"{ref}" does not name a project. Write it as "owner/project" '
129
+ 'or "owner/project/notebook", as in the address of its page.'
130
+ )
131
+
132
+
133
+ class Project:
134
+ """One version of a notebook on ScienceHub: its functions and its files.
135
+
136
+ Made by :func:`sciencehub.load`. Functions are attributes:
137
+ ``project.heat_content(depth_m=700)`` imports the notebook on this
138
+ computer and calls its function. ``project.path("data/argo.csv")``
139
+ downloads a file once and returns where it is.
140
+ """
141
+
142
+ def __init__(self, data: dict[str, Any], *, token: str | None = None) -> None:
143
+ self._data = data
144
+ self._token = token
145
+ self._module: ModuleType | None = None
146
+ self.owner: str = data["project"]["owner"]
147
+ self.slug: str = data["project"]["slug"]
148
+ self.name: str = data["project"]["name"]
149
+ self.description: str = data["project"]["description"]
150
+ self.url: str = data["notebook"]["url"]
151
+ self.notebook: str = data["notebook"]["slug"]
152
+ self.notebooks: list[str] = [entry["slug"] for entry in data["notebooks"]]
153
+ release = data["version"].get("release")
154
+ self.version = Version(
155
+ id=data["version"]["id"],
156
+ saved_at=data["version"].get("saved_at"),
157
+ current=bool(data["version"].get("current")),
158
+ release=release["number"] if release else None,
159
+ doi=release["doi"] if release else None,
160
+ url=data["version"]["url"],
161
+ )
162
+ self.dependencies: list[str] = list(data["environment"].get("dependencies") or [])
163
+ self.files: list[File] = [
164
+ File(path=f["path"], size=f["size"], sha256=f["sha256"], url=f["url"])
165
+ for f in data["files"]
166
+ ]
167
+ self.functions: dict[str, Callable[..., Any]] = {}
168
+ self._classes: dict[str, dict[str, Any]] = {}
169
+ for entry in data["functions"]:
170
+ if entry["kind"] == "class":
171
+ self._classes[entry["name"]] = entry
172
+ else:
173
+ self.functions[entry["name"]] = self._proxy(entry)
174
+
175
+ @property
176
+ def ref(self) -> str:
177
+ """``owner/project``, with the notebook when it is not the main one."""
178
+ main = self._data["notebook"].get("main", True)
179
+ base = f"{self.owner}/{self.slug}"
180
+ return base if main else f"{base}/{self.notebook}"
181
+
182
+ @property
183
+ def directory(self) -> Path:
184
+ """Where this version's source and files are kept on this computer."""
185
+ host = urlsplit(self.url).netloc.replace(":", "_")
186
+ return _cache_root() / host / self.owner / self.slug / self.notebook / self.version.id
187
+
188
+ # Functions -----------------------------------------------------------
189
+
190
+ def _proxy(self, entry: dict[str, Any]) -> Callable[..., Any]:
191
+ name = entry["name"]
192
+ project = self
193
+
194
+ if entry.get("async"):
195
+
196
+ async def call(*args: Any, **kwargs: Any) -> Any:
197
+ function = project._function(name)
198
+ with project._inside():
199
+ return await function(*args, **kwargs)
200
+ else:
201
+
202
+ def call(*args: Any, **kwargs: Any) -> Any:
203
+ function = project._function(name)
204
+ with project._inside():
205
+ return function(*args, **kwargs)
206
+
207
+ call.__name__ = name
208
+ call.__qualname__ = name
209
+ call.__module__ = self.ref
210
+ call.__doc__ = entry.get("doc") or None
211
+ call.__signature__ = _signature(entry["parameters"], entry.get("returns")) # type: ignore[attr-defined]
212
+ call.project = self # type: ignore[attr-defined]
213
+ call.url = f"{self.url}/functions#{name}" # type: ignore[attr-defined]
214
+ return call
215
+
216
+ def _function(self, name: str) -> Callable[..., Any]:
217
+ function = getattr(self.module(), name, None)
218
+ if function is None:
219
+ raise AttributeError(f"{self.ref} at version {self.version.id} has no function {name}.")
220
+ return function
221
+
222
+ @contextlib.contextmanager
223
+ def _inside(self):
224
+ """Run with the notebook's folder as the working directory, as it runs on ScienceHub."""
225
+ with _CHDIR_LOCK:
226
+ previous = os.getcwd()
227
+ os.chdir(self.directory)
228
+ try:
229
+ yield
230
+ finally:
231
+ os.chdir(previous)
232
+
233
+ def __getattr__(self, name: str) -> Any:
234
+ if name.startswith("_"):
235
+ raise AttributeError(name)
236
+ functions = self.__dict__.get("functions", {})
237
+ if name in functions:
238
+ return functions[name]
239
+ if name in self.__dict__.get("_classes", {}):
240
+ return getattr(self.module(), name)
241
+ known = [*functions, *self.__dict__.get("_classes", {})]
242
+ close = difflib.get_close_matches(name, known, n=1)
243
+ hint = f" Did you mean {close[0]}?" if close else ""
244
+ offered = ", ".join(known) if known else "none"
245
+ raise AttributeError(
246
+ f"{self.ref} has no function {name}.{hint} Its functions: {offered}."
247
+ )
248
+
249
+ def __dir__(self) -> list[str]:
250
+ return sorted({*super().__dir__(), *self.functions, *self._classes})
251
+
252
+ # Files ---------------------------------------------------------------
253
+
254
+ def _file(self, path: str) -> File:
255
+ for entry in self.files:
256
+ if entry.path == path:
257
+ return entry
258
+ close = difflib.get_close_matches(path, [entry.path for entry in self.files], n=1)
259
+ hint = f' Did you mean "{close[0]}"?' if close else ""
260
+ raise FileNotFoundError(f'{self.ref} has no file "{path}" at this version.{hint}')
261
+
262
+ def _fetch(self, entry: File) -> Path:
263
+ target = self.directory / entry.path
264
+ if target.exists() and _sha256(target) == entry.sha256:
265
+ return target
266
+ body = _http.get_bytes(entry.url, self._token)
267
+ if hashlib.sha256(body).hexdigest() != entry.sha256:
268
+ raise _http.ScienceHubError(
269
+ f"{entry.path} arrived with other bytes than this version names. Try again."
270
+ )
271
+ target.parent.mkdir(parents=True, exist_ok=True)
272
+ partial = target.with_name(f".{target.name}.part")
273
+ partial.write_bytes(body)
274
+ partial.replace(target)
275
+ return target
276
+
277
+ def path(self, path: str) -> Path:
278
+ """Download one file of this version, once, and return where it is on this computer."""
279
+ return self._fetch(self._file(path))
280
+
281
+ def open(self, path: str, mode: str = "rb", **kwargs: Any):
282
+ """Open one file of this version, downloading it first if needed."""
283
+ return open(self.path(path), mode, **kwargs) # noqa: SIM115
284
+
285
+ def download(self) -> Path:
286
+ """Download the notebook and every file of this version; return the folder they are in."""
287
+ self._source()
288
+ for entry in self.files:
289
+ self._fetch(entry)
290
+ return self.directory
291
+
292
+ # The notebook ----------------------------------------------------------
293
+
294
+ def _source(self) -> Path:
295
+ target = self.directory / self._data["notebook"]["file"]
296
+ if not target.exists():
297
+ body = _http.get_bytes(self._data["version"]["source_url"], self._token)
298
+ target.parent.mkdir(parents=True, exist_ok=True)
299
+ partial = target.with_name(f".{target.name}.part")
300
+ partial.write_bytes(body)
301
+ partial.replace(target)
302
+ (self.directory / ".sciencehub.json").write_text(json.dumps(self._data))
303
+ return target
304
+
305
+ def module(self) -> ModuleType:
306
+ """The notebook imported as a module, with its files beside it. Imported once."""
307
+ if self._module is not None:
308
+ return self._module
309
+ source = self.download() / self._data["notebook"]["file"]
310
+ name = "sciencehub_" + re.sub(r"\W", "_", f"{self.ref}_{self.version.id}")
311
+ spec = importlib.util.spec_from_file_location(name, source)
312
+ if spec is None or spec.loader is None:
313
+ raise ImportError(f"Could not import {source}.")
314
+ module = importlib.util.module_from_spec(spec)
315
+ sys.modules[name] = module
316
+ sys.path.insert(0, str(self.directory))
317
+ try:
318
+ with self._inside():
319
+ spec.loader.exec_module(module)
320
+ except ModuleNotFoundError as error:
321
+ del sys.modules[name]
322
+ raise MissingDependencyError(self._missing(error.name)) from error
323
+ except BaseException:
324
+ del sys.modules[name]
325
+ raise
326
+ finally:
327
+ with contextlib.suppress(ValueError):
328
+ sys.path.remove(str(self.directory))
329
+ self._module = module
330
+ return module
331
+
332
+ def _missing(self, package: str | None) -> str:
333
+ needed = [d for d in self.dependencies if not re.match(r"^marimo\b", d)]
334
+ what = f"the package {package}" if package else "a package"
335
+ install = f"pip install {' '.join(_quoted(needed))}" if needed else f"pip install {package}"
336
+ return (
337
+ f"{self.ref} needs {what}, which is not installed in this Python. "
338
+ f"Install what the notebook declares with project.install(), or run: {install}"
339
+ )
340
+
341
+ def install(self) -> None:
342
+ """Install the packages the notebook declares into this Python, with pip or uv."""
343
+ packages = [d for d in self.dependencies if not re.match(r"^marimo\b", d)]
344
+ if not packages:
345
+ return
346
+ if shutil.which("uv") and not _has_pip():
347
+ command = ["uv", "pip", "install", "--python", sys.executable, *packages]
348
+ else:
349
+ command = [sys.executable, "-m", "pip", "install", *packages]
350
+ subprocess.run(command, check=True)
351
+
352
+ # Showing it ------------------------------------------------------------
353
+
354
+ def _version_line(self) -> str:
355
+ parts = [f"version {self.version.id[-8:]}"]
356
+ if self.version.release:
357
+ parts.append(f"release {self.version.release}, https://doi.org/{self.version.doi}")
358
+ elif self.version.current:
359
+ parts.append("the current one")
360
+ return ", ".join(parts)
361
+
362
+ def __repr__(self) -> str:
363
+ lines = [f"{self.ref} · {self.name}", f"{self._version_line()} · {self.url}"]
364
+ if self.description:
365
+ lines.append(self.description)
366
+ if self.functions or self._classes:
367
+ lines.append("")
368
+ lines.append("Functions")
369
+ for entry in self._data["functions"]:
370
+ prefix = "class " if entry["kind"] == "class" else ""
371
+ lines.append(f" {prefix}{entry['name']}{entry['signature']}")
372
+ if entry.get("doc"):
373
+ lines.append(f" {entry['doc'].splitlines()[0]}")
374
+ if self.files:
375
+ lines.append("")
376
+ lines.append("Files")
377
+ width = max(len(entry.path) for entry in self.files)
378
+ for entry in self.files:
379
+ lines.append(f" {entry.path.ljust(width)} {_size(entry.size)}")
380
+ if self.version.current and not self.version.release:
381
+ lines.append("")
382
+ lines.append(f'Pin this version: sh.load("{self.ref}", version="{self.version.id}")')
383
+ return "\n".join(lines)
384
+
385
+ def _repr_html_(self) -> str:
386
+ e = html.escape
387
+ rows = "".join(
388
+ f"<tr><td><code>{e(('class ' if f['kind'] == 'class' else '') + f['name'] + f['signature'])}</code></td>"
389
+ f"<td>{e((f.get('doc') or '').splitlines()[0] if f.get('doc') else '')}</td></tr>"
390
+ for f in self._data["functions"]
391
+ )
392
+ files = "".join(
393
+ f"<tr><td><code>{e(entry.path)}</code></td><td>{_size(entry.size)}</td></tr>"
394
+ for entry in self.files
395
+ )
396
+ return (
397
+ f'<div><p><a href="{e(self.url)}"><strong>{e(self.ref)}</strong></a> · {e(self.name)}<br>'
398
+ f"<small>{e(self._version_line())}</small></p>"
399
+ + (f"<p>{e(self.description)}</p>" if self.description else "")
400
+ + (f"<table><thead><tr><th>Function</th><th></th></tr></thead><tbody>{rows}</tbody></table>" if rows else "")
401
+ + (f"<table><thead><tr><th>File</th><th>Size</th></tr></thead><tbody>{files}</tbody></table>" if files else "")
402
+ + "</div>"
403
+ )
404
+
405
+
406
+ def _quoted(packages: list[str]) -> list[str]:
407
+ return [f'"{p}"' if re.search(r"[<>=!~\[\] ]", p) else p for p in packages]
408
+
409
+
410
+ def _has_pip() -> bool:
411
+ return importlib.util.find_spec("pip") is not None
412
+
413
+
414
+ def _sha256(path: Path) -> str:
415
+ digest = hashlib.sha256()
416
+ with open(path, "rb") as handle:
417
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
418
+ digest.update(chunk)
419
+ return digest.hexdigest()
420
+
421
+
422
+ def load(
423
+ ref: str,
424
+ *,
425
+ version: str | None = None,
426
+ release: int | None = None,
427
+ token: str | None = None,
428
+ url: str | None = None,
429
+ ) -> Project:
430
+ """Load a version of a ScienceHub notebook.
431
+
432
+ ``ref`` names it the way its page does: ``"owner/project"`` for the main
433
+ notebook, ``"owner/project/notebook"`` for another, or the page's address.
434
+ Without ``version`` or ``release`` it is the current version; the result
435
+ says which, so it can be pinned. ``release=2`` loads exactly what release
436
+ 2's DOI names.
437
+
438
+ Private projects need a personal access token with the read scope, given
439
+ as ``token`` or in ``SCIENCEHUB_TOKEN``. ``url`` (or ``SCIENCEHUB_URL``)
440
+ chooses another ScienceHub site.
441
+ """
442
+ if version is not None and release is not None:
443
+ raise ValueError("Give version or release, not both.")
444
+ base = _http.base_url(url)
445
+ if "://" in ref:
446
+ parts = urlsplit(ref)
447
+ base = f"{parts.scheme}://{parts.netloc}"
448
+ owner, slug, notebook = _parse_ref(ref)
449
+ address = f"{base}/api/v1/projects/{quote(owner)}/{quote(slug)}"
450
+ if notebook:
451
+ address += f"/notebooks/{quote(notebook)}"
452
+ if release is not None:
453
+ address += f"?release={int(release)}"
454
+ elif version is not None:
455
+ address += f"?version={quote(version)}"
456
+ return Project(_http.get_json(address, token), token=token)
File without changes
@@ -0,0 +1,230 @@
1
+ """A site that answers the way sciencehub.sh does, on a local port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ import pytest
11
+
12
+ NOTEBOOK = '''# /// script
13
+ # dependencies = ["marimo", "pandas>=2"]
14
+ # ///
15
+ import marimo
16
+
17
+ __generated_with = "0.25.1"
18
+ app = marimo.App()
19
+
20
+ with app.setup:
21
+ import csv
22
+ G = 9.81
23
+
24
+
25
+ @app.function
26
+ def fall_time(height_m: float, g: float = G) -> float:
27
+ """Seconds for an object to fall height_m metres from rest."""
28
+ return (2 * height_m / g) ** 0.5
29
+
30
+
31
+ @app.function
32
+ def mean_drift(path: str = "data/argo.csv") -> float:
33
+ """Mean drift of the floats in a CSV file beside the notebook."""
34
+ with open(path) as handle:
35
+ rows = list(csv.DictReader(handle))
36
+ return sum(float(row["drift"]) for row in rows) / len(rows)
37
+
38
+
39
+ @app.function
40
+ async def doubled(x: int) -> int:
41
+ return 2 * x
42
+
43
+
44
+ @app.class_definition
45
+ class Body:
46
+ """A falling body."""
47
+
48
+ mass: float = 1.0
49
+
50
+
51
+ @app.cell
52
+ def _():
53
+ raise RuntimeError("cells do not run on import")
54
+ return
55
+
56
+
57
+ if __name__ == "__main__":
58
+ app.run()
59
+ '''
60
+
61
+ BROKEN = '''import marimo
62
+
63
+ app = marimo.App()
64
+
65
+ with app.setup:
66
+ import a_package_nobody_has
67
+
68
+
69
+ @app.function
70
+ def use() -> int:
71
+ return 1
72
+ '''
73
+
74
+ CSV = b"float,drift\nA,1.5\nB,2.5\n"
75
+
76
+
77
+ def description(base: str, slug: str, source_functions: list[dict], files: list[dict]) -> dict:
78
+ page = f"{base}/ada/{slug}"
79
+ return {
80
+ "project": {
81
+ "owner": "ada",
82
+ "slug": slug,
83
+ "name": "Drift",
84
+ "description": "Argo float drift.",
85
+ "visibility": "public",
86
+ "url": page,
87
+ },
88
+ "notebook": {
89
+ "slug": "drift",
90
+ "title": "Drift",
91
+ "file": "drift.py",
92
+ "main": True,
93
+ "url": page,
94
+ "concept_doi": None,
95
+ },
96
+ "notebooks": [{"slug": "drift", "title": "Drift", "main": True}],
97
+ "version": {
98
+ "id": "01K7VERSION",
99
+ "saved_at": "2026-10-13T10:00:00.000Z",
100
+ "current": True,
101
+ "release": None,
102
+ "url": f"{page}/history/01K7VERSION",
103
+ "source_url": f"{page}/history/01K7VERSION/source",
104
+ },
105
+ "environment": {"python": None, "dependencies": ["marimo", "pandas>=2"], "marimo": "0.25.1"},
106
+ "functions": source_functions,
107
+ "files": files,
108
+ "files_recorded": True,
109
+ }
110
+
111
+
112
+ FUNCTIONS = [
113
+ {
114
+ "kind": "function",
115
+ "name": "fall_time",
116
+ "async": False,
117
+ "signature": "(height_m: float, g: float = G) -> float",
118
+ "parameters": [
119
+ {"name": "height_m", "kind": "positional-or-keyword", "annotation": "float", "default": None},
120
+ {"name": "g", "kind": "positional-or-keyword", "annotation": "float", "default": "G"},
121
+ ],
122
+ "returns": "float",
123
+ "doc": "Seconds for an object to fall height_m metres from rest.",
124
+ "decorators": [],
125
+ },
126
+ {
127
+ "kind": "function",
128
+ "name": "mean_drift",
129
+ "async": False,
130
+ "signature": '(path: str = "data/argo.csv") -> float',
131
+ "parameters": [
132
+ {
133
+ "name": "path",
134
+ "kind": "positional-or-keyword",
135
+ "annotation": "str",
136
+ "default": '"data/argo.csv"',
137
+ }
138
+ ],
139
+ "returns": "float",
140
+ "doc": "Mean drift of the floats in a CSV file beside the notebook.",
141
+ "decorators": [],
142
+ },
143
+ {
144
+ "kind": "function",
145
+ "name": "doubled",
146
+ "async": True,
147
+ "signature": "(x: int) -> int",
148
+ "parameters": [{"name": "x", "kind": "positional-or-keyword", "annotation": "int", "default": None}],
149
+ "returns": "int",
150
+ "doc": None,
151
+ "decorators": [],
152
+ },
153
+ {
154
+ "kind": "class",
155
+ "name": "Body",
156
+ "async": False,
157
+ "signature": "",
158
+ "parameters": [],
159
+ "returns": None,
160
+ "doc": "A falling body.",
161
+ "decorators": [],
162
+ },
163
+ ]
164
+
165
+
166
+ class Site:
167
+ def __init__(self) -> None:
168
+ self.routes: dict[str, tuple[int, str, bytes]] = {}
169
+ self.requests: list[tuple[str, str | None]] = []
170
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
171
+ self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
172
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
173
+
174
+ def _handler(self):
175
+ site = self
176
+
177
+ class Handler(BaseHTTPRequestHandler):
178
+ def do_GET(self) -> None: # noqa: N802
179
+ site.requests.append((self.path, self.headers.get("Authorization")))
180
+ status, kind, body = site.routes.get(
181
+ self.path,
182
+ (404, "application/json", b'{"success": false, "error": {"code": "NOT_FOUND", "message": "No such project."}}'),
183
+ )
184
+ self.send_response(status)
185
+ self.send_header("Content-Type", kind)
186
+ self.send_header("Content-Length", str(len(body)))
187
+ self.end_headers()
188
+ self.wfile.write(body)
189
+
190
+ def log_message(self, *args) -> None:
191
+ pass
192
+
193
+ return Handler
194
+
195
+ def project(self, slug: str, source: str, functions: list[dict], files: dict[str, bytes]) -> None:
196
+ listed = [
197
+ {
198
+ "path": path,
199
+ "size": len(body),
200
+ "sha256": hashlib.sha256(body).hexdigest(),
201
+ "url": f"{self.base}/ada/{slug}/history/01K7VERSION/raw/{path}",
202
+ }
203
+ for path, body in files.items()
204
+ ]
205
+ data = description(self.base, slug, functions, listed)
206
+ self.routes[f"/api/v1/projects/ada/{slug}"] = (
207
+ 200,
208
+ "application/json",
209
+ json.dumps({"success": True, "data": data}).encode(),
210
+ )
211
+ self.routes[f"/ada/{slug}/history/01K7VERSION/source"] = (200, "text/plain", source.encode())
212
+ for path, body in files.items():
213
+ self.routes[f"/ada/{slug}/history/01K7VERSION/raw/{path}"] = (200, "text/csv", body)
214
+
215
+
216
+ @pytest.fixture
217
+ def site(tmp_path, monkeypatch):
218
+ monkeypatch.setenv("SCIENCEHUB_CACHE", str(tmp_path / "cache"))
219
+ monkeypatch.delenv("SCIENCEHUB_TOKEN", raising=False)
220
+ served = Site()
221
+ monkeypatch.setenv("SCIENCEHUB_URL", served.base)
222
+ served.project("drift", NOTEBOOK, FUNCTIONS, {"data/argo.csv": CSV})
223
+ served.project(
224
+ "broken",
225
+ BROKEN,
226
+ [{**FUNCTIONS[0], "name": "use", "parameters": [], "signature": "() -> int", "doc": None}],
227
+ {},
228
+ )
229
+ yield served
230
+ served.server.shutdown()
@@ -0,0 +1,100 @@
1
+ import asyncio
2
+ import inspect
3
+ import os
4
+ import pydoc
5
+
6
+ import pytest
7
+
8
+ import sciencehub as sh
9
+
10
+
11
+ def test_describes_the_project_without_downloading_or_importing_it(site):
12
+ project = sh.load("ada/drift")
13
+ assert project.ref == "ada/drift"
14
+ assert sorted(project.functions) == ["doubled", "fall_time", "mean_drift"]
15
+ assert [entry.path for entry in project.files] == ["data/argo.csv"]
16
+ assert project.version.id == "01K7VERSION"
17
+ text = repr(project)
18
+ assert "fall_time(height_m: float, g: float = G) -> float" in text
19
+ assert "Seconds for an object to fall" in text
20
+ assert "class Body" in text
21
+ assert 'sh.load("ada/drift", version="01K7VERSION")' in text
22
+ assert "<table>" in project._repr_html_()
23
+ assert [path for path, _ in site.requests] == ["/api/v1/projects/ada/drift"]
24
+
25
+
26
+ def test_gives_help_the_signature_and_docstring_the_author_wrote(site):
27
+ fall_time = sh.load("ada/drift").fall_time
28
+ assert str(inspect.signature(fall_time)) == "(height_m: float, g: float = G) -> float"
29
+ assert fall_time.__doc__ == "Seconds for an object to fall height_m metres from rest."
30
+ page = pydoc.render_doc(fall_time, renderer=pydoc.plaintext)
31
+ assert "fall_time(height_m: float, g: float = G) -> float" in page
32
+ assert "Seconds for an object to fall" in page
33
+ assert "fall_time" in dir(sh.load("ada/drift"))
34
+
35
+
36
+ def test_calls_a_function_on_this_computer(site):
37
+ project = sh.load("ada/drift")
38
+ assert project.fall_time(4.905) == pytest.approx(1.0)
39
+ assert project.fall_time(height_m=19.62, g=9.81) == pytest.approx(2.0)
40
+
41
+
42
+ def test_runs_a_function_where_its_files_are(site, tmp_path):
43
+ before = os.getcwd()
44
+ assert sh.load("ada/drift").mean_drift() == pytest.approx(2.0)
45
+ assert os.getcwd() == before
46
+
47
+
48
+ def test_awaits_an_async_function(site):
49
+ doubled = sh.load("ada/drift").doubled
50
+ assert inspect.iscoroutinefunction(doubled)
51
+ assert asyncio.run(doubled(21)) == 42
52
+
53
+
54
+ def test_gives_the_class_itself(site):
55
+ body = sh.load("ada/drift").Body
56
+ assert body.__doc__ == "A falling body."
57
+ assert body().mass == 1.0
58
+
59
+
60
+ def test_downloads_a_file_once_and_checks_its_bytes(site):
61
+ project = sh.load("ada/drift")
62
+ path = project.path("data/argo.csv")
63
+ assert path.read_bytes().startswith(b"float,drift")
64
+ asked = len(site.requests)
65
+ assert project.path("data/argo.csv") == path
66
+ assert len(site.requests) == asked
67
+ with pytest.raises(FileNotFoundError, match='Did you mean "data/argo.csv"'):
68
+ project.path("data/argo.cvs")
69
+
70
+
71
+ def test_refuses_bytes_that_are_not_the_ones_the_version_names(site):
72
+ project = sh.load("ada/drift")
73
+ site.routes["/ada/drift/history/01K7VERSION/raw/data/argo.csv"] = (200, "text/csv", b"tampered")
74
+ with pytest.raises(sh.ScienceHubError, match="other bytes"):
75
+ project.path("data/argo.csv")
76
+
77
+
78
+ def test_names_a_missing_package_and_how_to_install_it(site):
79
+ project = sh.load("ada/broken")
80
+ with pytest.raises(sh.MissingDependencyError, match="a_package_nobody_has.*project.install"):
81
+ project.use()
82
+
83
+
84
+ def test_suggests_a_name_for_a_typo(site):
85
+ with pytest.raises(AttributeError, match="Did you mean fall_time"):
86
+ sh.load("ada/drift").fall_tme # noqa: B018
87
+
88
+
89
+ def test_explains_a_missing_project_and_a_bad_reference(site):
90
+ with pytest.raises(sh.NotFoundError, match="No such project"):
91
+ sh.load("ada/nothing")
92
+ with pytest.raises(ValueError, match="owner/project"):
93
+ sh.load("drift")
94
+
95
+
96
+ def test_reads_a_page_address_and_sends_the_token(site, monkeypatch):
97
+ monkeypatch.setenv("SCIENCEHUB_TOKEN", "shp_test")
98
+ project = sh.load(f"{site.base}/ada/drift")
99
+ assert project.slug == "drift"
100
+ assert site.requests[-1] == ("/api/v1/projects/ada/drift", "Bearer shp_test")