podojo-cli 1.30.0__tar.gz → 1.32.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.
Files changed (41) hide show
  1. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/CHANGELOG.md +23 -0
  2. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/PKG-INFO +6 -1
  3. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/README.md +5 -0
  4. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/pyproject.toml +1 -1
  5. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/aiinterviews.py +3 -2
  6. podojo_cli-1.32.0/src/podojo_cli/commands/transcripts.py +105 -0
  7. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/usertests.py +130 -3
  8. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/videos.py +3 -2
  9. podojo_cli-1.32.0/src/podojo_cli/tables.py +13 -0
  10. podojo_cli-1.32.0/tests/test_transcripts.py +97 -0
  11. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_usertests.py +253 -0
  12. podojo_cli-1.32.0/tests/test_videos.py +28 -0
  13. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/uv.lock +1 -1
  14. podojo_cli-1.30.0/src/podojo_cli/commands/transcripts.py +0 -58
  15. podojo_cli-1.30.0/tests/test_transcripts.py +0 -30
  16. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/.github/workflows/publish.yml +0 -0
  17. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/.gitignore +0 -0
  18. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/CLAUDE.md +0 -0
  19. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/LICENSE +0 -0
  20. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/__init__.py +0 -0
  21. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/client.py +0 -0
  22. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/__init__.py +0 -0
  23. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/auth.py +0 -0
  24. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/interviews.py +0 -0
  25. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/projects.py +0 -0
  26. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/showreel.py +0 -0
  27. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/synth.py +0 -0
  28. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/config.py +0 -0
  29. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/main.py +0 -0
  30. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/__init__.py +0 -0
  31. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/driver.py +0 -0
  32. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/session.py +0 -0
  33. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/version_check.py +0 -0
  34. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/video/__init__.py +0 -0
  35. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/video/showreel.py +0 -0
  36. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/conftest.py +0 -0
  37. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_aiinterviews.py +0 -0
  38. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_auth.py +0 -0
  39. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_interviews.py +0 -0
  40. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_projects.py +0 -0
  41. {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_showreel.py +0 -0
@@ -5,6 +5,29 @@ All notable changes to the Podojo CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
+ ## [1.32.0] - 2026-11-04
9
+
10
+ ### Added
11
+ - Answer quotas in the user test YAML: give an option of a single-select
12
+ screening question a `quota` (completed sessions wanted from participants who
13
+ pick it). `usertests validate` checks them; `usertests get` leaves the
14
+ server-managed `quota_counts` out so the output still goes back into `update`.
15
+ - `usertests status <id>` shows responses and how full each quota is, and says
16
+ when a test closed itself because every answer to a question is full or
17
+ max_responses was reached. `--json` for scripts.
18
+
19
+ ## [1.31.0] - 2026-11-03
20
+
21
+ ### Added
22
+ - `transcripts download <project> --all -o <folder>` downloads every transcript
23
+ in a project, one `<name> - <batch-id>.txt` per interview, and lists the
24
+ interviews that have no transcript yet.
25
+
26
+ ### Fixed
27
+ - List tables no longer cut batch, interview and user test IDs. Off a terminal
28
+ (Claude Code, a pipe) they were squeezed to 80 columns and IDs ended in "…",
29
+ so agents guessed the rest and got 404s; tables now print at full width there.
30
+
8
31
  ## [1.30.0] - 2026-10-31
9
32
 
10
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: podojo-cli
3
- Version: 1.30.0
3
+ Version: 1.32.0
4
4
  Summary: CLI for the Podojo user research platform
5
5
  Project-URL: Homepage, https://github.com/podojo/cli-podojo
6
6
  Project-URL: Source, https://github.com/podojo/cli-podojo
@@ -92,8 +92,12 @@ podojo projects create "Checkout Redesign" --brief "Q1 usability study"
92
92
  podojo interviews upload session-03.mp4 --project "Checkout Redesign"
93
93
  podojo transcripts list "Checkout Redesign"
94
94
  podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
95
+ podojo transcripts download "Checkout Redesign" --all -o transcripts/
95
96
  ```
96
97
 
98
+ `--all` saves one `<name> - <batch-id>.txt` per interview, so each file carries
99
+ its batch ID for showreel clips.
100
+
97
101
  ### Videos & showreels
98
102
 
99
103
  ```bash
@@ -120,6 +124,7 @@ podojo usertests create -f my-test.yaml
120
124
  podojo usertests list
121
125
  podojo usertests get checkout-usability-v1
122
126
  podojo usertests update checkout-usability-v1 -f changes.yaml
127
+ podojo usertests status checkout-usability-v1 # responses and how full each answer quota is (--json for scripts)
123
128
  podojo usertests delete checkout-usability-v1
124
129
  podojo usertests snippet # recording snippet: two script tags + recorder.js
125
130
  ```
@@ -60,8 +60,12 @@ podojo projects create "Checkout Redesign" --brief "Q1 usability study"
60
60
  podojo interviews upload session-03.mp4 --project "Checkout Redesign"
61
61
  podojo transcripts list "Checkout Redesign"
62
62
  podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
63
+ podojo transcripts download "Checkout Redesign" --all -o transcripts/
63
64
  ```
64
65
 
66
+ `--all` saves one `<name> - <batch-id>.txt` per interview, so each file carries
67
+ its batch ID for showreel clips.
68
+
65
69
  ### Videos & showreels
66
70
 
67
71
  ```bash
@@ -88,6 +92,7 @@ podojo usertests create -f my-test.yaml
88
92
  podojo usertests list
89
93
  podojo usertests get checkout-usability-v1
90
94
  podojo usertests update checkout-usability-v1 -f changes.yaml
95
+ podojo usertests status checkout-usability-v1 # responses and how full each answer quota is (--json for scripts)
91
96
  podojo usertests delete checkout-usability-v1
92
97
  podojo usertests snippet # recording snippet: two script tags + recorder.js
93
98
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "podojo-cli"
3
- version = "1.30.0"
3
+ version = "1.32.0"
4
4
  description = "CLI for the Podojo user research platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,6 +8,7 @@ from rich.table import Table
8
8
 
9
9
  from ..client import PodojoClient
10
10
  from ..config import load_config
11
+ from ..tables import print_table
11
12
 
12
13
  app = typer.Typer(help="Manage AI voice interviews")
13
14
  console = Console()
@@ -436,7 +437,7 @@ def list_ai_interviews(
436
437
  return
437
438
 
438
439
  table = Table(title="AI Interviews")
439
- table.add_column("Interview ID")
440
+ table.add_column("Interview ID", no_wrap=True)
440
441
  table.add_column("Title")
441
442
  table.add_column("Language")
442
443
  table.add_column("Questions", justify="right")
@@ -459,7 +460,7 @@ def list_ai_interviews(
459
460
  s.get("last_updated", ""),
460
461
  )
461
462
 
462
- console.print(table)
463
+ print_table(console, table)
463
464
 
464
465
 
465
466
  @app.command("get")
@@ -0,0 +1,105 @@
1
+ import re
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..client import PodojoClient
10
+ from ..tables import print_table
11
+
12
+ app = typer.Typer(help="Manage transcripts")
13
+ console = Console()
14
+
15
+
16
+ def _format_date(value) -> str:
17
+ if not value:
18
+ return ""
19
+ try:
20
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
21
+ except (ValueError, AttributeError):
22
+ return str(value)
23
+
24
+
25
+ def _filename(interview: dict) -> str:
26
+ """Name plus batch ID, so a file maps back to its batch ID without a
27
+ lookup. Characters file systems reject become "_"."""
28
+ batch_id = interview["batch_id"]
29
+ name = re.sub(r'[\x00-\x1f/\\:*?"<>|]', "_", (interview.get("batch_name") or "").strip())
30
+ return f"{name} - {batch_id}.txt" if name else f"{batch_id}.txt"
31
+
32
+
33
+ @app.command("list")
34
+ def list_transcripts(project: str = typer.Argument(help="Project name")):
35
+ """List transcripts for a project."""
36
+ client = PodojoClient()
37
+ data = client.list_transcripts(project)
38
+
39
+ table = Table(title=f"Transcripts — {project} ({data['total']} total)")
40
+ table.add_column("Date")
41
+ table.add_column("Batch ID", no_wrap=True)
42
+ table.add_column("Name")
43
+
44
+ for t in data["interviews"]:
45
+ table.add_row(
46
+ _format_date(t.get("date")),
47
+ t.get("batch_id", ""),
48
+ t.get("batch_name", ""),
49
+ )
50
+
51
+ print_table(console, table)
52
+
53
+
54
+ def _download_all(client: PodojoClient, project: str, folder: Path) -> None:
55
+ data = client.list_transcripts(project)
56
+ interviews = data["interviews"]
57
+ folder.mkdir(parents=True, exist_ok=True)
58
+
59
+ missing = []
60
+ for interview in interviews:
61
+ text = client.download_transcript(project, interview["batch_id"])
62
+ if not text.strip():
63
+ missing.append(interview.get("batch_name") or interview["batch_id"])
64
+ continue
65
+ path = folder / _filename(interview)
66
+ path.write_text(text, encoding="utf-8")
67
+ console.print(f"Saved {path}", soft_wrap=True, markup=False)
68
+
69
+ console.print(
70
+ f"Downloaded {len(interviews) - len(missing)} transcripts to {folder}",
71
+ soft_wrap=True,
72
+ markup=False,
73
+ )
74
+ if missing:
75
+ console.print(f"No transcript yet: {', '.join(missing)}", soft_wrap=True, markup=False)
76
+ if data["total"] > len(interviews):
77
+ console.print(f"Only the newest {len(interviews)} of {data['total']} interviews were downloaded.")
78
+
79
+
80
+ @app.command("download")
81
+ def download_transcript(
82
+ project: str = typer.Argument(help="Project name"),
83
+ batch_id: str = typer.Argument(None, help="Batch ID (leave out with --all)"),
84
+ all_: bool = typer.Option(False, "--all", help="Download every transcript in the project"),
85
+ output: Path = typer.Option(
86
+ None, "-o", "--output", help="Output file path, or the folder for --all (default: current folder)"
87
+ ),
88
+ ):
89
+ """Download a transcript, or all of a project's transcripts with --all."""
90
+ if bool(batch_id) == all_:
91
+ console.print("[red]Error:[/red] Pass either a batch ID or --all.")
92
+ raise typer.Exit(1)
93
+
94
+ client = PodojoClient()
95
+ if all_:
96
+ _download_all(client, project, output or Path("."))
97
+ return
98
+
99
+ text = client.download_transcript(project, batch_id)
100
+
101
+ if output:
102
+ output.write_text(text, encoding="utf-8")
103
+ console.print(f"Saved to {output}")
104
+ else:
105
+ console.print(text)
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from pathlib import Path
2
3
 
3
4
  import httpx
@@ -7,6 +8,7 @@ from rich.console import Console
7
8
  from rich.table import Table
8
9
 
9
10
  from ..client import PodojoClient
11
+ from ..tables import print_table
10
12
 
11
13
  app = typer.Typer(help="Manage unmoderated user tests")
12
14
  console = Console()
@@ -69,6 +71,13 @@ EXAMPLE_YAML = """\
69
71
  # options (required, at least 2) each with `text`; `qualifies: true`
70
72
  # marks the accepted answers of a screener question and is
71
73
  # ignored on plain closed questions
74
+ # quota (optional, on an option) how many completed sessions you
75
+ # want from participants who pick it; once it's full, new
76
+ # participants who pick it are turned away. Single-select
77
+ # only, and on a screener only with `qualifies: true`. The
78
+ # test goes off live when every option that lets people
79
+ # through on one question is full. `podojo usertests status`
80
+ # shows how full each quota is.
72
81
 
73
82
  usertest_id: checkout-usability-v1
74
83
  title: Checkout Flow Usability Test
@@ -125,7 +134,9 @@ screening_questions:
125
134
  - text: Rarely or never
126
135
  - text: A few times a year
127
136
  - text: At least once a month
137
+ quota: 10
128
138
  - text: Weekly or more
139
+ quota: 10
129
140
 
130
141
  - text: Have you abandoned an online purchase at checkout in the past 3 months?
131
142
  screener: true
@@ -301,6 +312,12 @@ def validate_usertest_data(data: dict) -> list[str]:
301
312
  errors.append(f"Screening question {i}: 'options' must list at least 2 options")
302
313
  continue
303
314
  qualifying = 0
315
+ if multi_select is True and any(
316
+ isinstance(o, dict) and o.get("quota") is not None for o in options
317
+ ):
318
+ errors.append(
319
+ f"Screening question {i}: 'quota' isn't supported on multi_select questions"
320
+ )
304
321
  for j, option in enumerate(options, 1):
305
322
  if not isinstance(option, dict) or "text" not in option:
306
323
  errors.append(
@@ -314,6 +331,18 @@ def validate_usertest_data(data: dict) -> list[str]:
314
331
  )
315
332
  elif qualifies:
316
333
  qualifying += 1
334
+ quota = option.get("quota")
335
+ if quota is None or multi_select is True:
336
+ continue
337
+ if not isinstance(quota, int) or isinstance(quota, bool) or quota < 1:
338
+ errors.append(
339
+ f"Screening question {i}, option {j}: 'quota' must be a positive integer"
340
+ )
341
+ elif screener and qualifies is not True:
342
+ errors.append(
343
+ f"Screening question {i}, option {j}: 'quota' only works on "
344
+ "options with 'qualifies: true'"
345
+ )
317
346
  if screener and qualifying == 0:
318
347
  errors.append(
319
348
  f"Screening question {i}: needs at least one option with 'qualifies: true'"
@@ -423,7 +452,7 @@ def list_usertests(
423
452
  return
424
453
 
425
454
  table = Table(title="User Tests")
426
- table.add_column("User Test ID")
455
+ table.add_column("User Test ID", no_wrap=True)
427
456
  table.add_column("Title")
428
457
  table.add_column("Steps", justify="right")
429
458
  table.add_column("Live")
@@ -444,7 +473,7 @@ def list_usertests(
444
473
  s.get("last_updated", ""),
445
474
  )
446
475
 
447
- console.print(table)
476
+ print_table(console, table)
448
477
 
449
478
 
450
479
  @app.command("get")
@@ -465,7 +494,7 @@ def get_usertest(
465
494
  # Remove server-managed fields for a clean editable output
466
495
  group = usertest.pop("group", "")
467
496
  response_count = usertest.pop("response_count", 0) or 0
468
- for key in ("id", "created_at", "created_by", "last_updated"):
497
+ for key in ("quota_counts", "id", "created_at", "created_by", "last_updated"):
469
498
  usertest.pop(key, None)
470
499
 
471
500
  # Plain stdout, so `get > file.yaml` round-trips: rich would hard-wrap long
@@ -481,6 +510,104 @@ def get_usertest(
481
510
  err_console.print(f"Live: https://usertests.podojo.com/{group}?test={usertest_id}")
482
511
 
483
512
 
513
+ def _quota_status(usertest: dict) -> dict:
514
+ """Work out each quota's fill level and why a closed test closed."""
515
+ counts = usertest.get("quota_counts") or []
516
+ quotas = []
517
+ closed_question = None
518
+ for qi, question in enumerate(usertest.get("screening_questions") or []):
519
+ if question.get("multi_select"):
520
+ continue
521
+ screener = question.get("screener", False)
522
+ question_counts = counts[qi] if qi < len(counts) else []
523
+ passing = []
524
+ for oi, option in enumerate(question.get("options") or []):
525
+ if screener and not option.get("qualifies"):
526
+ continue
527
+ count = question_counts[oi] if oi < len(question_counts) else 0
528
+ quota = option.get("quota")
529
+ passing.append(
530
+ {
531
+ "text": option["text"],
532
+ "count": count,
533
+ "quota": quota,
534
+ "full": quota is not None and count >= quota,
535
+ "left": None if quota is None else max(quota - count, 0),
536
+ }
537
+ )
538
+ if not any(o["quota"] is not None for o in passing):
539
+ continue
540
+ quotas.append({"question": question["text"], "options": passing})
541
+ if closed_question is None and all(o["full"] for o in passing):
542
+ closed_question = question["text"]
543
+
544
+ live = bool(usertest.get("live"))
545
+ max_responses = usertest.get("max_responses")
546
+ response_count = usertest.get("response_count") or 0
547
+ closed_reason = None
548
+ if not live:
549
+ if closed_question is not None:
550
+ closed_reason = f'every answer to "{closed_question}" is full'
551
+ elif max_responses and response_count >= max_responses:
552
+ closed_reason = "max responses reached"
553
+ return {
554
+ "usertest_id": usertest.get("usertest_id"),
555
+ "title": usertest.get("title"),
556
+ "live": live,
557
+ "response_count": response_count,
558
+ "max_responses": max_responses,
559
+ "closed_reason": closed_reason,
560
+ "quotas": quotas,
561
+ }
562
+
563
+
564
+ @app.command("status")
565
+ def usertest_status(
566
+ usertest_id: str = typer.Argument(help="User test ID to check"),
567
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
568
+ ):
569
+ """Show how far a user test is: responses and how full each quota is."""
570
+ client = PodojoClient()
571
+ try:
572
+ usertest = client.get_usertest(usertest_id)
573
+ except httpx.HTTPStatusError as e:
574
+ if e.response.status_code == 404:
575
+ console.print(f"[red]Error:[/red] User test '{usertest_id}' not found")
576
+ else:
577
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
578
+ raise typer.Exit(1)
579
+
580
+ status = _quota_status(usertest)
581
+ if as_json:
582
+ typer.echo(json.dumps(status, indent=2, ensure_ascii=False))
583
+ return
584
+
585
+ typer.echo(f"{status['title']} · {'Live' if status['live'] else 'Not live'}")
586
+ if status["closed_reason"]:
587
+ typer.echo(f"Closed automatically: {status['closed_reason']}")
588
+ if status["max_responses"]:
589
+ typer.echo(f"Responses: {status['response_count']} / {status['max_responses']}")
590
+ else:
591
+ typer.echo(f"Responses: {status['response_count']}")
592
+ all_options = [o for q in status["quotas"] for o in q["options"]]
593
+ if all_options:
594
+ typer.echo("")
595
+ text_width = max((len(o["text"]) for o in all_options), default=0)
596
+ count_width = max((len(str(o["count"])) for o in all_options), default=0)
597
+ quota_width = max((len(str(o["quota"])) for o in all_options if o["quota"]), default=0)
598
+ for quota in status["quotas"]:
599
+ typer.echo(quota["question"])
600
+ for o in quota["options"]:
601
+ progress = f"{o['count']:>{count_width}}"
602
+ if o["quota"] is None:
603
+ note = "no limit"
604
+ progress = f"{progress} {'':>{quota_width}}"
605
+ else:
606
+ progress = f"{progress} / {o['quota']:>{quota_width}}"
607
+ note = "full" if o["full"] else f"{o['left']} left"
608
+ typer.echo(f" {o['text']:<{text_width}} {progress} {note}")
609
+
610
+
484
611
  @app.command("create")
485
612
  def create_usertest(
486
613
  from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with user test config"),
@@ -8,6 +8,7 @@ from rich.progress import Progress
8
8
  from rich.table import Table
9
9
 
10
10
  from ..client import PodojoClient
11
+ from ..tables import print_table
11
12
 
12
13
  app = typer.Typer(help="Manage videos")
13
14
  console = Console()
@@ -32,7 +33,7 @@ def list_videos(project: str = typer.Argument(help="Project name")):
32
33
 
33
34
  table = Table(title=f"Videos — {project} ({data['total']} total)")
34
35
  table.add_column("Date")
35
- table.add_column("Batch ID")
36
+ table.add_column("Batch ID", no_wrap=True)
36
37
  table.add_column("Name")
37
38
  table.add_column("Duration (min)")
38
39
 
@@ -44,7 +45,7 @@ def list_videos(project: str = typer.Argument(help="Project name")):
44
45
  str(v.get("duration_minutes", "")),
45
46
  )
46
47
 
47
- console.print(table)
48
+ print_table(console, table)
48
49
 
49
50
 
50
51
  @app.command("download")
@@ -0,0 +1,13 @@
1
+ from rich.console import Console
2
+ from rich.table import Table
3
+
4
+ # Off a terminal (Claude Code, a pipe) Rich falls back to 80 columns and cuts
5
+ # cells with "…", so agents end up guessing the rest of a batch ID. Tables
6
+ # render at their natural width up to this one instead.
7
+ OFF_TERMINAL_WIDTH = 200
8
+
9
+
10
+ def print_table(console: Console, table: Table) -> None:
11
+ if not console.is_terminal:
12
+ console = Console(width=OFF_TERMINAL_WIDTH)
13
+ console.print(table)
@@ -0,0 +1,97 @@
1
+ from podojo_cli.main import app
2
+
3
+
4
+ def test_list_transcripts(runner, httpx_mock):
5
+ httpx_mock.add_response(
6
+ method="GET",
7
+ url="http://test.local/api/v1/projects/Alpha/transcripts",
8
+ json={
9
+ "project": "Alpha",
10
+ "total": 2,
11
+ "interviews": [
12
+ {
13
+ "batch_id": "batch-1",
14
+ "batch_name": "session_1",
15
+ "date": "2026-05-01T12:00:00",
16
+ },
17
+ {
18
+ "batch_id": "batch-2",
19
+ "batch_name": "session_2",
20
+ "date": "2026-05-02T12:00:00",
21
+ },
22
+ ],
23
+ },
24
+ )
25
+
26
+ result = runner.invoke(app, ["transcripts", "list", "Alpha"])
27
+
28
+ assert result.exit_code == 0
29
+ assert "batch-1" in result.output
30
+ assert "session_2" in result.output
31
+
32
+
33
+ BATCH_ID = "62d1b9bf-8f03-41d1-b5e0-d0c4bd0e7eb6"
34
+
35
+
36
+ def test_list_transcripts_keeps_batch_ids_whole_off_terminal(runner, httpx_mock, monkeypatch):
37
+ monkeypatch.setenv("COLUMNS", "80")
38
+ httpx_mock.add_response(
39
+ method="GET",
40
+ url="http://test.local/api/v1/projects/Alpha/transcripts",
41
+ json={
42
+ "project": "Alpha",
43
+ "total": 1,
44
+ "interviews": [
45
+ {"batch_id": BATCH_ID, "batch_name": "RapidUsertest 32721 1 (3)", "date": "2026-09-17T14:10:00"},
46
+ ],
47
+ },
48
+ )
49
+
50
+ result = runner.invoke(app, ["transcripts", "list", "Alpha"])
51
+
52
+ assert result.exit_code == 0
53
+ assert BATCH_ID in result.output
54
+ assert "RapidUsertest 32721 1 (3)" in result.output
55
+
56
+
57
+ def test_download_all_transcripts(runner, httpx_mock, tmp_path):
58
+ httpx_mock.add_response(
59
+ method="GET",
60
+ url="http://test.local/api/v1/projects/Alpha/transcripts",
61
+ json={
62
+ "project": "Alpha",
63
+ "total": 3,
64
+ "interviews": [
65
+ {"batch_id": BATCH_ID, "batch_name": "Session 1/2"},
66
+ {"batch_id": "batch-2", "batch_name": "[DE] session_2"},
67
+ {"batch_id": "batch-3", "batch_name": "session_3"},
68
+ ],
69
+ },
70
+ )
71
+ httpx_mock.add_response(
72
+ method="GET", url=f"http://test.local/api/v1/projects/Alpha/transcripts/{BATCH_ID}", text="Hello"
73
+ )
74
+ httpx_mock.add_response(
75
+ method="GET", url="http://test.local/api/v1/projects/Alpha/transcripts/batch-2", text="Hallo"
76
+ )
77
+ httpx_mock.add_response(
78
+ method="GET", url="http://test.local/api/v1/projects/Alpha/transcripts/batch-3", text=""
79
+ )
80
+ folder = tmp_path / "transcripts"
81
+
82
+ result = runner.invoke(app, ["transcripts", "download", "Alpha", "--all", "-o", str(folder)])
83
+
84
+ assert result.exit_code == 0
85
+ assert (folder / f"Session 1_2 - {BATCH_ID}.txt").read_text() == "Hello"
86
+ assert (folder / "[DE] session_2 - batch-2.txt").read_text() == "Hallo"
87
+ assert sorted(p.name for p in folder.iterdir()) == [f"Session 1_2 - {BATCH_ID}.txt", "[DE] session_2 - batch-2.txt"]
88
+ assert "Downloaded 2 transcripts" in result.output
89
+ assert "No transcript yet: session_3" in result.output
90
+
91
+
92
+ def test_download_transcript_needs_batch_id_or_all(runner):
93
+ for args in (["Alpha"], ["Alpha", "batch-1", "--all"]):
94
+ result = runner.invoke(app, ["transcripts", "download", *args])
95
+
96
+ assert result.exit_code == 1
97
+ assert "Pass either a batch ID or --all" in result.output
@@ -1,3 +1,5 @@
1
+ import json
2
+
1
3
  import yaml
2
4
 
3
5
  from podojo_cli.main import app
@@ -799,3 +801,254 @@ def test_example_template_validates(runner, tmp_path):
799
801
 
800
802
  assert "variant: ai-question" in example.output
801
803
  assert result.exit_code == 0
804
+
805
+
806
+ def _quota_yaml(question: str) -> str:
807
+ return VALID_USERTEST_YAML + "screening_questions:\n" + question
808
+
809
+
810
+ def test_validate_quota_valid(runner, tmp_path):
811
+ yaml_file = tmp_path / "usertest.yaml"
812
+ yaml_file.write_text(
813
+ _quota_yaml(
814
+ " - text: How long?\n"
815
+ " options:\n"
816
+ " - text: Short\n"
817
+ " quota: 5\n"
818
+ " - text: Long\n"
819
+ " quota: null\n"
820
+ " - text: Abandoned a purchase?\n"
821
+ " screener: true\n"
822
+ " options:\n"
823
+ " - text: \"Yes\"\n"
824
+ " qualifies: true\n"
825
+ " quota: 10\n"
826
+ " - text: \"No\"\n"
827
+ )
828
+ )
829
+
830
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
831
+
832
+ assert result.exit_code == 0
833
+ assert "Valid user test config" in result.output
834
+
835
+
836
+ def test_validate_quota_must_be_a_positive_integer(runner, tmp_path):
837
+ for bad in ("0", "-2", "1.5", "true", "many"):
838
+ yaml_file = tmp_path / "usertest.yaml"
839
+ yaml_file.write_text(
840
+ _quota_yaml(
841
+ " - text: How long?\n"
842
+ " options:\n"
843
+ " - text: Short\n"
844
+ f" quota: {bad}\n"
845
+ " - text: Long\n"
846
+ )
847
+ )
848
+
849
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
850
+
851
+ assert result.exit_code == 1, bad
852
+ assert "Screening question 1, option 1: 'quota' must be a positive integer" in result.output
853
+
854
+
855
+ def test_validate_quota_rejected_on_multi_select(runner, tmp_path):
856
+ yaml_file = tmp_path / "usertest.yaml"
857
+ yaml_file.write_text(
858
+ _quota_yaml(
859
+ " - text: Which devices?\n"
860
+ " multi_select: true\n"
861
+ " options:\n"
862
+ " - text: Phone\n"
863
+ " quota: 5\n"
864
+ " - text: Laptop\n"
865
+ " quota: 5\n"
866
+ )
867
+ )
868
+
869
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
870
+
871
+ assert result.exit_code == 1
872
+ assert result.output.count("'quota' isn't supported on multi_select questions") == 1
873
+ assert "Screening question 1: 'quota' isn't supported on multi_select questions" in result.output
874
+
875
+
876
+ def test_validate_quota_on_screener_needs_qualifying_option(runner, tmp_path):
877
+ yaml_file = tmp_path / "usertest.yaml"
878
+ yaml_file.write_text(
879
+ _quota_yaml(
880
+ " - text: Abandoned a purchase?\n"
881
+ " screener: true\n"
882
+ " options:\n"
883
+ " - text: \"Yes\"\n"
884
+ " qualifies: true\n"
885
+ " - text: \"No\"\n"
886
+ " quota: 3\n"
887
+ )
888
+ )
889
+
890
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
891
+
892
+ assert result.exit_code == 1
893
+ assert "Screening question 1, option 2: 'quota' only works on" in result.output
894
+ assert "'qualifies: true'" in result.output
895
+
896
+
897
+ def test_get_usertest_drops_quota_counts(runner, httpx_mock):
898
+ httpx_mock.add_response(
899
+ url="http://test.local/api/v1/usertests/ut-1",
900
+ json={**GET_RESPONSE, "quota_counts": [[1, 2]]},
901
+ )
902
+
903
+ result = runner.invoke(app, ["usertests", "get", "ut-1"])
904
+
905
+ assert result.exit_code == 0
906
+ assert "quota_counts" not in result.stdout
907
+
908
+
909
+ STATUS_RESPONSE = {
910
+ **GET_RESPONSE,
911
+ "title": "Customer onboarding test",
912
+ "live": True,
913
+ "max_responses": None,
914
+ "response_count": 12,
915
+ "screening_questions": [
916
+ {
917
+ "text": "How long have you been a customer?",
918
+ "options": [
919
+ {"text": "Less than 1 year", "quota": 5},
920
+ {"text": "1–3 years", "quota": 5},
921
+ {"text": "More than 3 years", "quota": 5},
922
+ ],
923
+ },
924
+ {"text": "Plain question?", "options": [{"text": "A"}, {"text": "B"}]},
925
+ {
926
+ "text": "Are you a buyer?",
927
+ "screener": True,
928
+ "options": [
929
+ {"text": "Yes", "qualifies": True, "quota": 10},
930
+ {"text": "Sometimes", "qualifies": True},
931
+ {"text": "No"},
932
+ ],
933
+ },
934
+ ],
935
+ "quota_counts": [[5, 3, 4], [0, 0], [2, 6, 9]],
936
+ }
937
+
938
+
939
+ def test_status_live_with_quotas(runner, httpx_mock):
940
+ httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=STATUS_RESPONSE)
941
+
942
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
943
+
944
+ assert result.exit_code == 0
945
+ assert result.stdout == (
946
+ "Customer onboarding test · Live\n"
947
+ "Responses: 12\n"
948
+ "\n"
949
+ "How long have you been a customer?\n"
950
+ " Less than 1 year 5 / 5 full\n"
951
+ " 1–3 years 3 / 5 2 left\n"
952
+ " More than 3 years 4 / 5 1 left\n"
953
+ "Are you a buyer?\n"
954
+ " Yes 2 / 10 8 left\n"
955
+ " Sometimes 6 no limit\n"
956
+ )
957
+
958
+
959
+ def test_status_closed_by_full_question(runner, httpx_mock):
960
+ httpx_mock.add_response(
961
+ url="http://test.local/api/v1/usertests/ut-1",
962
+ json={
963
+ **STATUS_RESPONSE,
964
+ "live": False,
965
+ "max_responses": 50,
966
+ "quota_counts": [[5, 5, 5], [0, 0], [0, 0, 0]],
967
+ },
968
+ )
969
+
970
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
971
+
972
+ lines = result.stdout.splitlines()
973
+ assert lines[0] == "Customer onboarding test · Not live"
974
+ assert lines[1] == 'Closed automatically: every answer to "How long have you been a customer?" is full'
975
+ assert lines[2] == "Responses: 12 / 50"
976
+
977
+
978
+ def test_status_closed_by_max_responses(runner, httpx_mock):
979
+ httpx_mock.add_response(
980
+ url="http://test.local/api/v1/usertests/ut-1",
981
+ json={**STATUS_RESPONSE, "live": False, "max_responses": 12},
982
+ )
983
+
984
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
985
+
986
+ assert "Closed automatically: max responses reached" in result.stdout
987
+ assert "Responses: 12 / 12" in result.stdout
988
+
989
+
990
+ def test_status_not_live_without_a_reason(runner, httpx_mock):
991
+ httpx_mock.add_response(
992
+ url="http://test.local/api/v1/usertests/ut-1",
993
+ json={**STATUS_RESPONSE, "live": False},
994
+ )
995
+
996
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
997
+
998
+ assert "Not live" in result.stdout
999
+ assert "Closed automatically" not in result.stdout
1000
+
1001
+
1002
+ def test_status_without_quotas(runner, httpx_mock):
1003
+ httpx_mock.add_response(
1004
+ url="http://test.local/api/v1/usertests/ut-1",
1005
+ json={**GET_RESPONSE, "title": "Plain", "live": True, "max_responses": None},
1006
+ )
1007
+
1008
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
1009
+
1010
+ assert result.exit_code == 0
1011
+ assert result.stdout == "Plain · Live\nResponses: 7\n"
1012
+
1013
+
1014
+ def test_status_missing_quota_counts_means_zeros(runner, httpx_mock):
1015
+ data = {k: v for k, v in STATUS_RESPONSE.items() if k != "quota_counts"}
1016
+ httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=data)
1017
+
1018
+ result = runner.invoke(app, ["usertests", "status", "ut-1"])
1019
+
1020
+ assert result.exit_code == 0
1021
+ assert " Less than 1 year 0 / 5 5 left" in result.stdout
1022
+
1023
+
1024
+ def test_status_json(runner, httpx_mock):
1025
+ httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=STATUS_RESPONSE)
1026
+
1027
+ result = runner.invoke(app, ["usertests", "status", "ut-1", "--json"])
1028
+
1029
+ assert result.exit_code == 0
1030
+ data = json.loads(result.stdout)
1031
+ assert data["usertest_id"] == "ut-1"
1032
+ assert data["live"] is True
1033
+ assert data["response_count"] == 12
1034
+ assert data["max_responses"] is None
1035
+ assert data["closed_reason"] is None
1036
+ assert [q["question"] for q in data["quotas"]] == [
1037
+ "How long have you been a customer?",
1038
+ "Are you a buyer?",
1039
+ ]
1040
+ assert data["quotas"][0]["options"][0] == {
1041
+ "text": "Less than 1 year", "count": 5, "quota": 5, "full": True, "left": 0,
1042
+ }
1043
+ assert data["quotas"][1]["options"][1] == {
1044
+ "text": "Sometimes", "count": 6, "quota": None, "full": False, "left": None,
1045
+ }
1046
+
1047
+
1048
+ def test_status_not_found(runner, httpx_mock):
1049
+ httpx_mock.add_response(url="http://test.local/api/v1/usertests/nope", status_code=404)
1050
+
1051
+ result = runner.invoke(app, ["usertests", "status", "nope"])
1052
+
1053
+ assert result.exit_code == 1
1054
+ assert "User test 'nope' not found" in result.output
@@ -0,0 +1,28 @@
1
+ from podojo_cli.main import app
2
+
3
+
4
+ def test_list_videos_keeps_batch_ids_whole_off_terminal(runner, httpx_mock, monkeypatch):
5
+ monkeypatch.setenv("COLUMNS", "80")
6
+ batch_id = "62d1b9bf-8f03-41d1-b5e0-d0c4bd0e7eb6"
7
+ httpx_mock.add_response(
8
+ method="GET",
9
+ url="http://test.local/api/v1/projects/Alpha/videos",
10
+ json={
11
+ "project": "Alpha",
12
+ "total": 1,
13
+ "videos": [
14
+ {
15
+ "batch_id": batch_id,
16
+ "batch_name": "RapidUsertest 32721 1 (3)",
17
+ "batch_timestamp": "2026-09-17T14:10:00",
18
+ "duration_minutes": 12.4,
19
+ },
20
+ ],
21
+ },
22
+ )
23
+
24
+ result = runner.invoke(app, ["videos", "list", "Alpha"])
25
+
26
+ assert result.exit_code == 0
27
+ assert batch_id in result.output
28
+ assert "RapidUsertest 32721 1 (3)" in result.output
@@ -438,7 +438,7 @@ wheels = [
438
438
 
439
439
  [[package]]
440
440
  name = "podojo-cli"
441
- version = "1.30.0"
441
+ version = "1.32.0"
442
442
  source = { editable = "." }
443
443
  dependencies = [
444
444
  { name = "httpx" },
@@ -1,58 +0,0 @@
1
- from datetime import datetime
2
- from pathlib import Path
3
-
4
- import typer
5
- from rich.console import Console
6
- from rich.table import Table
7
-
8
- from ..client import PodojoClient
9
-
10
- app = typer.Typer(help="Manage transcripts")
11
- console = Console()
12
-
13
-
14
- def _format_date(value) -> str:
15
- if not value:
16
- return ""
17
- try:
18
- return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
19
- except (ValueError, AttributeError):
20
- return str(value)
21
-
22
-
23
- @app.command("list")
24
- def list_transcripts(project: str = typer.Argument(help="Project name")):
25
- """List transcripts for a project."""
26
- client = PodojoClient()
27
- data = client.list_transcripts(project)
28
-
29
- table = Table(title=f"Transcripts — {project} ({data['total']} total)")
30
- table.add_column("Date")
31
- table.add_column("Batch ID")
32
- table.add_column("Name")
33
-
34
- for t in data["interviews"]:
35
- table.add_row(
36
- _format_date(t.get("date")),
37
- t.get("batch_id", ""),
38
- t.get("batch_name", ""),
39
- )
40
-
41
- console.print(table)
42
-
43
-
44
- @app.command("download")
45
- def download_transcript(
46
- project: str = typer.Argument(help="Project name"),
47
- batch_id: str = typer.Argument(help="Batch ID"),
48
- output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
49
- ):
50
- """Download a transcript."""
51
- client = PodojoClient()
52
- text = client.download_transcript(project, batch_id)
53
-
54
- if output:
55
- output.write_text(text, encoding="utf-8")
56
- console.print(f"Saved to {output}")
57
- else:
58
- console.print(text)
@@ -1,30 +0,0 @@
1
- from podojo_cli.main import app
2
-
3
-
4
- def test_list_transcripts(runner, httpx_mock):
5
- httpx_mock.add_response(
6
- method="GET",
7
- url="http://test.local/api/v1/projects/Alpha/transcripts",
8
- json={
9
- "project": "Alpha",
10
- "total": 2,
11
- "interviews": [
12
- {
13
- "batch_id": "batch-1",
14
- "batch_name": "session_1",
15
- "date": "2026-05-01T12:00:00",
16
- },
17
- {
18
- "batch_id": "batch-2",
19
- "batch_name": "session_2",
20
- "date": "2026-05-02T12:00:00",
21
- },
22
- ],
23
- },
24
- )
25
-
26
- result = runner.invoke(app, ["transcripts", "list", "Alpha"])
27
-
28
- assert result.exit_code == 0
29
- assert "batch-1" in result.output
30
- assert "session_2" in result.output
File without changes
File without changes
File without changes