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.
- sciencehub-0.1.0/.gitignore +5 -0
- sciencehub-0.1.0/PKG-INFO +120 -0
- sciencehub-0.1.0/README.md +103 -0
- sciencehub-0.1.0/pyproject.toml +34 -0
- sciencehub-0.1.0/src/sciencehub/__init__.py +28 -0
- sciencehub-0.1.0/src/sciencehub/_http.py +82 -0
- sciencehub-0.1.0/src/sciencehub/_project.py +456 -0
- sciencehub-0.1.0/src/sciencehub/py.typed +0 -0
- sciencehub-0.1.0/tests/conftest.py +230 -0
- sciencehub-0.1.0/tests/test_load.py +100 -0
|
@@ -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")
|