talk-python-cli 0.1.0__py3-none-any.whl

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,3 @@
1
+ """Talk Python to Me CLI — query podcast episodes, guests, and courses."""
2
+
3
+ __version__ = '0.1.0'
@@ -0,0 +1,5 @@
1
+ """Allow running as ``python -m talk_python_cli``."""
2
+
3
+ from talk_python_cli.app import main
4
+
5
+ main()
talk_python_cli/app.py ADDED
@@ -0,0 +1,91 @@
1
+ """Root Cyclopts application with global options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Annotated, Literal
7
+
8
+ import cyclopts
9
+
10
+ from talk_python_cli import __version__
11
+ from talk_python_cli.client import DEFAULT_URL, MCPClient
12
+ from talk_python_cli.formatting import is_tty, print_error
13
+
14
+ # ── Shared state ─────────────────────────────────────────────────────────────
15
+ # The meta-app handler stores the client here so command modules can access it.
16
+ _client: MCPClient | None = None
17
+
18
+
19
+ def get_client() -> MCPClient:
20
+ """Return the active MCPClient (set by the meta-app launcher)."""
21
+ assert _client is not None, 'MCPClient not initialised — this is a bug'
22
+ return _client
23
+
24
+
25
+ # ── Root app ─────────────────────────────────────────────────────────────────
26
+ app = cyclopts.App(
27
+ name='talkpython',
28
+ help='CLI for the Talk Python to Me podcast and courses.\n\n'
29
+ 'Query episodes, guests, transcripts, and training courses\n'
30
+ 'from the Talk Python MCP server.',
31
+ version=__version__,
32
+ version_flags=['--version', '-V'],
33
+ )
34
+
35
+ # ── Register sub-apps (imported here to avoid circular imports) ──────────────
36
+ from talk_python_cli.courses import courses_app # noqa: E402
37
+ from talk_python_cli.episodes import episodes_app # noqa: E402
38
+ from talk_python_cli.guests import guests_app # noqa: E402
39
+
40
+ app.command(episodes_app)
41
+ app.command(guests_app)
42
+ app.command(courses_app)
43
+
44
+
45
+ # ── Meta-app: handles global options before dispatching to sub-commands ──────
46
+ @app.meta.default
47
+ def launcher(
48
+ *tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
49
+ format: Annotated[
50
+ Literal['text', 'json'],
51
+ cyclopts.Parameter(
52
+ name='--format',
53
+ help="Output format: 'text' (rich Markdown) or 'json'. Defaults to 'json' when stdout is piped.",
54
+ ),
55
+ ] = None, # type: ignore
56
+ url: Annotated[
57
+ str,
58
+ cyclopts.Parameter(
59
+ name='--url',
60
+ help='MCP server URL.',
61
+ show_default=True,
62
+ ),
63
+ ] = DEFAULT_URL,
64
+ ) -> None:
65
+ global _client
66
+
67
+ # Auto-detect: default to json when piped, text when interactive
68
+ if format is None:
69
+ format = 'text' if is_tty() else 'json'
70
+
71
+ _client = MCPClient(base_url=url, output_format=format)
72
+ try:
73
+ app(tokens)
74
+ except Exception as exc:
75
+ print_error(str(exc))
76
+ sys.exit(1)
77
+ finally:
78
+ _client.close()
79
+ _client = None
80
+
81
+
82
+ # ── Entrypoint ───────────────────────────────────────────────────────────────
83
+ def main() -> None:
84
+ """CLI entrypoint — called by the ``talkpython`` console script."""
85
+ try:
86
+ app.meta()
87
+ except SystemExit:
88
+ raise
89
+ except Exception as exc:
90
+ print_error(str(exc))
91
+ sys.exit(1)
@@ -0,0 +1,144 @@
1
+ """MCP HTTP client — thin JSON-RPC 2.0 wrapper over httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from talk_python_cli import __version__
8
+
9
+ DEFAULT_URL = 'https://talkpython.fm/api/mcp'
10
+ _PROTOCOL_VERSION = '2025-03-26'
11
+ _CLIENT_INFO = {'name': 'talk-python-cli', 'version': __version__}
12
+
13
+
14
+ class MCPError(Exception):
15
+ """Raised when the MCP server returns a JSON-RPC error."""
16
+
17
+ def __init__(self, code: int, message: str, data: object = None):
18
+ self.code = code
19
+ self.message = message
20
+ self.data = data
21
+ super().__init__(f'MCP error {code}: {message}')
22
+
23
+
24
+ class MCPClient:
25
+ """Synchronous client for the Talk Python MCP server (Streamable HTTP)."""
26
+
27
+ def __init__(self, base_url: str = DEFAULT_URL, output_format: str = 'text'):
28
+ self.base_url = base_url.rstrip('/')
29
+ self.output_format = output_format
30
+ self._msg_id = 0
31
+ self._session_id: str | None = None
32
+ self._initialized = False
33
+ self._http = httpx.Client(
34
+ timeout=30.0,
35
+ headers={
36
+ 'Content-Type': 'application/json',
37
+ 'Accept': 'application/json, text/event-stream',
38
+ 'User-Agent': f'talk-python-cli/{__version__}',
39
+ },
40
+ )
41
+
42
+ # -- internal helpers -----------------------------------------------------
43
+
44
+ def _next_id(self) -> int:
45
+ self._msg_id += 1
46
+ return self._msg_id
47
+
48
+ def _url(self) -> str:
49
+ if self.output_format == 'json':
50
+ return f'{self.base_url}?format=json'
51
+ return self.base_url
52
+
53
+ def _post(self, payload: dict) -> httpx.Response:
54
+ """Send a JSON-RPC request, attaching session header if available."""
55
+ headers: dict[str, str] = {}
56
+ if self._session_id:
57
+ headers['Mcp-Session-Id'] = self._session_id
58
+ resp = self._http.post(self._url(), json=payload, headers=headers)
59
+ # Capture session id from response
60
+ if 'mcp-session-id' in resp.headers:
61
+ self._session_id = resp.headers['mcp-session-id']
62
+ resp.raise_for_status()
63
+ return resp
64
+
65
+ def _send_request(self, method: str, params: dict | None = None) -> dict:
66
+ """Send a JSON-RPC *request* (expects a response with an id)."""
67
+ payload: dict = {
68
+ 'jsonrpc': '2.0',
69
+ 'id': self._next_id(),
70
+ 'method': method,
71
+ }
72
+ if params is not None:
73
+ payload['params'] = params
74
+
75
+ resp = self._post(payload)
76
+ body = resp.json()
77
+
78
+ if 'error' in body:
79
+ err = body['error']
80
+ raise MCPError(err.get('code', -1), err.get('message', 'Unknown error'), err.get('data'))
81
+
82
+ return body.get('result', {})
83
+
84
+ def _send_notification(self, method: str, params: dict | None = None) -> None:
85
+ """Send a JSON-RPC *notification* (no id, no response expected)."""
86
+ payload: dict = {
87
+ 'jsonrpc': '2.0',
88
+ 'method': method,
89
+ }
90
+ if params is not None:
91
+ payload['params'] = params
92
+ self._post(payload)
93
+
94
+ # -- MCP lifecycle --------------------------------------------------------
95
+
96
+ def _initialize(self) -> None:
97
+ """Perform the MCP initialize handshake."""
98
+ self._send_request(
99
+ 'initialize',
100
+ {
101
+ 'protocolVersion': _PROTOCOL_VERSION,
102
+ 'capabilities': {},
103
+ 'clientInfo': _CLIENT_INFO,
104
+ },
105
+ )
106
+ self._send_notification('notifications/initialized')
107
+ self._initialized = True
108
+
109
+ def _ensure_initialized(self) -> None:
110
+ if not self._initialized:
111
+ self._initialize()
112
+
113
+ # -- public API -----------------------------------------------------------
114
+
115
+ def call_tool(self, tool_name: str, arguments: dict | None = None) -> str:
116
+ """Call an MCP tool and return the text content from the first result.
117
+
118
+ Returns the raw text string from the server (Markdown or JSON depending
119
+ on the ``output_format`` passed at construction time).
120
+ """
121
+ self._ensure_initialized()
122
+
123
+ result = self._send_request(
124
+ 'tools/call',
125
+ {
126
+ 'name': tool_name,
127
+ 'arguments': arguments or {},
128
+ },
129
+ )
130
+
131
+ # MCP tools/call result: {"content": [{"type": "text", "text": "..."}]}
132
+ content_list = result.get('content', [])
133
+ texts = [item['text'] for item in content_list if item.get('type') == 'text']
134
+ return '\n'.join(texts)
135
+
136
+ def close(self) -> None:
137
+ """Close the underlying HTTP client."""
138
+ self._http.close()
139
+
140
+ def __enter__(self) -> MCPClient:
141
+ return self
142
+
143
+ def __exit__(self, *exc: object) -> None:
144
+ self.close()
@@ -0,0 +1,59 @@
1
+ """Course commands — search, get, list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cyclopts
6
+
7
+ from talk_python_cli.formatting import display
8
+
9
+ courses_app = cyclopts.App(
10
+ name='courses',
11
+ help='Browse and search Talk Python Training courses.',
12
+ )
13
+
14
+
15
+ def _client():
16
+ from talk_python_cli.app import get_client
17
+
18
+ return get_client()
19
+
20
+
21
+ @courses_app.command
22
+ def search(query: str, *, course_id: int | None = None) -> None:
23
+ """Search courses, chapters, and lectures by keyword.
24
+
25
+ Parameters
26
+ ----------
27
+ query
28
+ Keywords for course, chapter, or lecture titles.
29
+ course_id
30
+ Limit search to a specific course (optional).
31
+ """
32
+ client = _client()
33
+ args: dict = {'query': query}
34
+ if course_id is not None:
35
+ args['course_id'] = course_id
36
+ content = client.call_tool('search_courses', args)
37
+ display(content, client.output_format)
38
+
39
+
40
+ @courses_app.command
41
+ def get(course_id: int) -> None:
42
+ """Get full details for a course by ID, including chapters and lectures.
43
+
44
+ Parameters
45
+ ----------
46
+ course_id
47
+ The course ID.
48
+ """
49
+ client = _client()
50
+ content = client.call_tool('get_course_details', {'course_id': course_id})
51
+ display(content, client.output_format)
52
+
53
+
54
+ @courses_app.command(name='list')
55
+ def list_courses() -> None:
56
+ """List all available Talk Python Training courses."""
57
+ client = _client()
58
+ content = client.call_tool('get_courses')
59
+ display(content, client.output_format)
@@ -0,0 +1,99 @@
1
+ """Episode commands — search, get, list, recent, transcript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cyclopts
6
+
7
+ from talk_python_cli.formatting import display
8
+
9
+ episodes_app = cyclopts.App(
10
+ name='episodes',
11
+ help='Browse and search Talk Python to Me podcast episodes.',
12
+ )
13
+
14
+
15
+ def _client():
16
+ from talk_python_cli.app import get_client
17
+
18
+ return get_client()
19
+
20
+
21
+ @episodes_app.command
22
+ def search(query: str, *, limit: int = 10) -> None:
23
+ """Search episodes by keyword.
24
+
25
+ Parameters
26
+ ----------
27
+ query
28
+ Search keywords for episode titles and descriptions.
29
+ limit
30
+ Maximum number of results to return.
31
+ """
32
+ client = _client()
33
+ args = {'query': query, 'limit': limit}
34
+ content = client.call_tool('search_episodes', args)
35
+ display(content, client.output_format)
36
+
37
+
38
+ @episodes_app.command
39
+ def get(show_id: int) -> None:
40
+ """Get full details for an episode by its show ID.
41
+
42
+ Parameters
43
+ ----------
44
+ show_id
45
+ Episode show ID (e.g. 400).
46
+ """
47
+ client = _client()
48
+ content = client.call_tool('get_episode', {'show_id': show_id})
49
+ display(content, client.output_format)
50
+
51
+
52
+ @episodes_app.command(name='list')
53
+ def list_episodes() -> None:
54
+ """List all podcast episodes with their show IDs and titles."""
55
+ client = _client()
56
+ content = client.call_tool('get_episodes')
57
+ display(content, client.output_format)
58
+
59
+
60
+ @episodes_app.command
61
+ def recent(*, limit: int = 10) -> None:
62
+ """Get the most recently published episodes.
63
+
64
+ Parameters
65
+ ----------
66
+ limit
67
+ Number of recent episodes to return.
68
+ """
69
+ client = _client()
70
+ content = client.call_tool('get_recent_episodes', {'limit': limit})
71
+ display(content, client.output_format)
72
+
73
+
74
+ @episodes_app.command
75
+ def transcript(show_id: int) -> None:
76
+ """Get the full plain-text transcript for an episode.
77
+
78
+ Parameters
79
+ ----------
80
+ show_id
81
+ Episode show ID.
82
+ """
83
+ client = _client()
84
+ content = client.call_tool('get_transcript_for_episode', {'show_id': show_id})
85
+ display(content, client.output_format)
86
+
87
+
88
+ @episodes_app.command(name='transcript-vtt')
89
+ def transcript_vtt(show_id: int) -> None:
90
+ """Get the transcript in WebVTT format (with timestamps).
91
+
92
+ Parameters
93
+ ----------
94
+ show_id
95
+ Episode show ID.
96
+ """
97
+ client = _client()
98
+ content = client.call_tool('get_transcript_vtt', {'show_id': show_id})
99
+ display(content, client.output_format)
@@ -0,0 +1,94 @@
1
+ """Output formatting — Rich Markdown rendering and JSON display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+ from rich.theme import Theme
13
+
14
+ # ── Custom theme for Talk Python branding ────────────────────────────────────
15
+ _TALK_PYTHON_THEME = Theme(
16
+ {
17
+ 'tp.title': 'bold cyan',
18
+ 'tp.heading': 'bold magenta',
19
+ 'tp.id': 'bold yellow',
20
+ 'tp.url': 'blue underline',
21
+ 'tp.date': 'green',
22
+ 'tp.dim': 'dim',
23
+ 'tp.error': 'bold red',
24
+ 'tp.success': 'bold green',
25
+ 'tp.label': 'bold white',
26
+ }
27
+ )
28
+
29
+ console = Console(theme=_TALK_PYTHON_THEME, highlight=False)
30
+ error_console = Console(theme=_TALK_PYTHON_THEME, stderr=True, highlight=False)
31
+
32
+
33
+ def is_tty() -> bool:
34
+ """Return True if stdout is connected to a terminal."""
35
+ return sys.stdout.isatty()
36
+
37
+
38
+ def display(content: str, output_format: str) -> None:
39
+ """Route content to the appropriate renderer."""
40
+ if output_format == 'json':
41
+ display_json(content)
42
+ else:
43
+ display_markdown(content)
44
+
45
+
46
+ def display_markdown(content: str) -> None:
47
+ """Render Markdown content with Rich, wrapped in a styled panel."""
48
+ md = Markdown(content, code_theme='monokai')
49
+
50
+ panel = Panel(
51
+ md,
52
+ border_style='cyan',
53
+ title='[bold cyan]Talk Python[/bold cyan]',
54
+ title_align='left',
55
+ padding=(1, 2),
56
+ )
57
+ console.print(panel)
58
+
59
+
60
+ def display_json(content: str) -> None:
61
+ """Output JSON content — pretty-printed if on a TTY, raw otherwise."""
62
+ try:
63
+ data = json.loads(content)
64
+ except json.JSONDecodeError, TypeError:
65
+ # Server may have returned Markdown even though JSON was requested;
66
+ # fall back to printing the raw text.
67
+ console.print(content)
68
+ return
69
+
70
+ if is_tty():
71
+ from rich.syntax import Syntax
72
+
73
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
74
+ syntax = Syntax(formatted, 'json', theme='monokai', word_wrap=True)
75
+ console.print(syntax)
76
+ else:
77
+ # Raw compact JSON for piping
78
+ print(json.dumps(data, ensure_ascii=False))
79
+
80
+
81
+ def print_error(message: str) -> None:
82
+ """Print a styled error message to stderr."""
83
+ error_console.print(
84
+ Text.assemble(
85
+ ('ERROR', 'bold red'),
86
+ (' ', ''),
87
+ (message, 'red'),
88
+ )
89
+ )
90
+
91
+
92
+ def print_info(message: str) -> None:
93
+ """Print a styled informational message."""
94
+ console.print(f'[tp.dim]{message}[/tp.dim]')
@@ -0,0 +1,56 @@
1
+ """Guest commands — search, get, list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cyclopts
6
+
7
+ from talk_python_cli.formatting import display
8
+
9
+ guests_app = cyclopts.App(
10
+ name='guests',
11
+ help='Browse and search Talk Python to Me podcast guests.',
12
+ )
13
+
14
+
15
+ def _client():
16
+ from talk_python_cli.app import get_client
17
+
18
+ return get_client()
19
+
20
+
21
+ @guests_app.command
22
+ def search(query: str, *, limit: int = 10) -> None:
23
+ """Search guests by name.
24
+
25
+ Parameters
26
+ ----------
27
+ query
28
+ Guest name or partial name to search for.
29
+ limit
30
+ Maximum number of results to return.
31
+ """
32
+ client = _client()
33
+ content = client.call_tool('search_guests', {'query': query, 'limit': limit})
34
+ display(content, client.output_format)
35
+
36
+
37
+ @guests_app.command
38
+ def get(guest_id: int) -> None:
39
+ """Get detailed info about a guest by their ID.
40
+
41
+ Parameters
42
+ ----------
43
+ guest_id
44
+ The guest ID.
45
+ """
46
+ client = _client()
47
+ content = client.call_tool('get_guest_by_id', {'guest_id': guest_id})
48
+ display(content, client.output_format)
49
+
50
+
51
+ @guests_app.command(name='list')
52
+ def list_guests() -> None:
53
+ """List all podcast guests, sorted by number of appearances."""
54
+ client = _client()
55
+ content = client.call_tool('get_guests')
56
+ display(content, client.output_format)
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: talk-python-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Talk Python to Me podcast and courses
5
+ Project-URL: Homepage, https://github.com/talkpython/talk-python-cli
6
+ Project-URL: Source, https://github.com/talkpython/talk-python-cli
7
+ Project-URL: Documentation, https://github.com/talkpython/talk-python-cli
8
+ Author-email: Michael Kennedy <michael@talkpython.fm>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Topic :: Education
21
+ Classifier: Topic :: Multimedia :: Sound/Audio
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: cyclopts>=3.0
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: rich>=13.0
@@ -0,0 +1,13 @@
1
+ talk_python_cli/__init__.py,sha256=05NfNnHR3JpU6LYq_GWjTERnI7FInPp_pmF9L1qk7uI,100
2
+ talk_python_cli/__main__.py,sha256=CiR9VEA51QfEByCah9e1uvR7gqiFDoBmsRgFVWltjBU,100
3
+ talk_python_cli/app.py,sha256=gzfruXfL8pDxr8pEFCTrUKzGfZGKr3Af87jtiHNlfRs,3224
4
+ talk_python_cli/client.py,sha256=NR48azeJlfHYFMqikUSjfrFOmCVClyOlq4yd0hyvxHY,4778
5
+ talk_python_cli/courses.py,sha256=EgWFbhLFeBTotDHu3xZ4fyiJokgWY2ZLFVaDRTU6NNo,1478
6
+ talk_python_cli/episodes.py,sha256=7Mm6uZ_jzMV6JBPv-1xbmINo7eyI4ylKvsT4_xT3Umw,2453
7
+ talk_python_cli/formatting.py,sha256=Ww3pfIAK50Ms2MSSkKrKjvwam_5C3ltEB1klHRGeM8U,2699
8
+ talk_python_cli/guests.py,sha256=O5MpmnhbACx1czKbV_p9KZywRwEo4jxOM5y8oU3ix50,1314
9
+ talk_python_cli-0.1.0.dist-info/METADATA,sha256=SkfLFf8i5jH2whFa8DCKxufxZ6dvzXjB0xoscxwt_HU,1038
10
+ talk_python_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ talk_python_cli-0.1.0.dist-info/entry_points.txt,sha256=Q7eLneKcuKeoSY_Ps-RyMb8BF1q6QPytIZ0ZYm7fhBg,56
12
+ talk_python_cli-0.1.0.dist-info/licenses/LICENSE,sha256=E1rfm5mKKkNGtHcctSAevPdM0uk6DqdPlcM48R1jGM4,1068
13
+ talk_python_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ talkpython = talk_python_cli.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Talk Python
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.