podojo-cli 0.1.0__tar.gz → 0.2.1__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.
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/CLAUDE.md +16 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/PKG-INFO +2 -1
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/pyproject.toml +2 -1
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/client.py +43 -0
- podojo_cli-0.2.1/src/podojo_cli/commands/sessions.py +308 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/main.py +2 -1
- podojo_cli-0.2.1/tests/test_sessions.py +277 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/uv.lock +49 -1
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/.github/workflows/publish.yml +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/.gitignore +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/README.md +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/gdrive.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/config.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/__init__.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/list.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/upload.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/conftest.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_auth.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_gdrive.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_projects.py +0 -0
- {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_showreel.py +0 -0
|
@@ -25,6 +25,22 @@ podojo gdrive setup ~/.podojo-gdrive.json
|
|
|
25
25
|
podojo gdrive upload report.md --title "My Report"
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## Sessions (Unmoderated Tests)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
podojo sessions list
|
|
32
|
+
podojo sessions get <session_id>
|
|
33
|
+
podojo sessions create --from-file session.yaml
|
|
34
|
+
podojo sessions update <session_id> --from-file updates.yaml
|
|
35
|
+
podojo sessions delete <session_id> [--yes]
|
|
36
|
+
podojo sessions example # print template YAML to stdout
|
|
37
|
+
podojo sessions validate session.yaml # validate without creating
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Session configs are YAML files. Run `podojo sessions example` for the full template.
|
|
41
|
+
Required fields: `session_id`, `client`, `title`, `logo`, `prototype_url`, `steps`.
|
|
42
|
+
Each step needs `type` ("screen" or "prototype") and `title`. Screen steps should have `variant` ("question" or "task").
|
|
43
|
+
|
|
28
44
|
## Configuration
|
|
29
45
|
|
|
30
46
|
Set via env vars or `~/.podojo.toml`:
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: podojo-cli
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: CLI for the Podojo user research platform
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.12
|
|
7
7
|
Requires-Dist: google-api-python-client>=2.0
|
|
8
8
|
Requires-Dist: google-auth>=2.0
|
|
9
9
|
Requires-Dist: httpx>=0.28
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
11
|
Requires-Dist: rich>=13.0
|
|
11
12
|
Requires-Dist: typer>=0.15
|
|
12
13
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "podojo-cli"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "CLI for the Podojo user research platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
|
9
9
|
"typer>=0.15",
|
|
10
10
|
"httpx>=0.28",
|
|
11
11
|
"rich>=13.0",
|
|
12
|
+
"pyyaml>=6.0",
|
|
12
13
|
"google-auth>=2.0",
|
|
13
14
|
"google-api-python-client>=2.0",
|
|
14
15
|
]
|
|
@@ -52,3 +52,46 @@ class PodojoClient:
|
|
|
52
52
|
)
|
|
53
53
|
r.raise_for_status()
|
|
54
54
|
return r.json()
|
|
55
|
+
|
|
56
|
+
def list_sessions(self, skip: int = 0, limit: int = 50) -> dict:
|
|
57
|
+
r = httpx.get(
|
|
58
|
+
f"{self.base_url}/sessions",
|
|
59
|
+
params={"skip": skip, "limit": limit},
|
|
60
|
+
headers=self._headers(),
|
|
61
|
+
)
|
|
62
|
+
r.raise_for_status()
|
|
63
|
+
return r.json()
|
|
64
|
+
|
|
65
|
+
def get_session(self, session_id: str) -> dict:
|
|
66
|
+
r = httpx.get(
|
|
67
|
+
f"{self.base_url}/sessions/{session_id}",
|
|
68
|
+
headers=self._headers(),
|
|
69
|
+
)
|
|
70
|
+
r.raise_for_status()
|
|
71
|
+
return r.json()
|
|
72
|
+
|
|
73
|
+
def create_session(self, data: dict) -> dict:
|
|
74
|
+
r = httpx.post(
|
|
75
|
+
f"{self.base_url}/sessions",
|
|
76
|
+
json=data,
|
|
77
|
+
headers=self._headers(),
|
|
78
|
+
)
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
return r.json()
|
|
81
|
+
|
|
82
|
+
def update_session(self, session_id: str, data: dict) -> dict:
|
|
83
|
+
r = httpx.put(
|
|
84
|
+
f"{self.base_url}/sessions/{session_id}",
|
|
85
|
+
json=data,
|
|
86
|
+
headers=self._headers(),
|
|
87
|
+
)
|
|
88
|
+
r.raise_for_status()
|
|
89
|
+
return r.json()
|
|
90
|
+
|
|
91
|
+
def delete_session(self, session_id: str) -> dict:
|
|
92
|
+
r = httpx.delete(
|
|
93
|
+
f"{self.base_url}/sessions/{session_id}",
|
|
94
|
+
headers=self._headers(),
|
|
95
|
+
)
|
|
96
|
+
r.raise_for_status()
|
|
97
|
+
return r.json()
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
import yaml
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from ..client import PodojoClient
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage unmoderated test sessions")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
REQUIRED_FIELDS = ["session_id", "client", "title", "logo", "prototype_url", "steps"]
|
|
15
|
+
VALID_STEP_TYPES = {"screen", "prototype"}
|
|
16
|
+
VALID_STEP_VARIANTS = {"question", "task"}
|
|
17
|
+
REQUIRED_STEP_FIELDS = ["type", "title"]
|
|
18
|
+
|
|
19
|
+
EXAMPLE_YAML = """\
|
|
20
|
+
# Podojo Unmoderated Test Session Configuration
|
|
21
|
+
#
|
|
22
|
+
# Required fields: session_id, client, title, logo, prototype_url, steps
|
|
23
|
+
# Optional fields: welcome_text, privacy_text, promo_code, promo_code_info, project_name
|
|
24
|
+
|
|
25
|
+
session_id: checkout-usability-v1
|
|
26
|
+
client: Acme Corp
|
|
27
|
+
title: Checkout Flow Usability Test
|
|
28
|
+
logo: https://example.com/logo.png
|
|
29
|
+
prototype_url: https://figma.com/proto/abc123
|
|
30
|
+
|
|
31
|
+
# Optional: welcome message shown to participants (supports markdown)
|
|
32
|
+
welcome_text: |
|
|
33
|
+
## Welcome to our study!
|
|
34
|
+
|
|
35
|
+
We're testing a new checkout flow. There are no right or wrong
|
|
36
|
+
answers -- we want to understand your experience.
|
|
37
|
+
|
|
38
|
+
This session takes about 10 minutes.
|
|
39
|
+
|
|
40
|
+
# Optional: privacy notice
|
|
41
|
+
privacy_text: |
|
|
42
|
+
Your responses are anonymous and will only be used to improve
|
|
43
|
+
our product. You can stop at any time.
|
|
44
|
+
|
|
45
|
+
# Optional: reward for participants
|
|
46
|
+
promo_code: THANKS10
|
|
47
|
+
promo_code_info: "Use this code for 10% off your next purchase"
|
|
48
|
+
|
|
49
|
+
# Optional: link session to a project
|
|
50
|
+
project_name: checkout-redesign-q1
|
|
51
|
+
|
|
52
|
+
# Optional: set session live (default: false)
|
|
53
|
+
# live: true
|
|
54
|
+
|
|
55
|
+
# Steps define what participants see and do
|
|
56
|
+
# Each step requires: type ("screen" or "prototype") and title
|
|
57
|
+
# Screen steps should have a variant: "question" (open-ended) or "task" (action-based)
|
|
58
|
+
# Optional per step: text (markdown), image (URL)
|
|
59
|
+
steps:
|
|
60
|
+
- type: screen
|
|
61
|
+
variant: question
|
|
62
|
+
title: First Impressions
|
|
63
|
+
text: |
|
|
64
|
+
Look at this homepage screenshot.
|
|
65
|
+
What stands out to you first?
|
|
66
|
+
image: https://example.com/screenshots/homepage.png
|
|
67
|
+
|
|
68
|
+
- type: prototype
|
|
69
|
+
title: Complete a Purchase
|
|
70
|
+
text: |
|
|
71
|
+
Using the prototype below, try to buy a pair of running shoes.
|
|
72
|
+
Think aloud as you go through each step.
|
|
73
|
+
|
|
74
|
+
- type: screen
|
|
75
|
+
variant: task
|
|
76
|
+
title: Find the Return Policy
|
|
77
|
+
text: |
|
|
78
|
+
Without using search, try to find the return policy page.
|
|
79
|
+
Describe where you would click.
|
|
80
|
+
|
|
81
|
+
- type: screen
|
|
82
|
+
variant: question
|
|
83
|
+
title: Overall Experience
|
|
84
|
+
text: |
|
|
85
|
+
On a scale of 1-5, how easy was the checkout process?
|
|
86
|
+
What would you improve?
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_session_data(data: dict) -> list[str]:
|
|
91
|
+
"""Validate session YAML data, return list of error strings."""
|
|
92
|
+
errors = []
|
|
93
|
+
for field in REQUIRED_FIELDS:
|
|
94
|
+
if field not in data or data[field] is None:
|
|
95
|
+
errors.append(f"Missing required field: '{field}'")
|
|
96
|
+
|
|
97
|
+
steps = data.get("steps")
|
|
98
|
+
if steps is not None:
|
|
99
|
+
if not isinstance(steps, list):
|
|
100
|
+
errors.append("'steps' must be a list")
|
|
101
|
+
elif len(steps) == 0:
|
|
102
|
+
errors.append("'steps' must contain at least one step")
|
|
103
|
+
else:
|
|
104
|
+
for i, step in enumerate(steps, 1):
|
|
105
|
+
if not isinstance(step, dict):
|
|
106
|
+
errors.append(f"Step {i}: must be a mapping with 'type' and 'title'")
|
|
107
|
+
continue
|
|
108
|
+
for field in REQUIRED_STEP_FIELDS:
|
|
109
|
+
if field not in step:
|
|
110
|
+
errors.append(f"Step {i}: missing required field '{field}'")
|
|
111
|
+
step_type = step.get("type")
|
|
112
|
+
if step_type not in VALID_STEP_TYPES:
|
|
113
|
+
errors.append(
|
|
114
|
+
f"Step {i}: 'type' must be 'screen' or 'prototype', got '{step_type}'"
|
|
115
|
+
)
|
|
116
|
+
variant = step.get("variant")
|
|
117
|
+
if step_type == "screen" and variant is not None and variant not in VALID_STEP_VARIANTS:
|
|
118
|
+
errors.append(
|
|
119
|
+
f"Step {i}: 'variant' must be 'question' or 'task', got '{variant}'"
|
|
120
|
+
)
|
|
121
|
+
return errors
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_yaml(path: Path) -> dict:
|
|
125
|
+
"""Load and parse YAML file."""
|
|
126
|
+
if not path.exists():
|
|
127
|
+
console.print(f"[red]Error:[/red] File not found: {path}")
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
try:
|
|
130
|
+
with open(path) as f:
|
|
131
|
+
data = yaml.safe_load(f)
|
|
132
|
+
except yaml.YAMLError as e:
|
|
133
|
+
console.print(f"[red]Error:[/red] Invalid YAML syntax:\n{e}")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
console.print("[red]Error:[/red] YAML file must contain a mapping (key-value pairs)")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _format_api_error(e: httpx.HTTPStatusError) -> str:
|
|
142
|
+
"""Format API error into actionable message."""
|
|
143
|
+
try:
|
|
144
|
+
detail = e.response.json().get("detail", "")
|
|
145
|
+
if isinstance(detail, list):
|
|
146
|
+
messages = []
|
|
147
|
+
for err in detail:
|
|
148
|
+
loc = " -> ".join(str(x) for x in err.get("loc", []))
|
|
149
|
+
messages.append(f" {loc}: {err.get('msg', '')}")
|
|
150
|
+
return "Validation errors:\n" + "\n".join(messages)
|
|
151
|
+
return str(detail)
|
|
152
|
+
except Exception:
|
|
153
|
+
return e.response.text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command("list")
|
|
157
|
+
def list_sessions(
|
|
158
|
+
skip: int = typer.Option(0, help="Number of sessions to skip"),
|
|
159
|
+
limit: int = typer.Option(50, help="Max sessions to return"),
|
|
160
|
+
):
|
|
161
|
+
"""List all sessions."""
|
|
162
|
+
client = PodojoClient()
|
|
163
|
+
try:
|
|
164
|
+
result = client.list_sessions(skip=skip, limit=limit)
|
|
165
|
+
except httpx.HTTPStatusError as e:
|
|
166
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
167
|
+
raise typer.Exit(1)
|
|
168
|
+
|
|
169
|
+
sessions = result.get("sessions", [])
|
|
170
|
+
if not sessions:
|
|
171
|
+
console.print("No sessions found.")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
table = Table(title="Sessions")
|
|
175
|
+
table.add_column("Session ID")
|
|
176
|
+
table.add_column("Title")
|
|
177
|
+
table.add_column("Client")
|
|
178
|
+
table.add_column("Steps", justify="right")
|
|
179
|
+
table.add_column("Live")
|
|
180
|
+
table.add_column("Last Updated")
|
|
181
|
+
|
|
182
|
+
for s in sessions:
|
|
183
|
+
live = "[green]Yes[/green]" if s.get("live") else "[dim]No[/dim]"
|
|
184
|
+
table.add_row(
|
|
185
|
+
s.get("session_id", ""),
|
|
186
|
+
s.get("title", ""),
|
|
187
|
+
s.get("client", ""),
|
|
188
|
+
str(s.get("step_count", "")),
|
|
189
|
+
live,
|
|
190
|
+
s.get("last_updated", ""),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
console.print(table)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command("get")
|
|
197
|
+
def get_session(
|
|
198
|
+
session_id: str = typer.Argument(help="Session ID to retrieve"),
|
|
199
|
+
):
|
|
200
|
+
"""Get a session and output as YAML."""
|
|
201
|
+
client = PodojoClient()
|
|
202
|
+
try:
|
|
203
|
+
session = client.get_session(session_id)
|
|
204
|
+
except httpx.HTTPStatusError as e:
|
|
205
|
+
if e.response.status_code == 404:
|
|
206
|
+
console.print(f"[red]Error:[/red] Session '{session_id}' not found")
|
|
207
|
+
else:
|
|
208
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
# Remove server-managed fields for a clean editable output
|
|
212
|
+
for key in ("id", "created_at", "created_by", "last_updated"):
|
|
213
|
+
session.pop(key, None)
|
|
214
|
+
|
|
215
|
+
console.print(yaml.dump(session, default_flow_style=False, sort_keys=False, allow_unicode=True))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command("create")
|
|
219
|
+
def create_session(
|
|
220
|
+
from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with session config"),
|
|
221
|
+
):
|
|
222
|
+
"""Create a new session from a YAML file."""
|
|
223
|
+
data = _load_yaml(from_file)
|
|
224
|
+
|
|
225
|
+
errors = validate_session_data(data)
|
|
226
|
+
if errors:
|
|
227
|
+
console.print("[red]Validation errors:[/red]")
|
|
228
|
+
for err in errors:
|
|
229
|
+
console.print(f" {err}")
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
|
|
232
|
+
client = PodojoClient()
|
|
233
|
+
try:
|
|
234
|
+
result = client.create_session(data)
|
|
235
|
+
except httpx.HTTPStatusError as e:
|
|
236
|
+
if e.response.status_code == 409:
|
|
237
|
+
console.print(f"[red]Error:[/red] Session '{data.get('session_id')}' already exists")
|
|
238
|
+
else:
|
|
239
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
|
|
242
|
+
console.print(f"[green]Created session:[/green] {result.get('session_id', '')}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("update")
|
|
246
|
+
def update_session(
|
|
247
|
+
session_id: str = typer.Argument(help="Session ID to update"),
|
|
248
|
+
from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with fields to update"),
|
|
249
|
+
):
|
|
250
|
+
"""Update a session from a YAML file (partial updates OK)."""
|
|
251
|
+
data = _load_yaml(from_file)
|
|
252
|
+
|
|
253
|
+
client = PodojoClient()
|
|
254
|
+
try:
|
|
255
|
+
result = client.update_session(session_id, data)
|
|
256
|
+
except httpx.HTTPStatusError as e:
|
|
257
|
+
if e.response.status_code == 404:
|
|
258
|
+
console.print(f"[red]Error:[/red] Session '{session_id}' not found")
|
|
259
|
+
else:
|
|
260
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
261
|
+
raise typer.Exit(1)
|
|
262
|
+
|
|
263
|
+
console.print(f"[green]Updated session:[/green] {result.get('session_id', session_id)}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command("delete")
|
|
267
|
+
def delete_session(
|
|
268
|
+
session_id: str = typer.Argument(help="Session ID to delete"),
|
|
269
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
270
|
+
):
|
|
271
|
+
"""Delete a session."""
|
|
272
|
+
if not yes:
|
|
273
|
+
typer.confirm(f"Delete session '{session_id}'?", abort=True)
|
|
274
|
+
|
|
275
|
+
client = PodojoClient()
|
|
276
|
+
try:
|
|
277
|
+
client.delete_session(session_id)
|
|
278
|
+
except httpx.HTTPStatusError as e:
|
|
279
|
+
if e.response.status_code == 404:
|
|
280
|
+
console.print(f"[red]Error:[/red] Session '{session_id}' not found")
|
|
281
|
+
else:
|
|
282
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
283
|
+
raise typer.Exit(1)
|
|
284
|
+
|
|
285
|
+
console.print(f"[green]Deleted session:[/green] {session_id}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command("validate")
|
|
289
|
+
def validate(
|
|
290
|
+
file: Path = typer.Argument(help="YAML file to validate"),
|
|
291
|
+
):
|
|
292
|
+
"""Validate a session YAML file without creating it."""
|
|
293
|
+
data = _load_yaml(file)
|
|
294
|
+
|
|
295
|
+
errors = validate_session_data(data)
|
|
296
|
+
if errors:
|
|
297
|
+
console.print("[red]Validation errors:[/red]")
|
|
298
|
+
for err in errors:
|
|
299
|
+
console.print(f" {err}")
|
|
300
|
+
raise typer.Exit(1)
|
|
301
|
+
|
|
302
|
+
console.print("[green]Valid session config.[/green]")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command("example")
|
|
306
|
+
def example():
|
|
307
|
+
"""Print an example session YAML template."""
|
|
308
|
+
print(EXAMPLE_YAML)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
|
|
3
|
-
from .commands import auth, gdrive, projects, showreel, transcripts, videos
|
|
3
|
+
from .commands import auth, gdrive, projects, sessions, showreel, transcripts, videos
|
|
4
4
|
|
|
5
5
|
app = typer.Typer(
|
|
6
6
|
name="podojo",
|
|
@@ -10,6 +10,7 @@ app = typer.Typer(
|
|
|
10
10
|
|
|
11
11
|
app.add_typer(auth.app, name="auth")
|
|
12
12
|
app.add_typer(projects.app, name="projects")
|
|
13
|
+
app.add_typer(sessions.app, name="sessions")
|
|
13
14
|
app.add_typer(transcripts.app, name="transcripts")
|
|
14
15
|
app.add_typer(videos.app, name="videos")
|
|
15
16
|
app.add_typer(showreel.app, name="showreel")
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from podojo_cli.main import app
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
VALID_SESSION_YAML = """\
|
|
5
|
+
session_id: test-session-1
|
|
6
|
+
client: Test Corp
|
|
7
|
+
title: Test Session
|
|
8
|
+
logo: https://example.com/logo.png
|
|
9
|
+
prototype_url: https://example.com/proto
|
|
10
|
+
steps:
|
|
11
|
+
- type: screen
|
|
12
|
+
variant: question
|
|
13
|
+
title: First Question
|
|
14
|
+
text: What do you see?
|
|
15
|
+
- type: prototype
|
|
16
|
+
title: Try the Flow
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
LIST_RESPONSE = {
|
|
20
|
+
"sessions": [
|
|
21
|
+
{
|
|
22
|
+
"session_id": "sess-1",
|
|
23
|
+
"title": "Session One",
|
|
24
|
+
"client": "Acme",
|
|
25
|
+
"step_count": 3,
|
|
26
|
+
"live": True,
|
|
27
|
+
"last_updated": "2026-03-01T12:00:00",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"session_id": "sess-2",
|
|
31
|
+
"title": "Session Two",
|
|
32
|
+
"client": "Beta",
|
|
33
|
+
"step_count": 5,
|
|
34
|
+
"live": False,
|
|
35
|
+
"last_updated": "2026-03-02T12:00:00",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
"total": 2,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
GET_RESPONSE = {
|
|
42
|
+
"id": "abc123",
|
|
43
|
+
"session_id": "sess-1",
|
|
44
|
+
"title": "Session One",
|
|
45
|
+
"client": "Acme",
|
|
46
|
+
"logo": "https://example.com/logo.png",
|
|
47
|
+
"prototype_url": "https://example.com/proto",
|
|
48
|
+
"steps": [{"type": "screen", "variant": "question", "title": "Q1"}],
|
|
49
|
+
"created_at": "2026-03-01T12:00:00",
|
|
50
|
+
"created_by": "user@example.com",
|
|
51
|
+
"last_updated": "2026-03-01T12:00:00",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_list_sessions(runner, httpx_mock):
|
|
56
|
+
httpx_mock.add_response(
|
|
57
|
+
url="http://test.local/api/v1/sessions?skip=0&limit=50",
|
|
58
|
+
json=LIST_RESPONSE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result = runner.invoke(app, ["sessions", "list"])
|
|
62
|
+
|
|
63
|
+
assert result.exit_code == 0
|
|
64
|
+
assert "Session One" in result.output
|
|
65
|
+
assert "Session Two" in result.output
|
|
66
|
+
assert "Acme" in result.output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_list_sessions_empty(runner, httpx_mock):
|
|
70
|
+
httpx_mock.add_response(
|
|
71
|
+
url="http://test.local/api/v1/sessions?skip=0&limit=50",
|
|
72
|
+
json={"sessions": [], "total": 0},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
result = runner.invoke(app, ["sessions", "list"])
|
|
76
|
+
|
|
77
|
+
assert result.exit_code == 0
|
|
78
|
+
assert "No sessions found" in result.output
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_get_session(runner, httpx_mock):
|
|
82
|
+
httpx_mock.add_response(
|
|
83
|
+
url="http://test.local/api/v1/sessions/sess-1",
|
|
84
|
+
json=GET_RESPONSE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
result = runner.invoke(app, ["sessions", "get", "sess-1"])
|
|
88
|
+
|
|
89
|
+
assert result.exit_code == 0
|
|
90
|
+
assert "session_id: sess-1" in result.output
|
|
91
|
+
assert "title: Session One" in result.output
|
|
92
|
+
# Server-managed fields should be stripped
|
|
93
|
+
assert "created_at" not in result.output
|
|
94
|
+
assert "created_by" not in result.output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_get_session_not_found(runner, httpx_mock):
|
|
98
|
+
httpx_mock.add_response(
|
|
99
|
+
url="http://test.local/api/v1/sessions/nonexistent",
|
|
100
|
+
status_code=404,
|
|
101
|
+
json={"detail": "Session not found"},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
result = runner.invoke(app, ["sessions", "get", "nonexistent"])
|
|
105
|
+
|
|
106
|
+
assert result.exit_code == 1
|
|
107
|
+
assert "not found" in result.output
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_create_session(runner, httpx_mock, tmp_path):
|
|
111
|
+
yaml_file = tmp_path / "session.yaml"
|
|
112
|
+
yaml_file.write_text(VALID_SESSION_YAML)
|
|
113
|
+
|
|
114
|
+
httpx_mock.add_response(
|
|
115
|
+
url="http://test.local/api/v1/sessions",
|
|
116
|
+
method="POST",
|
|
117
|
+
json={"session_id": "test-session-1", "id": "abc123"},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
|
|
121
|
+
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
assert "test-session-1" in result.output
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_create_session_file_not_found(runner):
|
|
127
|
+
result = runner.invoke(app, ["sessions", "create", "-f", "/nonexistent/session.yaml"])
|
|
128
|
+
|
|
129
|
+
assert result.exit_code == 1
|
|
130
|
+
assert "File not found" in result.output
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_create_session_invalid_yaml(runner, tmp_path):
|
|
134
|
+
yaml_file = tmp_path / "bad.yaml"
|
|
135
|
+
yaml_file.write_text(":\n invalid: [yaml\n broken")
|
|
136
|
+
|
|
137
|
+
result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
|
|
138
|
+
|
|
139
|
+
assert result.exit_code == 1
|
|
140
|
+
assert "Invalid YAML" in result.output
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_create_session_missing_fields(runner, tmp_path):
|
|
144
|
+
yaml_file = tmp_path / "incomplete.yaml"
|
|
145
|
+
yaml_file.write_text("title: Just a title\n")
|
|
146
|
+
|
|
147
|
+
result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
|
|
148
|
+
|
|
149
|
+
assert result.exit_code == 1
|
|
150
|
+
assert "Missing required field" in result.output
|
|
151
|
+
assert "'session_id'" in result.output
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_create_session_invalid_step_type(runner, tmp_path):
|
|
155
|
+
yaml_file = tmp_path / "badstep.yaml"
|
|
156
|
+
yaml_file.write_text(
|
|
157
|
+
"session_id: test\n"
|
|
158
|
+
"client: Test\n"
|
|
159
|
+
"title: Test\n"
|
|
160
|
+
"logo: https://example.com/logo.png\n"
|
|
161
|
+
"prototype_url: https://example.com/proto\n"
|
|
162
|
+
"steps:\n"
|
|
163
|
+
" - type: invalid\n"
|
|
164
|
+
" title: Bad Step\n"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
|
|
168
|
+
|
|
169
|
+
assert result.exit_code == 1
|
|
170
|
+
assert "'type' must be 'screen' or 'prototype'" in result.output
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_create_session_duplicate(runner, httpx_mock, tmp_path):
|
|
174
|
+
yaml_file = tmp_path / "session.yaml"
|
|
175
|
+
yaml_file.write_text(VALID_SESSION_YAML)
|
|
176
|
+
|
|
177
|
+
httpx_mock.add_response(
|
|
178
|
+
url="http://test.local/api/v1/sessions",
|
|
179
|
+
method="POST",
|
|
180
|
+
status_code=409,
|
|
181
|
+
json={"detail": "Session with this ID already exists"},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
|
|
185
|
+
|
|
186
|
+
assert result.exit_code == 1
|
|
187
|
+
assert "already exists" in result.output
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_update_session(runner, httpx_mock, tmp_path):
|
|
191
|
+
yaml_file = tmp_path / "update.yaml"
|
|
192
|
+
yaml_file.write_text("title: Updated Title\n")
|
|
193
|
+
|
|
194
|
+
httpx_mock.add_response(
|
|
195
|
+
url="http://test.local/api/v1/sessions/sess-1",
|
|
196
|
+
method="PUT",
|
|
197
|
+
json={"session_id": "sess-1", "title": "Updated Title"},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
result = runner.invoke(app, ["sessions", "update", "sess-1", "-f", str(yaml_file)])
|
|
201
|
+
|
|
202
|
+
assert result.exit_code == 0
|
|
203
|
+
assert "Updated session" in result.output
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_update_session_not_found(runner, httpx_mock, tmp_path):
|
|
207
|
+
yaml_file = tmp_path / "update.yaml"
|
|
208
|
+
yaml_file.write_text("title: Updated Title\n")
|
|
209
|
+
|
|
210
|
+
httpx_mock.add_response(
|
|
211
|
+
url="http://test.local/api/v1/sessions/nonexistent",
|
|
212
|
+
method="PUT",
|
|
213
|
+
status_code=404,
|
|
214
|
+
json={"detail": "Session not found"},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
result = runner.invoke(app, ["sessions", "update", "nonexistent", "-f", str(yaml_file)])
|
|
218
|
+
|
|
219
|
+
assert result.exit_code == 1
|
|
220
|
+
assert "not found" in result.output
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_delete_session(runner, httpx_mock):
|
|
224
|
+
httpx_mock.add_response(
|
|
225
|
+
url="http://test.local/api/v1/sessions/sess-1",
|
|
226
|
+
method="DELETE",
|
|
227
|
+
json={"status": "deleted", "session_id": "sess-1"},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
result = runner.invoke(app, ["sessions", "delete", "sess-1", "--yes"])
|
|
231
|
+
|
|
232
|
+
assert result.exit_code == 0
|
|
233
|
+
assert "Deleted session" in result.output
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_delete_session_not_found(runner, httpx_mock):
|
|
237
|
+
httpx_mock.add_response(
|
|
238
|
+
url="http://test.local/api/v1/sessions/nonexistent",
|
|
239
|
+
method="DELETE",
|
|
240
|
+
status_code=404,
|
|
241
|
+
json={"detail": "Session not found"},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
result = runner.invoke(app, ["sessions", "delete", "nonexistent", "--yes"])
|
|
245
|
+
|
|
246
|
+
assert result.exit_code == 1
|
|
247
|
+
assert "not found" in result.output
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_validate_valid(runner, tmp_path):
|
|
251
|
+
yaml_file = tmp_path / "session.yaml"
|
|
252
|
+
yaml_file.write_text(VALID_SESSION_YAML)
|
|
253
|
+
|
|
254
|
+
result = runner.invoke(app, ["sessions", "validate", str(yaml_file)])
|
|
255
|
+
|
|
256
|
+
assert result.exit_code == 0
|
|
257
|
+
assert "Valid session config" in result.output
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_validate_invalid(runner, tmp_path):
|
|
261
|
+
yaml_file = tmp_path / "bad.yaml"
|
|
262
|
+
yaml_file.write_text("title: Just a title\n")
|
|
263
|
+
|
|
264
|
+
result = runner.invoke(app, ["sessions", "validate", str(yaml_file)])
|
|
265
|
+
|
|
266
|
+
assert result.exit_code == 1
|
|
267
|
+
assert "Missing required field" in result.output
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_example(runner):
|
|
271
|
+
result = runner.invoke(app, ["sessions", "example"])
|
|
272
|
+
|
|
273
|
+
assert result.exit_code == 0
|
|
274
|
+
assert "session_id:" in result.output
|
|
275
|
+
assert "steps:" in result.output
|
|
276
|
+
assert "prototype" in result.output
|
|
277
|
+
assert "screen" in result.output
|
|
@@ -404,12 +404,13 @@ wheels = [
|
|
|
404
404
|
|
|
405
405
|
[[package]]
|
|
406
406
|
name = "podojo-cli"
|
|
407
|
-
version = "0.1
|
|
407
|
+
version = "0.2.1"
|
|
408
408
|
source = { editable = "." }
|
|
409
409
|
dependencies = [
|
|
410
410
|
{ name = "google-api-python-client" },
|
|
411
411
|
{ name = "google-auth" },
|
|
412
412
|
{ name = "httpx" },
|
|
413
|
+
{ name = "pyyaml" },
|
|
413
414
|
{ name = "rich" },
|
|
414
415
|
{ name = "typer" },
|
|
415
416
|
]
|
|
@@ -425,6 +426,7 @@ requires-dist = [
|
|
|
425
426
|
{ name = "google-api-python-client", specifier = ">=2.0" },
|
|
426
427
|
{ name = "google-auth", specifier = ">=2.0" },
|
|
427
428
|
{ name = "httpx", specifier = ">=0.28" },
|
|
429
|
+
{ name = "pyyaml", specifier = ">=6.0" },
|
|
428
430
|
{ name = "rich", specifier = ">=13.0" },
|
|
429
431
|
{ name = "typer", specifier = ">=0.15" },
|
|
430
432
|
]
|
|
@@ -539,6 +541,52 @@ wheels = [
|
|
|
539
541
|
{ url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" },
|
|
540
542
|
]
|
|
541
543
|
|
|
544
|
+
[[package]]
|
|
545
|
+
name = "pyyaml"
|
|
546
|
+
version = "6.0.3"
|
|
547
|
+
source = { registry = "https://pypi.org/simple" }
|
|
548
|
+
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
|
549
|
+
wheels = [
|
|
550
|
+
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
|
551
|
+
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
|
552
|
+
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
|
553
|
+
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
|
554
|
+
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
|
555
|
+
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
|
556
|
+
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
|
557
|
+
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
|
558
|
+
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
|
559
|
+
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
|
560
|
+
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
|
561
|
+
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
|
562
|
+
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
|
563
|
+
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
|
564
|
+
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
|
565
|
+
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
|
566
|
+
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
|
567
|
+
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
|
568
|
+
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
|
569
|
+
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
|
570
|
+
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
|
571
|
+
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
|
572
|
+
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
|
573
|
+
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
|
574
|
+
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
|
575
|
+
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
|
576
|
+
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
|
577
|
+
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
|
578
|
+
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
|
579
|
+
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
|
580
|
+
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
|
581
|
+
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
|
582
|
+
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
|
583
|
+
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
|
584
|
+
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
|
585
|
+
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
|
586
|
+
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
|
587
|
+
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
|
588
|
+
]
|
|
589
|
+
|
|
542
590
|
[[package]]
|
|
543
591
|
name = "requests"
|
|
544
592
|
version = "2.32.5"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|