shipreal 1.0.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.
- shipreal-1.0.0/.gitignore +11 -0
- shipreal-1.0.0/PKG-INFO +106 -0
- shipreal-1.0.0/README.md +81 -0
- shipreal-1.0.0/pyproject.toml +44 -0
- shipreal-1.0.0/src/shipreal/__init__.py +14 -0
- shipreal-1.0.0/src/shipreal/_client.py +221 -0
- shipreal-1.0.0/src/shipreal/cli.py +125 -0
- shipreal-1.0.0/tests/test_smoke.py +53 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
*.tgz
|
|
3
|
+
.DS_Store
|
|
4
|
+
|
|
5
|
+
# Local artifacts from running `npx skills add` inside this repo to test it.
|
|
6
|
+
# The source of truth for a skill is skills/<id>/SKILL.md, a real file; a
|
|
7
|
+
# consumer install writes .agents/ and symlinks over skills/, which would
|
|
8
|
+
# otherwise get committed as a broken tree.
|
|
9
|
+
.agents/
|
|
10
|
+
.claude/
|
|
11
|
+
skills-lock.json
|
shipreal-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shipreal
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK and CLI for the public ShipReal course API: search the curriculum, read a module, read pricing. No authentication.
|
|
5
|
+
Project-URL: Homepage, https://shipreal.dev/developers
|
|
6
|
+
Project-URL: Documentation, https://shipreal.dev/developers
|
|
7
|
+
Project-URL: Repository, https://github.com/mluggy/shipreal-dev
|
|
8
|
+
Project-URL: Issues, https://github.com/mluggy/shipreal-dev/issues
|
|
9
|
+
Author-email: Michael Lugassy <michael@shipreal.dev>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: agent,api-client,cli,course,curriculum,mcp,sdk,shipreal
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# shipreal
|
|
27
|
+
|
|
28
|
+
SDK and CLI for the public [ShipReal](https://shipreal.dev) course API: search
|
|
29
|
+
the curriculum, read a module, read pricing.
|
|
30
|
+
|
|
31
|
+
Zero dependencies, standard library only. No authentication: the API is public
|
|
32
|
+
read-only reference data about one course, so there is no key to hold and no
|
|
33
|
+
token to refresh. If something asks you for a ShipReal API key, it is not us.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install shipreal
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Library
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from shipreal import ShipReal
|
|
43
|
+
|
|
44
|
+
sr = ShipReal()
|
|
45
|
+
|
|
46
|
+
sr.search("caching") # one page of matches
|
|
47
|
+
list(sr.modules()) # every module, pagination followed
|
|
48
|
+
sr.module("observability") # by slug or partial title
|
|
49
|
+
sr.pricing(region="il") # flattened to one billing region
|
|
50
|
+
sr.course() # totals and subtitle languages
|
|
51
|
+
sr.ask("how do I size a database") # natural language, keyword search behind it
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Errors carry [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem
|
|
55
|
+
details. Branch on `type`, not on `status`: the status says a request failed,
|
|
56
|
+
the type says which failure it was.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from shipreal import ShipReal, ShipRealError
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
ShipReal().module("does-not-exist")
|
|
63
|
+
except ShipRealError as err:
|
|
64
|
+
print(err.status, err.type, err)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## CLI
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
shipreal search caching
|
|
71
|
+
shipreal search --all --json
|
|
72
|
+
shipreal module observability
|
|
73
|
+
shipreal pricing --region il
|
|
74
|
+
shipreal ask "what should I learn before deploying"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Testing against the sandbox
|
|
78
|
+
|
|
79
|
+
Frozen fixture data over the same code path, so a test written against it stays
|
|
80
|
+
green when the course changes:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
sr = ShipReal(sandbox=True)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Fixture prices are 1 unit and fixture links point at `example.invalid`, so
|
|
87
|
+
sandbox data that leaks into real output is obvious on sight. Assert on shape,
|
|
88
|
+
pagination and error format, not on prices or titles. Details at
|
|
89
|
+
[shipreal.dev/sandbox](https://shipreal.dev/sandbox).
|
|
90
|
+
|
|
91
|
+
## There is nothing to write
|
|
92
|
+
|
|
93
|
+
The API has no write path and no purchase endpoint in either environment.
|
|
94
|
+
Enrollment runs through a hosted checkout that a person completes, so if a task
|
|
95
|
+
needs a transaction the answer is a link for a human, not a call.
|
|
96
|
+
|
|
97
|
+
## Other surfaces
|
|
98
|
+
|
|
99
|
+
REST is one of several. There is an
|
|
100
|
+
[OpenAPI 3.1 spec](https://shipreal.dev/openapi.json), an MCP server at
|
|
101
|
+
[/mcp](https://shipreal.dev/mcp), and a
|
|
102
|
+
[JavaScript client](https://www.npmjs.com/package/shipreal) with the same
|
|
103
|
+
surface. Full notes at [shipreal.dev/developers](https://shipreal.dev/developers).
|
|
104
|
+
|
|
105
|
+
MIT licensed. Source at
|
|
106
|
+
[github.com/mluggy/shipreal-dev](https://github.com/mluggy/shipreal-dev).
|
shipreal-1.0.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# shipreal
|
|
2
|
+
|
|
3
|
+
SDK and CLI for the public [ShipReal](https://shipreal.dev) course API: search
|
|
4
|
+
the curriculum, read a module, read pricing.
|
|
5
|
+
|
|
6
|
+
Zero dependencies, standard library only. No authentication: the API is public
|
|
7
|
+
read-only reference data about one course, so there is no key to hold and no
|
|
8
|
+
token to refresh. If something asks you for a ShipReal API key, it is not us.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
pip install shipreal
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Library
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from shipreal import ShipReal
|
|
18
|
+
|
|
19
|
+
sr = ShipReal()
|
|
20
|
+
|
|
21
|
+
sr.search("caching") # one page of matches
|
|
22
|
+
list(sr.modules()) # every module, pagination followed
|
|
23
|
+
sr.module("observability") # by slug or partial title
|
|
24
|
+
sr.pricing(region="il") # flattened to one billing region
|
|
25
|
+
sr.course() # totals and subtitle languages
|
|
26
|
+
sr.ask("how do I size a database") # natural language, keyword search behind it
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Errors carry [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem
|
|
30
|
+
details. Branch on `type`, not on `status`: the status says a request failed,
|
|
31
|
+
the type says which failure it was.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from shipreal import ShipReal, ShipRealError
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
ShipReal().module("does-not-exist")
|
|
38
|
+
except ShipRealError as err:
|
|
39
|
+
print(err.status, err.type, err)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## CLI
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
shipreal search caching
|
|
46
|
+
shipreal search --all --json
|
|
47
|
+
shipreal module observability
|
|
48
|
+
shipreal pricing --region il
|
|
49
|
+
shipreal ask "what should I learn before deploying"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Testing against the sandbox
|
|
53
|
+
|
|
54
|
+
Frozen fixture data over the same code path, so a test written against it stays
|
|
55
|
+
green when the course changes:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
sr = ShipReal(sandbox=True)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Fixture prices are 1 unit and fixture links point at `example.invalid`, so
|
|
62
|
+
sandbox data that leaks into real output is obvious on sight. Assert on shape,
|
|
63
|
+
pagination and error format, not on prices or titles. Details at
|
|
64
|
+
[shipreal.dev/sandbox](https://shipreal.dev/sandbox).
|
|
65
|
+
|
|
66
|
+
## There is nothing to write
|
|
67
|
+
|
|
68
|
+
The API has no write path and no purchase endpoint in either environment.
|
|
69
|
+
Enrollment runs through a hosted checkout that a person completes, so if a task
|
|
70
|
+
needs a transaction the answer is a link for a human, not a call.
|
|
71
|
+
|
|
72
|
+
## Other surfaces
|
|
73
|
+
|
|
74
|
+
REST is one of several. There is an
|
|
75
|
+
[OpenAPI 3.1 spec](https://shipreal.dev/openapi.json), an MCP server at
|
|
76
|
+
[/mcp](https://shipreal.dev/mcp), and a
|
|
77
|
+
[JavaScript client](https://www.npmjs.com/package/shipreal) with the same
|
|
78
|
+
surface. Full notes at [shipreal.dev/developers](https://shipreal.dev/developers).
|
|
79
|
+
|
|
80
|
+
MIT licensed. Source at
|
|
81
|
+
[github.com/mluggy/shipreal-dev](https://github.com/mluggy/shipreal-dev).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shipreal"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK and CLI for the public ShipReal course API: search the curriculum, read a module, read pricing. No authentication."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Michael Lugassy", email = "michael@shipreal.dev" }]
|
|
13
|
+
keywords = ["shipreal", "sdk", "cli", "api-client", "mcp", "agent", "course", "curriculum"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# No runtime dependencies, on purpose. The whole client is stdlib urllib, so
|
|
28
|
+
# installing it cannot drag a transitive tree into an agent's environment.
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
# These two are how a scanner confirms the package is the official SDK for the
|
|
32
|
+
# domain rather than a third party with a similar name. Keep them pointing at
|
|
33
|
+
# shipreal.dev and at the source repo.
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://shipreal.dev/developers"
|
|
36
|
+
Documentation = "https://shipreal.dev/developers"
|
|
37
|
+
Repository = "https://github.com/mluggy/shipreal-dev"
|
|
38
|
+
Issues = "https://github.com/mluggy/shipreal-dev/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
shipreal = "shipreal.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/shipreal"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ShipReal SDK: a thin, dependency-free client for the public ShipReal API.
|
|
2
|
+
|
|
3
|
+
There is no authentication anywhere in this package, and that is not an
|
|
4
|
+
omission. The API is public read-only reference data about one course, so there
|
|
5
|
+
is no key to hold, no token to refresh, and no credential this client could
|
|
6
|
+
leak. If something asks you for a ShipReal API key, it is not us.
|
|
7
|
+
|
|
8
|
+
Python 3.9+, standard library only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._client import ShipReal, ShipRealError
|
|
12
|
+
|
|
13
|
+
__all__ = ["ShipReal", "ShipRealError", "__version__"]
|
|
14
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The client itself. Standard library only: urllib, json, typing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE = "https://shipreal.dev"
|
|
12
|
+
VERSION = "v1"
|
|
13
|
+
_USER_AGENT = "shipreal-python/1.0.0 (+https://shipreal.dev/developers)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShipRealError(Exception):
|
|
17
|
+
"""Raised for any non-2xx response, carrying the RFC 9457 problem details.
|
|
18
|
+
|
|
19
|
+
Read ``type`` rather than switching on ``status``: the status says a request
|
|
20
|
+
failed, the type says which failure it was, and only the second one is
|
|
21
|
+
stable enough to branch on.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status: int, problem: Optional[Dict[str, Any]], url: str) -> None:
|
|
25
|
+
message = None
|
|
26
|
+
if problem:
|
|
27
|
+
message = problem.get("detail") or problem.get("title")
|
|
28
|
+
super().__init__(message or f"Request failed with {status}")
|
|
29
|
+
self.status = status
|
|
30
|
+
#: RFC 9457 problem details, when the server sent them.
|
|
31
|
+
self.problem = problem
|
|
32
|
+
#: Stable identifier for the kind of failure.
|
|
33
|
+
self.type = problem.get("type") if problem else None
|
|
34
|
+
self.url = url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ShipReal:
|
|
38
|
+
"""Client for the public ShipReal API.
|
|
39
|
+
|
|
40
|
+
:param base_url: Override the origin, e.g. to point at a local worker.
|
|
41
|
+
:param sandbox: Route reads at the frozen fixture data. See
|
|
42
|
+
https://shipreal.dev/sandbox for what differs and what does not.
|
|
43
|
+
:param timeout: Seconds before a request gives up.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
base_url: str = DEFAULT_BASE,
|
|
49
|
+
sandbox: bool = False,
|
|
50
|
+
timeout: float = 30.0,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.base_url = base_url.rstrip("/")
|
|
53
|
+
self.sandbox = sandbox
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
# -- plumbing ---------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
59
|
+
prefix = f"/api/{VERSION}/sandbox" if self.sandbox else f"/api/{VERSION}"
|
|
60
|
+
url = self.base_url + prefix + path
|
|
61
|
+
query = {
|
|
62
|
+
k: str(v)
|
|
63
|
+
for k, v in (params or {}).items()
|
|
64
|
+
if v is not None and v != ""
|
|
65
|
+
}
|
|
66
|
+
if query:
|
|
67
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
68
|
+
return url
|
|
69
|
+
|
|
70
|
+
def _request(
|
|
71
|
+
self,
|
|
72
|
+
url: str,
|
|
73
|
+
body: Optional[Dict[str, Any]] = None,
|
|
74
|
+
accept: str = "application/json",
|
|
75
|
+
) -> Any:
|
|
76
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
77
|
+
headers = {"accept": accept, "user-agent": _USER_AGENT}
|
|
78
|
+
if data is not None:
|
|
79
|
+
headers["content-type"] = "application/json"
|
|
80
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
83
|
+
return json.loads(res.read().decode("utf-8"))
|
|
84
|
+
except urllib.error.HTTPError as err:
|
|
85
|
+
# The error body is where the problem details live, so it gets
|
|
86
|
+
# parsed rather than discarded. A body that is not JSON is not
|
|
87
|
+
# itself an error worth raising over: the status still stands.
|
|
88
|
+
problem = None
|
|
89
|
+
try:
|
|
90
|
+
problem = json.loads(err.read().decode("utf-8"))
|
|
91
|
+
except Exception:
|
|
92
|
+
problem = None
|
|
93
|
+
raise ShipRealError(err.code, problem, url) from None
|
|
94
|
+
|
|
95
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
96
|
+
return self._request(self._url(path, params))
|
|
97
|
+
|
|
98
|
+
# -- reads ------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def search(
|
|
101
|
+
self,
|
|
102
|
+
query: Optional[str] = None,
|
|
103
|
+
page: Optional[int] = None,
|
|
104
|
+
limit: Optional[int] = None,
|
|
105
|
+
cursor: Optional[str] = None,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Search the curriculum. Without a query, returns every module in
|
|
108
|
+
course order.
|
|
109
|
+
|
|
110
|
+
Matching is a case-insensitive substring over title, description and
|
|
111
|
+
part name, so an empty result means the course does not cover that
|
|
112
|
+
topic under that name, rather than that the search was too clever.
|
|
113
|
+
"""
|
|
114
|
+
return self._get(
|
|
115
|
+
"/modules", {"q": query, "page": page, "limit": limit, "cursor": cursor}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def modules(self, query: Optional[str] = None) -> Iterator[Dict[str, Any]]:
|
|
119
|
+
"""Every matching module, following pagination for you."""
|
|
120
|
+
cursor = None
|
|
121
|
+
while True:
|
|
122
|
+
page = self.search(query, limit=100, cursor=cursor)
|
|
123
|
+
for module in page.get("data", []):
|
|
124
|
+
yield module
|
|
125
|
+
cursor = (page.get("pagination") or {}).get("nextCursor")
|
|
126
|
+
if not cursor:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
def module(self, slug_or_title: str) -> Dict[str, Any]:
|
|
130
|
+
"""One module by slug, or by an exact or partial title match."""
|
|
131
|
+
if not slug_or_title:
|
|
132
|
+
raise TypeError("module(slug_or_title) needs an argument")
|
|
133
|
+
return self._get("/modules/" + urllib.parse.quote(slug_or_title, safe=""))
|
|
134
|
+
|
|
135
|
+
def pricing(self, region: Optional[str] = None) -> Dict[str, Any]:
|
|
136
|
+
"""Current plans and prices.
|
|
137
|
+
|
|
138
|
+
Two regional prices are live at once, so quoting one without naming its
|
|
139
|
+
region is misleading. Pass ``region`` ("intl" or "il") when you know
|
|
140
|
+
which one applies and the response is flattened to that region.
|
|
141
|
+
"""
|
|
142
|
+
every = self._get("/pricing")
|
|
143
|
+
if region not in ("intl", "il"):
|
|
144
|
+
return every
|
|
145
|
+
complete = dict(every["complete"][region])
|
|
146
|
+
complete["url"] = every["complete"].get("url")
|
|
147
|
+
teams = dict(every["teams"][region])
|
|
148
|
+
teams["minSeats"] = every["teams"].get("minSeats")
|
|
149
|
+
teams["perSeat"] = True
|
|
150
|
+
return {
|
|
151
|
+
"region": region,
|
|
152
|
+
"free": every["free"],
|
|
153
|
+
"complete": complete,
|
|
154
|
+
"teams": teams,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def course(self) -> Dict[str, Any]:
|
|
158
|
+
"""Totals, language and the subtitle languages."""
|
|
159
|
+
return self._get("/course")
|
|
160
|
+
|
|
161
|
+
def batch(self, requests: Sequence[Dict[str, str]]) -> Dict[str, Any]:
|
|
162
|
+
"""Several reads in one round trip, up to 20.
|
|
163
|
+
|
|
164
|
+
Each item comes back with its own status, so check per item rather than
|
|
165
|
+
assuming the whole batch succeeded.
|
|
166
|
+
"""
|
|
167
|
+
items: List[Dict[str, str]] = list(requests)
|
|
168
|
+
if len(items) > 20:
|
|
169
|
+
raise ValueError("batch takes at most 20 requests")
|
|
170
|
+
return self._request(
|
|
171
|
+
f"{self.base_url}/api/{VERSION}/batch", body={"requests": items}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def ask(self, query: str) -> Dict[str, Any]:
|
|
175
|
+
"""Ask in natural language (NLWeb).
|
|
176
|
+
|
|
177
|
+
There is no model behind this: it runs the same keyword search, which
|
|
178
|
+
means it says so when nothing matches instead of inventing a module.
|
|
179
|
+
"""
|
|
180
|
+
if not query:
|
|
181
|
+
raise TypeError("ask(query) needs a question")
|
|
182
|
+
return self._request(f"{self.base_url}/ask", body={"query": query})
|
|
183
|
+
|
|
184
|
+
def ask_stream(self, query: str) -> Iterator[Dict[str, Any]]:
|
|
185
|
+
"""The same question, streamed. Yields NLWeb events as they arrive:
|
|
186
|
+
``start``, then one ``result`` per hit, then ``complete``.
|
|
187
|
+
"""
|
|
188
|
+
if not query:
|
|
189
|
+
raise TypeError("ask_stream(query) needs a question")
|
|
190
|
+
url = f"{self.base_url}/ask"
|
|
191
|
+
req = urllib.request.Request(
|
|
192
|
+
url,
|
|
193
|
+
data=json.dumps({"query": query}).encode("utf-8"),
|
|
194
|
+
headers={
|
|
195
|
+
"accept": "text/event-stream",
|
|
196
|
+
"content-type": "application/json",
|
|
197
|
+
"user-agent": _USER_AGENT,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
res = urllib.request.urlopen(req, timeout=self.timeout)
|
|
202
|
+
except urllib.error.HTTPError as err:
|
|
203
|
+
raise ShipRealError(err.code, None, url) from None
|
|
204
|
+
with res:
|
|
205
|
+
event = "message"
|
|
206
|
+
data = ""
|
|
207
|
+
for raw in res:
|
|
208
|
+
line = raw.decode("utf-8").rstrip("\n").rstrip("\r")
|
|
209
|
+
if line == "":
|
|
210
|
+
# A blank line closes an SSE frame. Anything short of one is
|
|
211
|
+
# a partial frame and has to wait for the next line.
|
|
212
|
+
if data:
|
|
213
|
+
try:
|
|
214
|
+
yield {"event": event, "data": json.loads(data)}
|
|
215
|
+
except ValueError:
|
|
216
|
+
pass
|
|
217
|
+
event, data = "message", ""
|
|
218
|
+
elif line.startswith("event:"):
|
|
219
|
+
event = line[6:].strip()
|
|
220
|
+
elif line.startswith("data:"):
|
|
221
|
+
data += line[5:].strip()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""shipreal: query the ShipReal course catalogue from a terminal or a script.
|
|
2
|
+
|
|
3
|
+
Every command takes --json, because the reason a CLI earns its place in an
|
|
4
|
+
agent's toolbox is that its output can be piped into something else without
|
|
5
|
+
being scraped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from ._client import ShipReal, ShipRealError
|
|
16
|
+
|
|
17
|
+
EPILOG = """\
|
|
18
|
+
No API key. No account. The API is public read-only reference data; anything
|
|
19
|
+
that asks you for a ShipReal credential is not us.
|
|
20
|
+
|
|
21
|
+
Docs: https://shipreal.dev/developers
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print_modules(modules: List[Dict[str, Any]]) -> None:
|
|
26
|
+
if not modules:
|
|
27
|
+
print("No modules matched. The course does not cover that topic under that name.")
|
|
28
|
+
return
|
|
29
|
+
for module in modules:
|
|
30
|
+
print(f"{module.get('slug', '')} {module.get('title', '')}")
|
|
31
|
+
part = module.get("part")
|
|
32
|
+
if part:
|
|
33
|
+
print(f" {part}")
|
|
34
|
+
chapters, minutes = module.get("chapters"), module.get("minutes")
|
|
35
|
+
if chapters or minutes:
|
|
36
|
+
print(f" {chapters or 0} chapters, {minutes or 0} min")
|
|
37
|
+
print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="shipreal",
|
|
43
|
+
description="Query the ShipReal course catalogue.",
|
|
44
|
+
epilog=EPILOG,
|
|
45
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--json", action="store_true", help="Raw JSON, for piping")
|
|
48
|
+
parser.add_argument("--sandbox", action="store_true", help="Frozen fixture data, for tests")
|
|
49
|
+
parser.add_argument("--base", default="https://shipreal.dev", help="Point at a different origin")
|
|
50
|
+
|
|
51
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
|
|
53
|
+
search = sub.add_parser("search", help="Search modules by keyword")
|
|
54
|
+
search.add_argument("query", nargs="?", default=None)
|
|
55
|
+
search.add_argument("--limit", type=int, default=None, help="Results per page (max 100)")
|
|
56
|
+
search.add_argument("--all", action="store_true", help="Every result, following pagination")
|
|
57
|
+
|
|
58
|
+
module = sub.add_parser("module", help="One module")
|
|
59
|
+
module.add_argument("slug_or_title")
|
|
60
|
+
|
|
61
|
+
pricing = sub.add_parser("pricing", help="Plans and prices")
|
|
62
|
+
pricing.add_argument("--region", choices=["intl", "il"], default=None)
|
|
63
|
+
|
|
64
|
+
sub.add_parser("course", help="Totals and subtitle languages")
|
|
65
|
+
|
|
66
|
+
ask = sub.add_parser("ask", help="Natural-language query")
|
|
67
|
+
ask.add_argument("question", nargs="+")
|
|
68
|
+
ask.add_argument("--stream", action="store_true", help="Stream as it arrives")
|
|
69
|
+
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: List[str] | None = None) -> int:
|
|
74
|
+
args = _build_parser().parse_args(argv)
|
|
75
|
+
client = ShipReal(base_url=args.base, sandbox=args.sandbox)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
if args.command == "search":
|
|
79
|
+
if args.all:
|
|
80
|
+
modules = list(client.modules(args.query))
|
|
81
|
+
print(json.dumps(modules, indent=2)) if args.json else _print_modules(modules)
|
|
82
|
+
else:
|
|
83
|
+
page = client.search(args.query, limit=args.limit)
|
|
84
|
+
print(json.dumps(page, indent=2)) if args.json else _print_modules(page.get("data", []))
|
|
85
|
+
|
|
86
|
+
elif args.command == "module":
|
|
87
|
+
found = client.module(args.slug_or_title)
|
|
88
|
+
if args.json:
|
|
89
|
+
print(json.dumps(found, indent=2))
|
|
90
|
+
else:
|
|
91
|
+
print(found.get("title", ""))
|
|
92
|
+
print(found.get("part", ""))
|
|
93
|
+
print()
|
|
94
|
+
print(found.get("description", ""))
|
|
95
|
+
|
|
96
|
+
elif args.command == "pricing":
|
|
97
|
+
prices = client.pricing(region=args.region)
|
|
98
|
+
print(json.dumps(prices, indent=2))
|
|
99
|
+
|
|
100
|
+
elif args.command == "course":
|
|
101
|
+
print(json.dumps(client.course(), indent=2))
|
|
102
|
+
|
|
103
|
+
elif args.command == "ask":
|
|
104
|
+
question = " ".join(args.question)
|
|
105
|
+
if args.stream:
|
|
106
|
+
for frame in client.ask_stream(question):
|
|
107
|
+
print(json.dumps(frame) if args.json else f"{frame['event']}: {frame['data']}")
|
|
108
|
+
else:
|
|
109
|
+
print(json.dumps(client.ask(question), indent=2))
|
|
110
|
+
|
|
111
|
+
except ShipRealError as err:
|
|
112
|
+
# The problem type is the useful half of a failure, so it goes to
|
|
113
|
+
# stderr next to the message rather than being swallowed.
|
|
114
|
+
print(f"error {err.status}: {err}", file=sys.stderr)
|
|
115
|
+
if err.type:
|
|
116
|
+
print(f" type: {err.type}", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
return 130
|
|
120
|
+
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Smoke tests against the live sandbox.
|
|
2
|
+
|
|
3
|
+
They hit the network on purpose. The sandbox exists precisely so a test can
|
|
4
|
+
depend on fixed responses without depending on the course staying still, and a
|
|
5
|
+
client that is never exercised against the real transport is a client that
|
|
6
|
+
passes its tests and fails in use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
from shipreal import ShipReal, ShipRealError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SandboxTests(unittest.TestCase):
|
|
15
|
+
def setUp(self) -> None:
|
|
16
|
+
self.sr = ShipReal(sandbox=True)
|
|
17
|
+
|
|
18
|
+
def test_search_returns_fixture_modules(self) -> None:
|
|
19
|
+
page = self.sr.search()
|
|
20
|
+
self.assertIn("data", page)
|
|
21
|
+
self.assertTrue(page["data"])
|
|
22
|
+
self.assertTrue(page["data"][0]["slug"].startswith("sandbox-module-"))
|
|
23
|
+
|
|
24
|
+
def test_module_by_slug(self) -> None:
|
|
25
|
+
module = self.sr.module("sandbox-module-1")
|
|
26
|
+
self.assertEqual(module["slug"], "sandbox-module-1")
|
|
27
|
+
self.assertIn("description", module)
|
|
28
|
+
|
|
29
|
+
def test_modules_iterates_every_page(self) -> None:
|
|
30
|
+
every = list(self.sr.modules())
|
|
31
|
+
self.assertTrue(every)
|
|
32
|
+
self.assertEqual(len({m["slug"] for m in every}), len(every))
|
|
33
|
+
|
|
34
|
+
def test_pricing_flattens_to_one_region(self) -> None:
|
|
35
|
+
flat = self.sr.pricing(region="intl")
|
|
36
|
+
self.assertEqual(flat["region"], "intl")
|
|
37
|
+
self.assertIn("now", flat["complete"])
|
|
38
|
+
# Unflattened keeps both regions side by side.
|
|
39
|
+
self.assertIn("intl", self.sr.pricing()["complete"])
|
|
40
|
+
|
|
41
|
+
def test_missing_module_raises_problem_details(self) -> None:
|
|
42
|
+
with self.assertRaises(ShipRealError) as caught:
|
|
43
|
+
self.sr.module("does-not-exist")
|
|
44
|
+
self.assertEqual(caught.exception.status, 404)
|
|
45
|
+
self.assertIsNotNone(caught.exception.problem)
|
|
46
|
+
|
|
47
|
+
def test_batch_rejects_more_than_twenty(self) -> None:
|
|
48
|
+
with self.assertRaises(ValueError):
|
|
49
|
+
self.sr.batch([{"path": "/modules"}] * 21)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|