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.
- vibeinfra_sdk-0.1.0/.gitignore +102 -0
- vibeinfra_sdk-0.1.0/LICENSE +21 -0
- vibeinfra_sdk-0.1.0/PKG-INFO +122 -0
- vibeinfra_sdk-0.1.0/README.md +94 -0
- vibeinfra_sdk-0.1.0/pyproject.toml +52 -0
- vibeinfra_sdk-0.1.0/src/vibeinfra/__init__.py +41 -0
- vibeinfra_sdk-0.1.0/src/vibeinfra/client.py +259 -0
- vibeinfra_sdk-0.1.0/src/vibeinfra/py.typed +0 -0
- vibeinfra_sdk-0.1.0/tests/test_client.py +188 -0
|
@@ -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
|