vibeinfra-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ # macOS system files
2
+ .DS_Store
3
+ .AppleDouble
4
+ .LSOverride
5
+ Thumbs.db
6
+
7
+ # Environment variables & secrets
8
+ .env
9
+ .env.local
10
+ .env.production
11
+ *.pem
12
+ *.key
13
+ *.crt
14
+ vps-ssh.txt
15
+
16
+ # Go build binaries & logs
17
+ /labs-system/backend/labs-api
18
+ /labs-system/cmd/vibelab/vibelab
19
+ backend/backend/bin/
20
+ /bin/
21
+ *.exe
22
+ *.exe~
23
+ *.dll
24
+ *.so
25
+ *.dylib
26
+ *.test
27
+ *.out
28
+ go.work
29
+
30
+ # Node & Cloudflare Wrangler
31
+ node_modules/
32
+ .wrangler/
33
+ dist/
34
+ build/
35
+ out/
36
+ .next/
37
+ _next/
38
+ frontend-mockup/out/
39
+ frontend-mockup/.next/
40
+ npm-debug.log*
41
+ yarn-debug.log*
42
+ yarn-error.log*
43
+
44
+ # Docker & Database Data
45
+ pgdata/
46
+ docker-data/
47
+ *.db
48
+ *.sqlite
49
+ benchmark_results/
50
+
51
+ # Cloudflare Tunnel credentials
52
+ cloudflared/
53
+
54
+ # Secrets and machine-local tool state.
55
+ #
56
+ # This section previously ignored *.json wholesale with a hand-maintained list of `!` exceptions.
57
+ # That is backwards: every NEW json file in the repo -- course data, a config, a fixture -- was
58
+ # silently untracked until somebody noticed and added another exception. It had already bitten:
59
+ # frontend-mockup/public/labs/incident_blueprints.json was untracked while the root copy of the
60
+ # same file was tracked, so the static-mirror build source was missing its blueprint data.
61
+ # Ignore the specific things that must not be committed instead.
62
+ .mcp.json
63
+ .claude/settings.local.json
64
+ .claude/launch.json
65
+ .agentsroom/*.json
66
+ .agents/mcp_config.json
67
+ .obsidian/
68
+ signature-mapping.json
69
+
70
+ # Generated Lighthouse audit output (one-off artifacts, not source).
71
+ lighthouse-report*.json
72
+
73
+ !openspec.yaml
74
+ !.openspec.yaml
75
+
76
+ # IDE & Editor files
77
+ .idea/
78
+ .vscode/
79
+ *.swp
80
+ *.swo
81
+ *~
82
+ dump.rdb
83
+
84
+ # Python bytecode & cache
85
+ __pycache__/
86
+ *.py[cod]
87
+ *$py.class
88
+ .pytest_cache/
89
+ .venv/
90
+ venv/
91
+
92
+ # External tools & standalone repos
93
+ dockscope/
94
+ public-repos/
95
+
96
+ .mcp.json
97
+ .agentsroom/handoff-transcript-*.txt
98
+ .agentsroom/handoff-summary-*.md
99
+ .agents/mcp_config.json
100
+
101
+ # Local research books and PDFs
102
+ books/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VibeInfra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: vibeinfra-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the VibeInfra API - browse the SRE incident catalog, read live sandbox capacity and track learner progress.
5
+ Project-URL: Homepage, https://vibeinfra.id
6
+ Project-URL: Documentation, https://vibeinfra.id/docs/api/
7
+ Project-URL: Developer Portal, https://vibeinfra.id/developers/
8
+ Project-URL: OpenAPI Description, https://vibeinfra.id/openapi.json
9
+ Project-URL: Source, https://github.com/ridwansswnto/vibeinfraid
10
+ Project-URL: Issues, https://github.com/ridwansswnto/vibeinfraid/issues
11
+ Author-email: VibeInfra <hello@vibeinfra.id>
12
+ License: MIT
13
+ License-File: LICENSE
14
+ Keywords: api-client,devops,incident-response,infrastructure,kubernetes,observability,sdk,sre,vibeinfra
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Education
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # vibeinfra
30
+
31
+ Official Python client for the [VibeInfra](https://vibeinfra.id) API.
32
+
33
+ VibeInfra is a learn-by-doing infrastructure engineering platform: engineers troubleshoot
34
+ realistic production incidents — Linux kernel, Docker, Kubernetes, Nginx, PostgreSQL, Kafka,
35
+ AWS — inside live disposable multi-container sandboxes with automated grading.
36
+
37
+ The entire catalog surface is **unauthenticated**, so reading it needs no credentials.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install vibeinfra
43
+ ```
44
+
45
+ Requires Python 3.9+. Standard library only — no transitive dependencies.
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ from vibeinfra import VibeInfra
51
+
52
+ vibe = VibeInfra()
53
+
54
+ # Every incident and course in the catalog.
55
+ for course in vibe.list_courses():
56
+ print(course["id"], course["title"])
57
+
58
+ # One blueprint, including objectives and tech stack.
59
+ incident = vibe.get_course("incident-22-k8s-crashloop")
60
+
61
+ # Find something to work on.
62
+ kafka = vibe.search_courses(query="kafka", difficulty="Expert")
63
+
64
+ # Is the platform busy right now?
65
+ print(vibe.get_capacity()["queue_length"])
66
+ ```
67
+
68
+ ## Authenticated calls
69
+
70
+ Learner-scoped endpoints take the session JWT minted by the GitHub/Google OAuth callback:
71
+
72
+ ```python
73
+ import os
74
+
75
+ vibe = VibeInfra(token=os.environ["VIBEINFRA_TOKEN"])
76
+ vibe.list_completions()
77
+ ```
78
+
79
+ ## Constructor arguments
80
+
81
+ | Argument | Default | Purpose |
82
+ | ---------- | -------------------------- | --------------------------------------------- |
83
+ | `base_url` | `https://api.vibeinfra.id` | Point at a local backend during development. |
84
+ | `token` | `None` | Session JWT, sent as `Authorization: Bearer`. |
85
+ | `timeout` | `15.0` | Per-request timeout, in seconds. |
86
+ | `opener` | default urllib opener | Injected transport, for tests and proxies. |
87
+
88
+ ## Errors
89
+
90
+ - `VibeInfraApiError` — the server answered with a non-2xx status. Carries `status`, `url`,
91
+ the decoded `body`, and the `is_unauthorized` / `is_rate_limited` properties.
92
+ - `VibeInfraNetworkError` — the request never reached the server, or the timeout fired. The
93
+ original failure is on `__cause__`.
94
+
95
+ Both subclass `VibeInfraError`, so `except VibeInfraError` catches everything this client raises.
96
+
97
+ ## Methods
98
+
99
+ **Catalog** — `list_courses`, `get_course`, `search_courses`, `get_glossary`, `list_credit_packs`
100
+ **Platform** — `get_health`, `get_capacity`, `get_public_settings`, `get_public_telemetry`, `get_leaderboard`
101
+ **Labs & account** — `start_lab`, `get_session`, `get_profile`, `list_completions`
102
+
103
+ `search_courses` filters client-side: the API has no search endpoint because the catalog is
104
+ small enough to fetch whole.
105
+
106
+ ## Related
107
+
108
+ - API reference — <https://vibeinfra.id/docs/api/>
109
+ - OpenAPI description — <https://vibeinfra.id/openapi.json>
110
+ - MCP server (`@vibeinfra/mcp-server`) — <https://vibeinfra.id/docs/mcp/>
111
+ - Developer portal — <https://vibeinfra.id/developers/>
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ pip install -e ".[dev]"
117
+ pytest
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,94 @@
1
+ # vibeinfra
2
+
3
+ Official Python client for the [VibeInfra](https://vibeinfra.id) API.
4
+
5
+ VibeInfra is a learn-by-doing infrastructure engineering platform: engineers troubleshoot
6
+ realistic production incidents — Linux kernel, Docker, Kubernetes, Nginx, PostgreSQL, Kafka,
7
+ AWS — inside live disposable multi-container sandboxes with automated grading.
8
+
9
+ The entire catalog surface is **unauthenticated**, so reading it needs no credentials.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install vibeinfra
15
+ ```
16
+
17
+ Requires Python 3.9+. Standard library only — no transitive dependencies.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from vibeinfra import VibeInfra
23
+
24
+ vibe = VibeInfra()
25
+
26
+ # Every incident and course in the catalog.
27
+ for course in vibe.list_courses():
28
+ print(course["id"], course["title"])
29
+
30
+ # One blueprint, including objectives and tech stack.
31
+ incident = vibe.get_course("incident-22-k8s-crashloop")
32
+
33
+ # Find something to work on.
34
+ kafka = vibe.search_courses(query="kafka", difficulty="Expert")
35
+
36
+ # Is the platform busy right now?
37
+ print(vibe.get_capacity()["queue_length"])
38
+ ```
39
+
40
+ ## Authenticated calls
41
+
42
+ Learner-scoped endpoints take the session JWT minted by the GitHub/Google OAuth callback:
43
+
44
+ ```python
45
+ import os
46
+
47
+ vibe = VibeInfra(token=os.environ["VIBEINFRA_TOKEN"])
48
+ vibe.list_completions()
49
+ ```
50
+
51
+ ## Constructor arguments
52
+
53
+ | Argument | Default | Purpose |
54
+ | ---------- | -------------------------- | --------------------------------------------- |
55
+ | `base_url` | `https://api.vibeinfra.id` | Point at a local backend during development. |
56
+ | `token` | `None` | Session JWT, sent as `Authorization: Bearer`. |
57
+ | `timeout` | `15.0` | Per-request timeout, in seconds. |
58
+ | `opener` | default urllib opener | Injected transport, for tests and proxies. |
59
+
60
+ ## Errors
61
+
62
+ - `VibeInfraApiError` — the server answered with a non-2xx status. Carries `status`, `url`,
63
+ the decoded `body`, and the `is_unauthorized` / `is_rate_limited` properties.
64
+ - `VibeInfraNetworkError` — the request never reached the server, or the timeout fired. The
65
+ original failure is on `__cause__`.
66
+
67
+ Both subclass `VibeInfraError`, so `except VibeInfraError` catches everything this client raises.
68
+
69
+ ## Methods
70
+
71
+ **Catalog** — `list_courses`, `get_course`, `search_courses`, `get_glossary`, `list_credit_packs`
72
+ **Platform** — `get_health`, `get_capacity`, `get_public_settings`, `get_public_telemetry`, `get_leaderboard`
73
+ **Labs & account** — `start_lab`, `get_session`, `get_profile`, `list_completions`
74
+
75
+ `search_courses` filters client-side: the API has no search endpoint because the catalog is
76
+ small enough to fetch whole.
77
+
78
+ ## Related
79
+
80
+ - API reference — <https://vibeinfra.id/docs/api/>
81
+ - OpenAPI description — <https://vibeinfra.id/openapi.json>
82
+ - MCP server (`@vibeinfra/mcp-server`) — <https://vibeinfra.id/docs/mcp/>
83
+ - Developer portal — <https://vibeinfra.id/developers/>
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ pip install -e ".[dev]"
89
+ pytest
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vibeinfra-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the VibeInfra API - browse the SRE incident catalog, read live sandbox capacity and track learner progress."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "VibeInfra", email = "hello@vibeinfra.id" }]
13
+ keywords = [
14
+ "vibeinfra",
15
+ "sre",
16
+ "incident-response",
17
+ "infrastructure",
18
+ "devops",
19
+ "kubernetes",
20
+ "observability",
21
+ "api-client",
22
+ "sdk",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: System Administrators",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Topic :: Education",
32
+ "Topic :: System :: Systems Administration",
33
+ "Typing :: Typed",
34
+ ]
35
+ dependencies = []
36
+
37
+ [project.urls]
38
+ Homepage = "https://vibeinfra.id"
39
+ Documentation = "https://vibeinfra.id/docs/api/"
40
+ "Developer Portal" = "https://vibeinfra.id/developers/"
41
+ "OpenAPI Description" = "https://vibeinfra.id/openapi.json"
42
+ Source = "https://github.com/ridwansswnto/vibeinfraid"
43
+ Issues = "https://github.com/ridwansswnto/vibeinfraid/issues"
44
+
45
+ [project.optional-dependencies]
46
+ dev = ["pytest>=7"]
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/vibeinfra"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["src/vibeinfra", "README.md", "LICENSE", "tests"]
@@ -0,0 +1,41 @@
1
+ """Official Python client for the VibeInfra API.
2
+
3
+ VibeInfra is a learn-by-doing infrastructure engineering platform: engineers troubleshoot
4
+ realistic production incidents inside live disposable multi-container sandboxes with automated
5
+ grading.
6
+
7
+ The whole public surface of ``https://api.vibeinfra.id`` is unauthenticated, so the common
8
+ case -- an agent or a script reading the incident catalog -- needs no credentials::
9
+
10
+ from vibeinfra import VibeInfra
11
+
12
+ vibe = VibeInfra()
13
+ for course in vibe.list_courses():
14
+ print(course["id"], course["title"])
15
+
16
+ The client uses only the standard library, so it adds no transitive dependencies to whatever
17
+ it is embedded in. The API description it mirrors lives at https://vibeinfra.id/openapi.json.
18
+ """
19
+
20
+ from .client import (
21
+ API_VERSION,
22
+ DEFAULT_BASE_URL,
23
+ DEFAULT_TIMEOUT,
24
+ VibeInfra,
25
+ VibeInfraApiError,
26
+ VibeInfraError,
27
+ VibeInfraNetworkError,
28
+ )
29
+
30
+ __all__ = [
31
+ "API_VERSION",
32
+ "DEFAULT_BASE_URL",
33
+ "DEFAULT_TIMEOUT",
34
+ "VibeInfra",
35
+ "VibeInfraApiError",
36
+ "VibeInfraError",
37
+ "VibeInfraNetworkError",
38
+ "__version__",
39
+ ]
40
+
41
+ __version__ = "0.1.0"
@@ -0,0 +1,259 @@
1
+ """HTTP client for the VibeInfra API.
2
+
3
+ Deliberately standard-library only: ``requests`` would be more idiomatic, but this package is
4
+ meant to be dropped into agent runtimes and CI images where every added dependency is another
5
+ thing to pin, audit and resolve. ``urllib`` is enough for a JSON API with no streaming.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any, Dict, Iterable, List, Mapping, Optional
15
+
16
+ #: Default production origin. Override with ``base_url`` to point at a local backend.
17
+ DEFAULT_BASE_URL = "https://api.vibeinfra.id"
18
+
19
+ #: API version prefix, so a contract bump does not require editing every method here.
20
+ API_VERSION = "v1"
21
+
22
+ #: Per-request timeout in seconds.
23
+ DEFAULT_TIMEOUT = 15.0
24
+
25
+ _USER_AGENT = "vibeinfra-python/0.1.0 (+https://vibeinfra.id/developers/)"
26
+
27
+
28
+ class VibeInfraError(Exception):
29
+ """Base class for every error this client raises."""
30
+
31
+
32
+ class VibeInfraApiError(VibeInfraError):
33
+ """The call reached the server and came back with a non-2xx status.
34
+
35
+ ``body`` holds the decoded response when the server sent one, because the API reports
36
+ failures as ``{"error": "..."}`` and callers should not have to re-read the response to
37
+ find out why the request was rejected.
38
+ """
39
+
40
+ def __init__(self, status: int, url: str, message: str, body: Any = None) -> None:
41
+ super().__init__(message)
42
+ self.status = status
43
+ self.url = url
44
+ self.body = body
45
+
46
+ @property
47
+ def is_unauthorized(self) -> bool:
48
+ """True when the caller is not signed in, or the session has expired."""
49
+ return self.status == 401
50
+
51
+ @property
52
+ def is_rate_limited(self) -> bool:
53
+ """True when this caller exceeded the endpoint's rate limit."""
54
+ return self.status == 429
55
+
56
+
57
+ class VibeInfraNetworkError(VibeInfraError):
58
+ """The request never reached the server, or the timeout fired."""
59
+
60
+ def __init__(self, url: str, cause: BaseException) -> None:
61
+ super().__init__(f"request to {url} failed")
62
+ self.url = url
63
+ self.__cause__ = cause
64
+
65
+
66
+ def _build_query(query: Optional[Mapping[str, Any]]) -> str:
67
+ """Encode a query string, dropping empty values instead of sending ``None``."""
68
+ if not query:
69
+ return ""
70
+ pairs = [(k, str(v)) for k, v in query.items() if v is not None and v != ""]
71
+ if not pairs:
72
+ return ""
73
+ return "?" + urllib.parse.urlencode(pairs)
74
+
75
+
76
+ class VibeInfra:
77
+ """A VibeInfra API client.
78
+
79
+ :param base_url: API origin. Defaults to production.
80
+ :param token: Session JWT, sent as ``Authorization: Bearer``. Only the learner-account
81
+ methods need one; every catalog method works without it.
82
+ :param timeout: Per-request timeout in seconds.
83
+ :param opener: Injected ``urllib`` opener, for tests and custom transports.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ base_url: str = DEFAULT_BASE_URL,
89
+ token: Optional[str] = None,
90
+ timeout: float = DEFAULT_TIMEOUT,
91
+ opener: Optional[urllib.request.OpenerDirector] = None,
92
+ ) -> None:
93
+ self.base_url = base_url.rstrip("/")
94
+ self.token = token
95
+ self.timeout = timeout
96
+ self._opener = opener or urllib.request.build_opener()
97
+
98
+ def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
99
+ """Absolute URL for a version-relative path. Exposed so callers can log or cache by URL."""
100
+ suffix = path if path.startswith("/") else f"/{path}"
101
+ return f"{self.base_url}/api/{API_VERSION}{suffix}{_build_query(query)}"
102
+
103
+ def request(
104
+ self,
105
+ path: str,
106
+ method: str = "GET",
107
+ query: Optional[Mapping[str, Any]] = None,
108
+ json_body: Optional[Any] = None,
109
+ ) -> Any:
110
+ """Perform one request.
111
+
112
+ Every method below funnels through here so that timeouts, error shaping and auth are
113
+ decided once rather than per endpoint.
114
+ """
115
+ url = self.url(path, query)
116
+ data = None
117
+ headers = {"Accept": "application/json", "User-Agent": _USER_AGENT}
118
+ if json_body is not None:
119
+ data = json.dumps(json_body).encode("utf-8")
120
+ headers["Content-Type"] = "application/json"
121
+ if self.token:
122
+ headers["Authorization"] = f"Bearer {self.token}"
123
+
124
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
125
+ try:
126
+ with self._opener.open(request, timeout=self.timeout) as response:
127
+ return _decode(response.read())
128
+ except urllib.error.HTTPError as error: # a real response, just not a 2xx
129
+ body = _decode(error.read())
130
+ message = body.get("error") if isinstance(body, dict) else None
131
+ raise VibeInfraApiError(
132
+ error.code, url, message or f"{method} {url} failed with {error.code}", body
133
+ ) from error
134
+ except (urllib.error.URLError, OSError) as error:
135
+ raise VibeInfraNetworkError(url, error) from error
136
+
137
+ # -- Catalog ---------------------------------------------------------------------------
138
+
139
+ def list_courses(self, locale: Optional[str] = None) -> List[Dict[str, Any]]:
140
+ """Every course and incident in the catalog."""
141
+ return self.request("/courses", query={"locale": locale})
142
+
143
+ def get_course(self, course_id: str, locale: Optional[str] = None) -> Dict[str, Any]:
144
+ """One course blueprint by catalog ID, e.g. ``incident-22-k8s-crashloop``."""
145
+ if not course_id:
146
+ raise ValueError("course_id is required")
147
+ return self.request(
148
+ f"/courses/{urllib.parse.quote(course_id, safe='')}", query={"locale": locale}
149
+ )
150
+
151
+ def search_courses(
152
+ self,
153
+ query: Optional[str] = None,
154
+ difficulty: Optional[str] = None,
155
+ category: Optional[str] = None,
156
+ domain: Optional[str] = None,
157
+ tech: Optional[str] = None,
158
+ status: Optional[str] = None,
159
+ free_only: bool = False,
160
+ locale: Optional[str] = None,
161
+ ) -> List[Dict[str, Any]]:
162
+ """Filter the catalog client-side.
163
+
164
+ The API has no search endpoint -- the catalog is small enough to fetch whole -- so
165
+ this filters the list rather than pretending a server-side query exists.
166
+ """
167
+ needle = query.strip().lower() if query else None
168
+ results = []
169
+ for course in self.list_courses(locale=locale):
170
+ if difficulty and course.get("difficulty") != difficulty:
171
+ continue
172
+ if category and course.get("category") != category:
173
+ continue
174
+ if domain and course.get("domain") != domain:
175
+ continue
176
+ if status and course.get("status") != status:
177
+ continue
178
+ if free_only and not course.get("is_free"):
179
+ continue
180
+ stack: Iterable[str] = course.get("tech_stack") or []
181
+ if tech and not any(t.lower() == tech.lower() for t in stack):
182
+ continue
183
+ if needle and needle not in _haystack(course):
184
+ continue
185
+ results.append(course)
186
+ return results
187
+
188
+ def get_glossary(self, lang: Optional[str] = None) -> List[Dict[str, Any]]:
189
+ """Localised glossary of the infrastructure terms used across the catalog."""
190
+ return self.request("/glossary", query={"lang": lang})
191
+
192
+ def list_credit_packs(self) -> List[Dict[str, Any]]:
193
+ """Purchasable VibeCredits packs, priced in IDR."""
194
+ return self.request("/credits/packs")
195
+
196
+ # -- Platform --------------------------------------------------------------------------
197
+
198
+ def get_health(self) -> Dict[str, Any]:
199
+ """Liveness probe."""
200
+ return self.request("/health")
201
+
202
+ def get_capacity(self) -> Dict[str, Any]:
203
+ """How much sandbox capacity is in use right now, and how many learners are queued."""
204
+ return self.request("/labs/capacity")
205
+
206
+ def get_public_settings(self) -> Dict[str, Any]:
207
+ """Public feature flags and the live early-access seat counters."""
208
+ return self.request("/settings/public")
209
+
210
+ def get_public_telemetry(self) -> Dict[str, Any]:
211
+ """Aggregate, non-personal platform counters."""
212
+ return self.request("/telemetry/public")
213
+
214
+ def get_leaderboard(self) -> Dict[str, Any]:
215
+ """XP leaderboard and hall of fame."""
216
+ return self.request("/labs/leaderboard")
217
+
218
+ # -- Labs and account ------------------------------------------------------------------
219
+
220
+ def start_lab(self, course_id: str) -> Dict[str, Any]:
221
+ """Provision the sandbox for one incident.
222
+
223
+ Works anonymously for the free teaser lab; pass ``token`` to the constructor to start
224
+ any lab the learner's tier allows. When the platform is saturated the response reports
225
+ a queued status instead of a session, so check :meth:`get_capacity` first.
226
+ """
227
+ if not course_id:
228
+ raise ValueError("course_id is required")
229
+ return self.request("/labs/start", method="POST", json_body={"course_id": course_id})
230
+
231
+ def get_session(self) -> Dict[str, Any]:
232
+ """Describe the current session. Returns ``{"authenticated": False}`` when there is none."""
233
+ return self.request("/auth/me")
234
+
235
+ def get_profile(self) -> Dict[str, Any]:
236
+ """The signed-in learner's profile. Requires a token."""
237
+ return self.request("/user/profile")
238
+
239
+ def list_completions(self) -> List[Dict[str, Any]]:
240
+ """Incidents this learner has completed. Requires a token."""
241
+ return self.request("/user/completions")
242
+
243
+
244
+ def _haystack(course: Mapping[str, Any]) -> str:
245
+ """Lower-cased text of a course, for substring search."""
246
+ parts = [course.get("title"), course.get("description"), course.get("category"), course.get("domain")]
247
+ parts.extend(course.get("tech_stack") or [])
248
+ return " ".join(str(p) for p in parts if p).lower()
249
+
250
+
251
+ def _decode(raw: bytes) -> Any:
252
+ """Decode a response body, falling back to text when it is not JSON."""
253
+ if not raw:
254
+ return None
255
+ text = raw.decode("utf-8", errors="replace")
256
+ try:
257
+ return json.loads(text)
258
+ except json.JSONDecodeError:
259
+ return text
File without changes
@@ -0,0 +1,188 @@
1
+ """The client is exercised against a stub opener rather than the live API.
2
+
3
+ These assertions are about the client's own contract -- URL shaping, error mapping, filtering
4
+ -- and a test that needs the network is a test that fails in CI for reasons that are not the
5
+ code's fault.
6
+ """
7
+
8
+ import io
9
+ import json
10
+ import urllib.error
11
+
12
+ import pytest
13
+
14
+ from vibeinfra import VibeInfra, VibeInfraApiError, VibeInfraNetworkError
15
+
16
+
17
+ class StubResponse(io.BytesIO):
18
+ """Minimal stand-in for the object urllib's opener yields."""
19
+
20
+ def __enter__(self):
21
+ return self
22
+
23
+ def __exit__(self, *exc):
24
+ self.close()
25
+ return False
26
+
27
+
28
+ class StubOpener:
29
+ """Records every request and replays a canned response."""
30
+
31
+ def __init__(self, body=None, status=200, raises=None):
32
+ self.body = body if body is not None else {}
33
+ self.status = status
34
+ self.raises = raises
35
+ self.calls = []
36
+
37
+ def open(self, request, timeout=None):
38
+ self.calls.append(request)
39
+ if self.raises is not None:
40
+ raise self.raises
41
+ payload = self.body if isinstance(self.body, (bytes, str)) else json.dumps(self.body)
42
+ if isinstance(payload, str):
43
+ payload = payload.encode("utf-8")
44
+ if self.status >= 400:
45
+ raise urllib.error.HTTPError(
46
+ request.full_url, self.status, "error", {}, io.BytesIO(payload)
47
+ )
48
+ return StubResponse(payload)
49
+
50
+
51
+ CATALOG = [
52
+ {
53
+ "id": "incident-22-k8s-crashloop",
54
+ "title": "Kubernetes Microservices CrashLoopBackOff",
55
+ "description": "Trace and fix pod CrashLoopBackOff at the Ingress gateway.",
56
+ "category": "Kubernetes",
57
+ "domain": "Kubernetes",
58
+ "difficulty": "Advanced",
59
+ "status": "enabled",
60
+ "tech_stack": ["Kubernetes", "Ingress", "kubectl"],
61
+ "is_free": False,
62
+ },
63
+ {
64
+ "id": "incident-31-disk-full",
65
+ "title": "Disk Full: Silent Write Failures",
66
+ "description": "Recover from 100% disk usage and unlinked open file descriptors.",
67
+ "category": "Linux",
68
+ "domain": "Linux Kernel & OS",
69
+ "difficulty": "Intermediate",
70
+ "status": "enabled",
71
+ "tech_stack": ["Linux Filesystem", "df", "du"],
72
+ "is_free": True,
73
+ },
74
+ ]
75
+
76
+
77
+ def client(**kwargs):
78
+ opener = kwargs.pop("opener", StubOpener())
79
+ return VibeInfra(opener=opener, **kwargs), opener
80
+
81
+
82
+ def test_builds_versioned_urls_against_production_by_default():
83
+ vibe, _ = client()
84
+ assert vibe.url("/courses") == "https://api.vibeinfra.id/api/v1/courses"
85
+
86
+
87
+ def test_trims_a_trailing_slash_from_a_custom_base_url():
88
+ vibe, _ = client(base_url="http://localhost:8080/")
89
+ assert vibe.url("/health") == "http://localhost:8080/api/v1/health"
90
+
91
+
92
+ def test_omits_empty_query_values():
93
+ vibe, _ = client()
94
+ assert vibe.url("/courses", {"locale": None}) == "https://api.vibeinfra.id/api/v1/courses"
95
+ assert vibe.url("/courses", {"locale": "id"}) == "https://api.vibeinfra.id/api/v1/courses?locale=id"
96
+
97
+
98
+ def test_list_courses_returns_the_parsed_catalog():
99
+ vibe, opener = client(opener=StubOpener(body=CATALOG))
100
+ courses = vibe.list_courses()
101
+ assert [c["id"] for c in courses] == [c["id"] for c in CATALOG]
102
+ assert opener.calls[0].get_method() == "GET"
103
+
104
+
105
+ def test_get_course_percent_encodes_the_id():
106
+ vibe, opener = client(opener=StubOpener(body={"id": "a/b"}))
107
+ vibe.get_course("a/b")
108
+ assert opener.calls[0].full_url.endswith("/courses/a%2Fb")
109
+
110
+
111
+ def test_get_course_rejects_a_missing_id_before_touching_the_network():
112
+ vibe, opener = client()
113
+ with pytest.raises(ValueError):
114
+ vibe.get_course("")
115
+ assert opener.calls == []
116
+
117
+
118
+ def test_start_lab_posts_the_course_id_as_json():
119
+ vibe, opener = client(opener=StubOpener(body={"status": "ok"}))
120
+ vibe.start_lab("incident-31-disk-full")
121
+ request = opener.calls[0]
122
+ assert request.get_method() == "POST"
123
+ assert request.get_header("Content-type") == "application/json"
124
+ assert json.loads(request.data) == {"course_id": "incident-31-disk-full"}
125
+
126
+
127
+ def test_a_token_is_sent_as_a_bearer_header():
128
+ vibe, opener = client(token="jwt-123", opener=StubOpener(body={"authenticated": True}))
129
+ vibe.get_session()
130
+ assert opener.calls[0].get_header("Authorization") == "Bearer jwt-123"
131
+
132
+
133
+ def test_no_token_means_no_authorization_header():
134
+ vibe, opener = client(opener=StubOpener(body=[]))
135
+ vibe.list_courses()
136
+ assert opener.calls[0].get_header("Authorization") is None
137
+
138
+
139
+ def test_a_non_2xx_response_raises_with_the_servers_message():
140
+ vibe, _ = client(opener=StubOpener(status=404, body={"error": "Course not found"}))
141
+ with pytest.raises(VibeInfraApiError) as caught:
142
+ vibe.get_course("nope")
143
+ assert caught.value.status == 404
144
+ assert str(caught.value) == "Course not found"
145
+ assert caught.value.is_rate_limited is False
146
+
147
+
148
+ def test_a_429_is_flagged_as_rate_limited():
149
+ vibe, _ = client(opener=StubOpener(status=429, body=""))
150
+ with pytest.raises(VibeInfraApiError) as caught:
151
+ vibe.start_lab("incident-31-disk-full")
152
+ assert caught.value.is_rate_limited is True
153
+
154
+
155
+ def test_a_401_is_flagged_as_unauthorized():
156
+ vibe, _ = client(opener=StubOpener(status=401, body=""))
157
+ with pytest.raises(VibeInfraApiError) as caught:
158
+ vibe.get_profile()
159
+ assert caught.value.is_unauthorized is True
160
+
161
+
162
+ def test_a_transport_failure_raises_a_network_error_not_the_raw_cause():
163
+ cause = urllib.error.URLError("ECONNREFUSED")
164
+ vibe, _ = client(opener=StubOpener(raises=cause))
165
+ with pytest.raises(VibeInfraNetworkError) as caught:
166
+ vibe.get_health()
167
+ assert caught.value.__cause__ is cause
168
+
169
+
170
+ def test_a_non_json_body_is_returned_as_text():
171
+ vibe, _ = client(opener=StubOpener(body="plain text"))
172
+ assert vibe.get_health() == "plain text"
173
+
174
+
175
+ def test_search_matches_title_description_and_tech_stack():
176
+ vibe, _ = client(opener=StubOpener(body=CATALOG))
177
+ assert [c["id"] for c in vibe.search_courses(query="crashloop")] == ["incident-22-k8s-crashloop"]
178
+ assert [c["id"] for c in vibe.search_courses(query="unlinked")] == ["incident-31-disk-full"]
179
+ assert [c["id"] for c in vibe.search_courses(query="kubectl")] == ["incident-22-k8s-crashloop"]
180
+
181
+
182
+ def test_search_filters_combine_and_an_empty_query_matches_everything():
183
+ vibe, _ = client(opener=StubOpener(body=CATALOG))
184
+ assert len(vibe.search_courses()) == 2
185
+ assert len(vibe.search_courses(difficulty="Advanced")) == 1
186
+ assert len(vibe.search_courses(free_only=True)) == 1
187
+ assert len(vibe.search_courses(tech="KUBERNETES")) == 1
188
+ assert len(vibe.search_courses(difficulty="Advanced", free_only=True)) == 0