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.
@@ -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
@@ -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).
@@ -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()