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.
Files changed (30) hide show
  1. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/CLAUDE.md +16 -0
  2. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/PKG-INFO +2 -1
  3. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/pyproject.toml +2 -1
  4. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/client.py +43 -0
  5. podojo_cli-0.2.1/src/podojo_cli/commands/sessions.py +308 -0
  6. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/main.py +2 -1
  7. podojo_cli-0.2.1/tests/test_sessions.py +277 -0
  8. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/uv.lock +49 -1
  9. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/.github/workflows/publish.yml +0 -0
  10. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/.gitignore +0 -0
  11. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/README.md +0 -0
  12. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/__init__.py +0 -0
  13. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/__init__.py +0 -0
  14. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/auth.py +0 -0
  15. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/gdrive.py +0 -0
  16. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/projects.py +0 -0
  17. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/showreel.py +0 -0
  18. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/transcripts.py +0 -0
  19. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/commands/videos.py +0 -0
  20. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/config.py +0 -0
  21. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/__init__.py +0 -0
  22. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/list.py +0 -0
  23. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/gdrive/upload.py +0 -0
  24. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/video/__init__.py +0 -0
  25. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/src/podojo_cli/video/showreel.py +0 -0
  26. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/conftest.py +0 -0
  27. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_auth.py +0 -0
  28. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_gdrive.py +0 -0
  29. {podojo_cli-0.1.0 → podojo_cli-0.2.1}/tests/test_projects.py +0 -0
  30. {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.0
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.0"
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.0"
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