podojo-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: astral-sh/setup-uv@v4
17
+
18
+ - run: uv build
19
+
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .env
@@ -0,0 +1,40 @@
1
+ # Podojo CLI
2
+
3
+ CLI tool for the Podojo user research platform. Part of the Podojo multi-repo architecture.
4
+
5
+ ## Tech Stack
6
+
7
+ - Python 3.12, uv for dependency management
8
+ - Typer for CLI framework
9
+ - httpx for HTTP client
10
+ - Rich for terminal output
11
+ - ffmpeg for video editing (system dependency, install via `brew install ffmpeg`)
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ podojo auth login <api-key>
17
+ podojo auth logout
18
+ podojo projects list
19
+ podojo transcripts list <project>
20
+ podojo transcripts download <project> <batch_id> -o output.txt
21
+ podojo videos list <project>
22
+ podojo videos download <batch_id> -o output.mp4
23
+ podojo showreel create clips.json -o showreel.mp4
24
+ podojo gdrive setup ~/.podojo-gdrive.json
25
+ podojo gdrive upload report.md --title "My Report"
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Set via env vars or `~/.podojo.toml`:
31
+
32
+ ```toml
33
+ base_url = "https://your-api.example.com"
34
+ api_key = "your-api-key"
35
+ ```
36
+
37
+ ## Git Commits
38
+
39
+ - Do NOT add "Co-Authored-By: Claude" trailers to commit messages
40
+ - Keep commit messages concise and focused on the "why"
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: podojo-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Podojo user research platform
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: google-api-python-client>=2.0
8
+ Requires-Dist: google-auth>=2.0
9
+ Requires-Dist: httpx>=0.28
10
+ Requires-Dist: rich>=13.0
11
+ Requires-Dist: typer>=0.15
12
+ Description-Content-Type: text/markdown
13
+
14
+ # podojo-cli
15
+
16
+ CLI for the Podojo user research platform.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ uv tool install podojo-cli
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ podojo --help
28
+ ```
@@ -0,0 +1,15 @@
1
+ # podojo-cli
2
+
3
+ CLI for the Podojo user research platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ uv tool install podojo-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ podojo --help
15
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "podojo-cli"
3
+ version = "0.1.0"
4
+ description = "CLI for the Podojo user research platform"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "typer>=0.15",
10
+ "httpx>=0.28",
11
+ "rich>=13.0",
12
+ "google-auth>=2.0",
13
+ "google-api-python-client>=2.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ podojo = "podojo_cli.main:app"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8.0", "pytest-httpx>=0.35"]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/podojo_cli"]
File without changes
@@ -0,0 +1,54 @@
1
+ import httpx
2
+
3
+ from .config import load_config
4
+
5
+
6
+ class PodojoClient:
7
+ def __init__(self):
8
+ config = load_config()
9
+ self.base_url = config["base_url"].rstrip("/") + "/api/v1"
10
+ self.api_key = config["api_key"]
11
+
12
+ def _headers(self) -> dict:
13
+ return {"Authorization": f"Bearer {self.api_key}"}
14
+
15
+ def list_projects(self, skip: int = 0, limit: int = 50) -> list:
16
+ r = httpx.get(
17
+ f"{self.base_url}/projects",
18
+ params={"skip": skip, "limit": limit},
19
+ headers=self._headers(),
20
+ )
21
+ r.raise_for_status()
22
+ return r.json()["projects"]
23
+
24
+ def list_transcripts(self, project: str) -> dict:
25
+ r = httpx.get(
26
+ f"{self.base_url}/projects/{project}/transcripts",
27
+ headers=self._headers(),
28
+ )
29
+ r.raise_for_status()
30
+ return r.json()
31
+
32
+ def download_transcript(self, project: str, batch_id: str) -> str:
33
+ r = httpx.get(
34
+ f"{self.base_url}/projects/{project}/transcripts/{batch_id}",
35
+ headers=self._headers(),
36
+ )
37
+ r.raise_for_status()
38
+ return r.text
39
+
40
+ def list_videos(self, project: str) -> dict:
41
+ r = httpx.get(
42
+ f"{self.base_url}/projects/{project}/videos",
43
+ headers=self._headers(),
44
+ )
45
+ r.raise_for_status()
46
+ return r.json()
47
+
48
+ def get_video_url(self, batch_id: str) -> dict:
49
+ r = httpx.get(
50
+ f"{self.base_url}/videos/{batch_id}",
51
+ headers=self._headers(),
52
+ )
53
+ r.raise_for_status()
54
+ return r.json()
File without changes
@@ -0,0 +1,43 @@
1
+ import httpx
2
+ import typer
3
+ from rich.console import Console
4
+
5
+ from ..config import DEFAULT_BASE_URL, clear_api_key, load_config, save_config
6
+
7
+ app = typer.Typer(help="Authenticate with Podojo")
8
+ console = Console()
9
+
10
+
11
+ def _verify_key(api_key: str) -> bool:
12
+ config = load_config()
13
+ base_url = config["base_url"].rstrip("/") + "/api/v1"
14
+ try:
15
+ r = httpx.get(
16
+ f"{base_url}/projects",
17
+ params={"limit": 1},
18
+ headers={"Authorization": f"Bearer {api_key}"},
19
+ timeout=10,
20
+ )
21
+ return r.status_code == 200
22
+ except httpx.RequestError:
23
+ return False
24
+
25
+
26
+ @app.command("login")
27
+ def login(api_key: str = typer.Argument(help="Your Podojo API key")):
28
+ """Save your API key and verify it works."""
29
+ console.print("Verifying key…", end=" ")
30
+ if not _verify_key(api_key):
31
+ console.print("[red]failed[/red]")
32
+ console.print("[red]Error:[/red] Invalid API key or could not reach the server.")
33
+ raise typer.Exit(1)
34
+ save_config(api_key)
35
+ console.print("[green]done[/green]")
36
+ console.print("[green]Logged in successfully.[/green]")
37
+
38
+
39
+ @app.command("logout")
40
+ def logout():
41
+ """Remove the saved API key."""
42
+ clear_api_key()
43
+ console.print("Logged out.")
@@ -0,0 +1,51 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from ..gdrive.list import list_files
7
+ from ..gdrive.upload import CREDENTIALS_FILENAME, upload_md_as_doc
8
+
9
+ app = typer.Typer(help="Upload documents to Google Drive")
10
+ console = Console()
11
+
12
+
13
+ @app.command("upload")
14
+ def upload(
15
+ file: Path = typer.Argument(help="Markdown file to upload"),
16
+ folder_id: str = typer.Option(..., "--folder-id", "-f", help="Google Drive folder ID"),
17
+ title: str = typer.Option(None, "--title", "-t", help="Document title (defaults to filename)"),
18
+ ):
19
+ """Upload a markdown file to Google Drive as a Google Doc."""
20
+ if not file.exists():
21
+ console.print(f"[red]Error:[/red] File not found: {file}")
22
+ raise typer.Exit(1)
23
+
24
+ if not Path(CREDENTIALS_FILENAME).exists():
25
+ console.print(f"[red]Error:[/red] {CREDENTIALS_FILENAME} not found in current directory")
26
+ raise typer.Exit(1)
27
+
28
+ console.print(f"Uploading [bold]{file}[/bold]…", end=" ")
29
+ _, url = upload_md_as_doc(str(file), folder_id=folder_id, title=title)
30
+ console.print("[green]done[/green]")
31
+ console.print(url)
32
+
33
+
34
+ @app.command("list")
35
+ def list_cmd(
36
+ folder_id: str = typer.Option(..., "--folder-id", "-f", help="Google Drive folder ID"),
37
+ ):
38
+ """List files in a Google Drive folder."""
39
+ if not Path(CREDENTIALS_FILENAME).exists():
40
+ console.print(f"[red]Error:[/red] {CREDENTIALS_FILENAME} not found in current directory")
41
+ raise typer.Exit(1)
42
+
43
+ files = list_files(folder_id=folder_id)
44
+
45
+ if not files:
46
+ console.print("No files found.")
47
+ return
48
+
49
+ for f in files:
50
+ link = f.get("webViewLink", "")
51
+ console.print(f"[bold]{f['name']}[/bold] {link}")
@@ -0,0 +1,27 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+
5
+ from ..client import PodojoClient
6
+
7
+ app = typer.Typer(help="Manage projects")
8
+ console = Console()
9
+
10
+
11
+ @app.command("list")
12
+ def list_projects(
13
+ skip: int = typer.Option(0, help="Number of projects to skip"),
14
+ limit: int = typer.Option(50, help="Max projects to return"),
15
+ ):
16
+ """List all projects."""
17
+ client = PodojoClient()
18
+ projects = client.list_projects(skip=skip, limit=limit)
19
+
20
+ table = Table(title="Projects")
21
+ table.add_column("Name")
22
+ table.add_column("Brief")
23
+
24
+ for p in projects:
25
+ table.add_row(p.get("name", ""), p.get("brief", ""))
26
+
27
+ console.print(table)
@@ -0,0 +1,108 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn
11
+
12
+ from ..client import PodojoClient
13
+ from ..video.showreel import extract_clip, make_title_card, concatenate
14
+
15
+ app = typer.Typer(help="Generate video showreels")
16
+ console = Console()
17
+
18
+
19
+ def _check_ffmpeg():
20
+ if shutil.which("ffmpeg") is None:
21
+ console.print("[red]Error:[/red] ffmpeg not found. Install it with: brew install ffmpeg")
22
+ raise typer.Exit(1)
23
+
24
+
25
+ def _download_video(url: str, dest: Path):
26
+ with httpx.stream("GET", url, follow_redirects=True) as r:
27
+ r.raise_for_status()
28
+ total = int(r.headers.get("content-length", 0))
29
+ with open(dest, "wb") as f, Progress(
30
+ SpinnerColumn(),
31
+ TextColumn("{task.description}"),
32
+ BarColumn(),
33
+ DownloadColumn(),
34
+ console=console,
35
+ ) as progress:
36
+ task = progress.add_task(f" Downloading {dest.name}…", total=total or None)
37
+ for chunk in r.iter_bytes(chunk_size=8192):
38
+ f.write(chunk)
39
+ progress.update(task, advance=len(chunk))
40
+
41
+
42
+ @app.command("create")
43
+ def create_showreel(
44
+ clips_json: Path = typer.Argument(help="Path to clips JSON file"),
45
+ output: Path = typer.Option("showreel.mp4", "-o", "--output", help="Output .mp4 path"),
46
+ ):
47
+ """Create a video showreel from a clips JSON config.
48
+
49
+ The clips JSON should be a list of objects with these fields:
50
+ batch_id - video batch ID to fetch from the API
51
+ participant - display name (e.g. "P09 — Umid")
52
+ country - participant country
53
+ topic - short description of what the clip shows
54
+ start - clip start time (MM:SS or HH:MM:SS)
55
+ end - clip end time (MM:SS or HH:MM:SS)
56
+ """
57
+ _check_ffmpeg()
58
+
59
+ with open(clips_json) as f:
60
+ clips = json.load(f)
61
+
62
+ if not clips:
63
+ console.print("[yellow]No clips found in JSON.[/yellow]")
64
+ raise typer.Exit(0)
65
+
66
+ client = PodojoClient()
67
+ output = output.resolve()
68
+ output.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ with tempfile.TemporaryDirectory(prefix="podojo-showreel-") as tmp:
71
+ tmp = Path(tmp)
72
+
73
+ # Download each unique batch_id once
74
+ video_paths: dict[str, Path] = {}
75
+ unique_ids = {c["batch_id"] for c in clips}
76
+ console.print(f"\nDownloading {len(unique_ids)} video(s)…")
77
+
78
+ for batch_id in unique_ids:
79
+ data = client.get_video_url(batch_id)
80
+ dest = tmp / f"{batch_id}.mp4"
81
+ _download_video(data["url"], dest)
82
+ video_paths[batch_id] = dest
83
+
84
+ # Build segments
85
+ parts: list[str] = []
86
+ console.print(f"\nProcessing {len(clips)} clip(s)…")
87
+
88
+ for i, clip in enumerate(clips, 1):
89
+ participant = clip["participant"]
90
+ country = clip["country"]
91
+ topic = clip["topic"]
92
+ src = str(video_paths[clip["batch_id"]])
93
+
94
+ console.print(f" [{i}/{len(clips)}] {participant} — {topic}")
95
+
96
+ title_dst = str(tmp / f"_title_{i:02d}.mp4")
97
+ clip_dst = str(tmp / f"_clip_{i:02d}.mp4")
98
+
99
+ make_title_card(participant, country, topic, title_dst)
100
+ extract_clip(src, clip["start"], clip["end"], clip_dst)
101
+
102
+ parts.extend([title_dst, clip_dst])
103
+
104
+ console.print("\nConcatenating…")
105
+ concatenate(parts, str(output))
106
+
107
+ size_mb = os.path.getsize(output) / (1024 * 1024)
108
+ console.print(f"\n[green]Done![/green] {output} ({size_mb:.1f} MB)")
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from ..client import PodojoClient
8
+
9
+ app = typer.Typer(help="Manage transcripts")
10
+ console = Console()
11
+
12
+
13
+ @app.command("list")
14
+ def list_transcripts(project: str = typer.Argument(help="Project name")):
15
+ """List transcripts for a project."""
16
+ client = PodojoClient()
17
+ data = client.list_transcripts(project)
18
+
19
+ table = Table(title=f"Transcripts — {project} ({data['total']} total)")
20
+ table.add_column("Batch ID")
21
+ table.add_column("Name")
22
+
23
+ for t in data["interviews"]:
24
+ table.add_row(t.get("batch_id", ""), t.get("batch_name", ""))
25
+
26
+ console.print(table)
27
+
28
+
29
+ @app.command("download")
30
+ def download_transcript(
31
+ project: str = typer.Argument(help="Project name"),
32
+ batch_id: str = typer.Argument(help="Batch ID"),
33
+ output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
34
+ ):
35
+ """Download a transcript."""
36
+ client = PodojoClient()
37
+ text = client.download_transcript(project, batch_id)
38
+
39
+ if output:
40
+ output.write_text(text)
41
+ console.print(f"Saved to {output}")
42
+ else:
43
+ console.print(text)
@@ -0,0 +1,57 @@
1
+ from pathlib import Path
2
+
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.progress import Progress
7
+ from rich.table import Table
8
+
9
+ from ..client import PodojoClient
10
+
11
+ app = typer.Typer(help="Manage videos")
12
+ console = Console()
13
+
14
+
15
+ @app.command("list")
16
+ def list_videos(project: str = typer.Argument(help="Project name")):
17
+ """List videos for a project."""
18
+ client = PodojoClient()
19
+ data = client.list_videos(project)
20
+
21
+ table = Table(title=f"Videos — {project} ({data['total']} total)")
22
+ table.add_column("Batch ID")
23
+ table.add_column("Name")
24
+ table.add_column("Duration (min)")
25
+
26
+ for v in data["videos"]:
27
+ table.add_row(
28
+ v.get("batch_id", ""),
29
+ v.get("batch_name", ""),
30
+ str(v.get("duration_minutes", "")),
31
+ )
32
+
33
+ console.print(table)
34
+
35
+
36
+ @app.command("download")
37
+ def download_video(
38
+ batch_id: str = typer.Argument(help="Batch ID"),
39
+ output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
40
+ ):
41
+ """Download a video file."""
42
+ client = PodojoClient()
43
+ data = client.get_video_url(batch_id)
44
+ url = data["url"]
45
+
46
+ filename = output or Path(data.get("filename", f"{batch_id}.mp4"))
47
+
48
+ with httpx.stream("GET", url) as r:
49
+ r.raise_for_status()
50
+ total = int(r.headers.get("content-length", 0))
51
+ with open(filename, "wb") as f, Progress() as progress:
52
+ task = progress.add_task("Downloading...", total=total)
53
+ for chunk in r.iter_bytes(chunk_size=8192):
54
+ f.write(chunk)
55
+ progress.update(task, advance=len(chunk))
56
+
57
+ console.print(f"Saved to {filename}")
@@ -0,0 +1,39 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import tomllib
5
+
6
+
7
+ CONFIG_PATH = Path.home() / ".podojo.toml"
8
+ DEFAULT_BASE_URL = "https://podojo-fastapi-mcp.onrender.com"
9
+
10
+
11
+ def load_config() -> dict:
12
+ config = {}
13
+ if CONFIG_PATH.exists():
14
+ config = tomllib.loads(CONFIG_PATH.read_text())
15
+ config.setdefault("base_url", os.getenv("PODOJO_BASE_URL", DEFAULT_BASE_URL))
16
+ config.setdefault("api_key", os.getenv("PODOJO_API_KEY", ""))
17
+ return config
18
+
19
+
20
+ def save_config(api_key: str):
21
+ config = {}
22
+ if CONFIG_PATH.exists():
23
+ config = tomllib.loads(CONFIG_PATH.read_text())
24
+ config["api_key"] = api_key
25
+ config.setdefault("base_url", DEFAULT_BASE_URL)
26
+ lines = "\n".join(f'{k} = "{v}"' for k, v in config.items())
27
+ CONFIG_PATH.write_text(lines + "\n")
28
+
29
+
30
+ def clear_api_key():
31
+ if not CONFIG_PATH.exists():
32
+ return
33
+ config = tomllib.loads(CONFIG_PATH.read_text())
34
+ config.pop("api_key", None)
35
+ if config:
36
+ lines = "\n".join(f'{k} = "{v}"' for k, v in config.items())
37
+ CONFIG_PATH.write_text(lines + "\n")
38
+ else:
39
+ CONFIG_PATH.unlink()
File without changes
@@ -0,0 +1,20 @@
1
+ """List files in a Google Drive folder."""
2
+
3
+ from .upload import get_drive_service
4
+
5
+
6
+ def list_files(folder_id: str) -> list[dict]:
7
+ """Return a list of files in the given folder (id, name, mimeType, webViewLink)."""
8
+ drive = get_drive_service()
9
+
10
+ results = (
11
+ drive.files()
12
+ .list(
13
+ q=f"'{folder_id}' in parents and trashed = false",
14
+ fields="files(id, name, mimeType, webViewLink)",
15
+ supportsAllDrives=True,
16
+ includeItemsFromAllDrives=True,
17
+ )
18
+ .execute()
19
+ )
20
+ return results.get("files", [])
@@ -0,0 +1,56 @@
1
+ """Upload a markdown file to Google Drive as a Google Doc."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from google.oauth2.service_account import Credentials
7
+ from googleapiclient.discovery import build
8
+ from googleapiclient.http import MediaFileUpload
9
+
10
+
11
+ CREDENTIALS_FILENAME = "service-account.json"
12
+
13
+
14
+ def get_drive_service():
15
+ credentials_path = Path.cwd() / CREDENTIALS_FILENAME
16
+ if not credentials_path.exists():
17
+ raise FileNotFoundError(f"{CREDENTIALS_FILENAME} not found in current directory")
18
+ creds = Credentials.from_service_account_file(
19
+ str(credentials_path),
20
+ scopes=["https://www.googleapis.com/auth/drive"],
21
+ )
22
+ return build("drive", "v3", credentials=creds)
23
+
24
+
25
+ def upload_md_as_doc(
26
+ md_file: str,
27
+ folder_id: str,
28
+ title: str | None = None,
29
+ ) -> tuple[str, str]:
30
+ drive = get_drive_service()
31
+
32
+ if not title:
33
+ title = os.path.splitext(os.path.basename(md_file))[0]
34
+
35
+ parent = folder_id
36
+
37
+ media = MediaFileUpload(md_file, mimetype="text/markdown")
38
+ file_metadata = {
39
+ "name": title,
40
+ "mimeType": "application/vnd.google-apps.document",
41
+ "parents": [parent],
42
+ }
43
+ result = (
44
+ drive.files()
45
+ .create(
46
+ body=file_metadata,
47
+ media_body=media,
48
+ fields="id",
49
+ supportsAllDrives=True,
50
+ )
51
+ .execute()
52
+ )
53
+
54
+ doc_id = result["id"]
55
+ url = f"https://docs.google.com/document/d/{doc_id}/edit"
56
+ return doc_id, url
@@ -0,0 +1,19 @@
1
+ import typer
2
+
3
+ from .commands import auth, gdrive, projects, showreel, transcripts, videos
4
+
5
+ app = typer.Typer(
6
+ name="podojo",
7
+ help="CLI for the Podojo user research platform",
8
+ no_args_is_help=True,
9
+ )
10
+
11
+ app.add_typer(auth.app, name="auth")
12
+ app.add_typer(projects.app, name="projects")
13
+ app.add_typer(transcripts.app, name="transcripts")
14
+ app.add_typer(videos.app, name="videos")
15
+ app.add_typer(showreel.app, name="showreel")
16
+ app.add_typer(gdrive.app, name="gdrive")
17
+
18
+ if __name__ == "__main__":
19
+ app()
File without changes