propresenter-client 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,10 @@
1
+ """
2
+ propresenter-client - Python client for the ProPresenter REST API
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Your Name"
7
+
8
+ from .main import ProPresenterController
9
+
10
+ __all__ = ["ProPresenterController"]
@@ -0,0 +1,583 @@
1
+ """
2
+ Main module for ProPresenter API interface
3
+ """
4
+
5
+ import argparse
6
+ import logging
7
+ import requests
8
+ import sys
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ProPresenterController:
15
+ """Interface for controlling ProPresenter via its APIs"""
16
+
17
+ def __init__(self, host: str = "localhost", port: int = 1025, timeout: int = 5):
18
+ """
19
+ Initialize the ProPresenter controller.
20
+
21
+ Args:
22
+ host: The hostname or IP address of the ProPresenter instance
23
+ port: The port number for the ProPresenter API
24
+ timeout: Request timeout in seconds
25
+ """
26
+ self.host = host
27
+ self.port = port
28
+ self.timeout = timeout
29
+ self.base_url = f"http://{host}:{port}"
30
+
31
+ def _request(self, method: str, endpoint: str, **kwargs) -> Optional[dict]:
32
+ """
33
+ Make a request to the ProPresenter API.
34
+
35
+ Args:
36
+ method: HTTP method (GET, POST, etc.)
37
+ endpoint: API endpoint path
38
+ **kwargs: Additional arguments to pass to requests
39
+
40
+ Returns:
41
+ Response JSON if available, or empty dict if successful with no content, None if request fails
42
+ """
43
+ try:
44
+ url = f"{self.base_url}/{endpoint}"
45
+ response = requests.request(
46
+ method, url, timeout=self.timeout, **kwargs
47
+ )
48
+ response.raise_for_status()
49
+ # Try to parse JSON, but some endpoints return no content
50
+ if response.text:
51
+ return response.json()
52
+ else:
53
+ return {}
54
+ except requests.RequestException as e:
55
+ logger.debug(f"Request failed: {e}")
56
+ return None
57
+
58
+ def next_slide(self) -> bool:
59
+ """Advance to the next slide."""
60
+ result = self._request("GET", "v1/presentation/active/next/trigger")
61
+ return result is not None
62
+
63
+ def previous_slide(self) -> bool:
64
+ """Go to the previous slide."""
65
+ result = self._request("GET", "v1/presentation/active/previous/trigger")
66
+ return result is not None
67
+
68
+ def get_status(self) -> Optional[dict]:
69
+ """Get the current presentation status."""
70
+ return self._request("GET", "v1/status/slide")
71
+
72
+ def get_slide_position(self) -> Optional[tuple[int, int]]:
73
+ """
74
+ Get the current slide position and total slide count.
75
+
76
+ Returns:
77
+ A tuple of (current_slide_number, total_slides) in 1-indexed form,
78
+ or None if the slide position cannot be determined from status.
79
+ """
80
+ status = self.get_status()
81
+ if not status or not isinstance(status, dict):
82
+ return None
83
+
84
+ current = None
85
+ total = None
86
+
87
+ if "currentSlide" in status and isinstance(status["currentSlide"], dict):
88
+ current = status["currentSlide"].get("index")
89
+ if current is None:
90
+ current = status["currentSlide"].get("number")
91
+ total = status["currentSlide"].get("total")
92
+ if total is None:
93
+ total = status["currentSlide"].get("count")
94
+
95
+ if current is None and "slide" in status and isinstance(status["slide"], dict):
96
+ current = status["slide"].get("index")
97
+ if current is None:
98
+ current = status["slide"].get("number")
99
+ total = status["slide"].get("total")
100
+ if total is None:
101
+ total = status["slide"].get("count")
102
+
103
+ if current is None:
104
+ for key in ("currentSlide", "slideIndex", "currentSlideIndex"):
105
+ if key in status:
106
+ current = status[key]
107
+ break
108
+
109
+ if total is None:
110
+ for key in ("slideCount", "totalSlides", "slideTotal", "totalSlideCount"):
111
+ if key in status:
112
+ total = status[key]
113
+ break
114
+
115
+ if isinstance(current, int) and isinstance(total, int):
116
+ return current + 1, total
117
+
118
+ return None
119
+
120
+ def go_to_slide(self, slide_number: int) -> bool:
121
+ """
122
+ Go to a specific slide number.
123
+
124
+ Args:
125
+ slide_number: The slide number to navigate to (1-indexed)
126
+
127
+ Returns:
128
+ True if successful, False otherwise
129
+ """
130
+ if slide_number <= 0:
131
+ slide_number = 1 # Ensure slide number is at least 1
132
+
133
+ api_slide_number = max(slide_number - 1, 0)
134
+
135
+ result = self._request(
136
+ "GET",
137
+ f"v1/presentation/active/{api_slide_number}/trigger"
138
+ )
139
+ return result is not None
140
+
141
+ def get_slide_index(self, chunked: bool = False) -> Optional[int]:
142
+ """
143
+ Get the zero-based index of the currently active slide.
144
+
145
+ Args:
146
+ chunked: If True, uses chunked slide indexing. Default False
147
+ returns a flat index across the whole presentation.
148
+
149
+ Returns:
150
+ Zero-based slide index, or None if it cannot be determined.
151
+ """
152
+ result = self._request(
153
+ "GET", "v1/presentation/slide_index",
154
+ params={"chunked": str(chunked).lower()}
155
+ )
156
+ logger.debug("get_slide_index response: %s", result)
157
+ if result is None:
158
+ return None
159
+ if isinstance(result, int):
160
+ return result
161
+ if isinstance(result, dict):
162
+ # Bare index at top level
163
+ for key in ("slideIndex", "slide_index", "index"):
164
+ val = result.get(key)
165
+ if isinstance(val, int):
166
+ return val
167
+ # Nested one level under a container key — check container first so we
168
+ # don't accidentally pick up a sibling id.index from presentation metadata.
169
+ for container in ("presentation_index", "presentationSlideIndex"):
170
+ nested = result.get(container)
171
+ if isinstance(nested, dict):
172
+ for key in ("slideIndex", "slide_index", "index"):
173
+ val = nested.get(key)
174
+ if isinstance(val, int):
175
+ return val
176
+ return None
177
+
178
+ def get_active_presentation(self) -> Optional[dict]:
179
+ """
180
+ Get the currently active presentation.
181
+
182
+ Returns:
183
+ Presentation details if available, None if request fails
184
+ """
185
+ return self._request("GET", "v1/presentation/active")
186
+
187
+ def get_active_presentation_uuid(self) -> Optional[str]:
188
+ """
189
+ Get the UUID of the currently active presentation.
190
+
191
+ Returns:
192
+ UUID string if found, None if the active presentation cannot be
193
+ reached or its UUID cannot be parsed from the response.
194
+ """
195
+ data = self.get_active_presentation()
196
+ if not data:
197
+ return None
198
+ return ProPresenterController._extract_uuid(data)
199
+
200
+ @staticmethod
201
+ def _extract_uuid(data: object) -> Optional[str]:
202
+ """Recursively locate a presentation UUID in a ProPresenter API response."""
203
+ if not isinstance(data, dict):
204
+ return None
205
+ for key in ("uuid", "presentationId", "presentationUUID"):
206
+ val = data.get(key)
207
+ if isinstance(val, str) and val:
208
+ return val
209
+ for key in ("presentation", "id"):
210
+ result = ProPresenterController._extract_uuid(data.get(key))
211
+ if result:
212
+ return result
213
+ return None
214
+
215
+ @staticmethod
216
+ def find_slides(details: object) -> list:
217
+ """
218
+ Recursively collect ALL slides from a presentation details response,
219
+ flattening across every group so the returned list is in presentation order.
220
+
221
+ Args:
222
+ details: The response payload from get_presentation_details()
223
+
224
+ Returns:
225
+ Flat list of all slide objects across all groups, or [] if none found.
226
+ """
227
+ if isinstance(details, dict):
228
+ slides = details.get("slides")
229
+ if isinstance(slides, list):
230
+ return slides # this dict is a group — return its slides directly
231
+ all_slides: list = []
232
+ for value in details.values():
233
+ all_slides.extend(ProPresenterController.find_slides(value))
234
+ return all_slides
235
+ elif isinstance(details, list):
236
+ all_slides = []
237
+ for item in details:
238
+ all_slides.extend(ProPresenterController.find_slides(item))
239
+ return all_slides
240
+ return []
241
+
242
+ def get_library(self, library_name: str) -> Optional[dict]:
243
+ """
244
+ Get a named library's contents.
245
+
246
+ Args:
247
+ library_name: The library name to query
248
+
249
+ Returns:
250
+ Library data if available, None if request fails
251
+ """
252
+ return self._request("GET", f"v1/library/{library_name}")
253
+
254
+ def get_library_default(self) -> Optional[dict]:
255
+ """
256
+ Get the Default library contents.
257
+
258
+ Returns:
259
+ Library data if available, None if request fails
260
+ """
261
+ return self.get_library("Default")
262
+
263
+ def find_presentation_uuid_by_name(
264
+ self, presentation_name: str, library_data: Optional[dict]
265
+ ) -> Optional[str]:
266
+ """
267
+ Find a presentation UUID in the Default library by presentation name.
268
+
269
+ Uses case-insensitive substring matching on common title fields.
270
+
271
+ Args:
272
+ presentation_name: The presentation name to search for
273
+ library_data: The library response payload
274
+
275
+ Returns:
276
+ The matching presentation UUID, or None if not found
277
+ """
278
+ if not library_data:
279
+ return None
280
+
281
+ items = []
282
+ if isinstance(library_data, dict):
283
+ if "items" in library_data and isinstance(library_data["items"], list):
284
+ items = library_data["items"]
285
+ elif "presentations" in library_data and isinstance(library_data["presentations"], list):
286
+ items = library_data["presentations"]
287
+ else:
288
+ items = [library_data]
289
+ elif isinstance(library_data, list):
290
+ items = library_data
291
+
292
+ search = presentation_name.strip().lower()
293
+
294
+ for entry in items:
295
+ if not isinstance(entry, dict):
296
+ continue
297
+
298
+ title = (
299
+ entry.get("name")
300
+ or entry.get("title")
301
+ or entry.get("presentationName")
302
+ or entry.get("presentationTitle")
303
+ or entry.get("songName")
304
+ )
305
+ uuid = (
306
+ entry.get("uuid")
307
+ or entry.get("id")
308
+ or entry.get("presentationId")
309
+ or entry.get("presentationUUID")
310
+ )
311
+
312
+ if title and uuid and search in title.lower():
313
+ return uuid
314
+
315
+ return None
316
+
317
+ def get_presentation_details(self, uuid: str) -> Optional[dict]:
318
+ """
319
+ Get details for a presentation by UUID.
320
+
321
+ Args:
322
+ uuid: The presentation UUID to fetch details for
323
+
324
+ Returns:
325
+ Presentation details if available, None if request fails
326
+ """
327
+ return self._request("GET", f"v1/presentation/{uuid}")
328
+
329
+ def activate_presentation(self, uuid: str) -> bool:
330
+ """
331
+ Activate a presentation by UUID.
332
+
333
+ Args:
334
+ uuid: The presentation UUID to activate
335
+
336
+ Returns:
337
+ True if activation request succeeded, False otherwise
338
+ """
339
+ result = self._request("GET", f"v1/presentation/{uuid}/trigger")
340
+ return result is not None
341
+
342
+ def activate_first_library_presentation(self, library_name: str) -> bool:
343
+ """
344
+ Activate the first presentation in the specified library.
345
+
346
+ Args:
347
+ library_name: The library name to activate the first presentation from
348
+
349
+ Returns:
350
+ True if activation request succeeded, False otherwise
351
+ """
352
+ result = self._request("GET", f"v1/library/{library_name}/0/trigger")
353
+ return result is not None
354
+
355
+
356
+ def _get_command() -> str:
357
+ """Read a command without requiring Enter for single-char keys (n, b, q).
358
+ Digits are buffered until Enter is pressed. Falls back to input() on
359
+ platforms that don't support raw terminal mode."""
360
+ try:
361
+ import tty
362
+ import termios
363
+ except ImportError:
364
+ return input().strip().lower()
365
+
366
+ fd = sys.stdin.fileno()
367
+ old = termios.tcgetattr(fd)
368
+ buf = ""
369
+ try:
370
+ tty.setraw(fd)
371
+ while True:
372
+ ch = sys.stdin.read(1)
373
+ if ch == "\x03": # Ctrl+C
374
+ raise KeyboardInterrupt
375
+ if ch == "\x1b": # Escape — clear digit buffer
376
+ if buf:
377
+ sys.stdout.write("\r" + " " * (len("Enter command: ") + len(buf)))
378
+ sys.stdout.write("\rEnter command: ")
379
+ sys.stdout.flush()
380
+ buf = ""
381
+ continue
382
+ if not buf and ch.lower() in ("n", "b", "q"):
383
+ sys.stdout.write(ch.lower() + "\n")
384
+ sys.stdout.flush()
385
+ return ch.lower()
386
+ if ch.isdigit():
387
+ buf += ch
388
+ sys.stdout.write(ch)
389
+ sys.stdout.flush()
390
+ elif ch in ("\r", "\n") and buf:
391
+ sys.stdout.write("\n")
392
+ sys.stdout.flush()
393
+ return buf
394
+ elif ch in ("\x7f", "\x08") and buf: # Backspace
395
+ buf = buf[:-1]
396
+ sys.stdout.write("\b \b")
397
+ sys.stdout.flush()
398
+ finally:
399
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
400
+
401
+
402
+ def interactive_prompt(controller: ProPresenterController) -> None:
403
+ """
404
+ Start an interactive prompt for controlling the presentation.
405
+
406
+ Supported commands:
407
+ - 'n': next slide
408
+ - 'b': previous slide
409
+ - <number>: go to specific slide number (1-indexed)
410
+ - 'q': quit
411
+ """
412
+ print("\n=== ProPresenter Slide Controller ===")
413
+ print("Commands: 'n' (next), 'b' (back), <number> (go to slide number), 'q' (quit)")
414
+ print("Note: Slide numbers are 1-indexed (first slide = 1)")
415
+ print("====================================\n")
416
+
417
+ while True:
418
+ try:
419
+ sys.stdout.write("Enter command: ")
420
+ sys.stdout.flush()
421
+ user_input = _get_command()
422
+
423
+ if not user_input:
424
+ continue
425
+
426
+ if user_input == 'q':
427
+ print("Exiting...")
428
+ break
429
+ elif user_input == 'n':
430
+ slide_position = controller.get_slide_position()
431
+ if slide_position:
432
+ current_slide, total_slides = slide_position
433
+ if current_slide >= total_slides:
434
+ print("Cannot go beyond the last slide. Prompt attempted to go beyond the last slide.")
435
+ continue
436
+ if controller.next_slide():
437
+ print("✓ Moved to next slide")
438
+ else:
439
+ print("✗ Failed to move to next slide")
440
+ elif user_input == 'b':
441
+ slide_position = controller.get_slide_position()
442
+ if slide_position:
443
+ current_slide, _ = slide_position
444
+ if current_slide <= 1:
445
+ print("Cannot go before the first slide. Prompt attempted to go beyond the first slide.")
446
+ continue
447
+ if controller.previous_slide():
448
+ print("✓ Moved to previous slide")
449
+ else:
450
+ print("✗ Failed to move to previous slide")
451
+ else:
452
+ # Try to parse as slide number
453
+ try:
454
+ slide_num = int(user_input)
455
+ if slide_num <= 0:
456
+ print("Invalid slide number. Use 1 or greater.")
457
+ continue
458
+ slide_position = controller.get_slide_position()
459
+ if slide_position:
460
+ _, total_slides = slide_position
461
+ if slide_num > total_slides:
462
+ print("Cannot go beyond the last slide. Prompt attempted to go beyond the last slide.")
463
+ continue
464
+ if controller.go_to_slide(slide_num):
465
+ print(f"✓ Moved to slide {slide_num}")
466
+ else:
467
+ print(f"✗ Failed to move to slide {slide_num}")
468
+ except ValueError:
469
+ print("Invalid command. Use 'n', 'b', a slide number (1-based), or 'q' to quit.")
470
+ except KeyboardInterrupt:
471
+ print("\n\nExiting...")
472
+ break
473
+
474
+
475
+ def main() -> None:
476
+ """Main entry point for the CLI."""
477
+ parser = argparse.ArgumentParser(
478
+ description="Control ProPresenter presentations from the command line"
479
+ )
480
+ parser.add_argument(
481
+ "--host",
482
+ type=str,
483
+ default="localhost",
484
+ help="ProPresenter host/IP address (default: localhost)"
485
+ )
486
+ parser.add_argument(
487
+ "--port",
488
+ type=int,
489
+ default=1025,
490
+ help="ProPresenter port (default: 1025)"
491
+ )
492
+ parser.add_argument(
493
+ "--timeout",
494
+ type=int,
495
+ default=5,
496
+ help="Request timeout in seconds (default: 5)"
497
+ )
498
+ parser.add_argument(
499
+ "--library",
500
+ type=str,
501
+ default="Default",
502
+ help="Library name to use for presentation lookup and default activation (default: Default)"
503
+ )
504
+ parser.add_argument(
505
+ "--presentation",
506
+ type=str,
507
+ help="Presentation title to activate from the configured library before entering interactive mode"
508
+ )
509
+ parser.add_argument(
510
+ "--list-details",
511
+ action="store_true",
512
+ help="Print details for the specified presentation and exit (requires --presentation)"
513
+ )
514
+ parser.add_argument(
515
+ "--log-level",
516
+ type=str,
517
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
518
+ default="WARNING",
519
+ help="Set logging verbosity for request diagnostics (default: WARNING)"
520
+ )
521
+
522
+ args = parser.parse_args()
523
+
524
+ logging.basicConfig(
525
+ level=getattr(logging, args.log_level),
526
+ format="%(asctime)s [%(levelname)s] %(message)s"
527
+ )
528
+
529
+ controller = ProPresenterController(
530
+ host=args.host,
531
+ port=args.port,
532
+ timeout=args.timeout
533
+ )
534
+
535
+ # Test connection
536
+ status = controller.get_status()
537
+ if status is None:
538
+ print(f"Error: Could not connect to ProPresenter at {args.host}:{args.port}")
539
+ sys.exit(1)
540
+
541
+ print(f"Connected to ProPresenter at {args.host}:{args.port}")
542
+
543
+ if args.list_details and not args.presentation:
544
+ print("Error: --list-details requires --presentation")
545
+ sys.exit(1)
546
+
547
+ if args.presentation:
548
+ library = controller.get_library(args.library)
549
+ if library is None:
550
+ print(f"Error: Could not query {args.library} library at {args.host}:{args.port}")
551
+ sys.exit(1)
552
+
553
+ presentation_uuid = controller.find_presentation_uuid_by_name(args.presentation, library)
554
+ if presentation_uuid is None:
555
+ print(f"Error: Presentation '{args.presentation}' not found in {args.library} library")
556
+ sys.exit(1)
557
+
558
+ if args.list_details:
559
+ details = controller.get_presentation_details(presentation_uuid)
560
+ if details is None:
561
+ print(f"Error: Could not fetch details for '{args.presentation}' (UUID: {presentation_uuid})")
562
+ sys.exit(1)
563
+ import json
564
+ print(json.dumps(details, indent=2))
565
+ return
566
+
567
+ if controller.activate_presentation(presentation_uuid):
568
+ print(f"Activated '{args.presentation}' (UUID: {presentation_uuid})")
569
+ else:
570
+ print(f"Error: Failed to activate presentation UUID {presentation_uuid}")
571
+ sys.exit(1)
572
+ else:
573
+ # Default behavior: activate first presentation in configured library
574
+ if controller.activate_first_library_presentation(args.library):
575
+ print(f"Activated first presentation in {args.library} library")
576
+ else:
577
+ print(f"Warning: Could not activate first presentation in {args.library} library")
578
+
579
+ interactive_prompt(controller)
580
+
581
+
582
+ if __name__ == "__main__":
583
+ main()
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: propresenter-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the ProPresenter REST API
5
+ Author-email: scaperothian <scaperoth@berkeley.edu>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/scaperothian/propresenter-client
8
+ Project-URL: Repository, https://github.com/scaperothian/propresenter-client.git
9
+ Project-URL: Issues, https://github.com/scaperothian/propresenter-client/issues
10
+ Keywords: propresenter,api,client,presentation,slides
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: requests>=2.31
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
26
+ Requires-Dist: build>=1.0; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # propresenter-client
30
+
31
+ [![CI](https://github.com/scaperothian/propresenter-client/actions/workflows/ci.yml/badge.svg)](https://github.com/scaperothian/propresenter-client/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/propresenter-client.svg)](https://pypi.org/project/propresenter-client/)
33
+
34
+ Python client for the ProPresenter REST API.
35
+
36
+ ## Description
37
+
38
+ A Python library and CLI for interacting with ProPresenter's REST API. Provides a `ProPresenterController` class for programmatic access to any ProPresenter API endpoint, plus an interactive **presentation mode** CLI for live slide control.
39
+
40
+ ## Features
41
+
42
+ - `ProPresenterController` class covering common ProPresenter API endpoints
43
+ - Interactive presentation mode for live slide navigation
44
+ - Extensible design — add new API endpoints as methods over time
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install propresenter-client
50
+ ```
51
+
52
+ ## Development Setup
53
+
54
+ ```bash
55
+ # Create a virtual environment and install the package with dev dependencies
56
+ python -m venv .venv
57
+ source .venv/bin/activate
58
+ pip install -e ".[dev]"
59
+
60
+ # Run the CLI
61
+ propresenter-client --host=<your-host>
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```python
67
+ from propresenter_client import ProPresenterController
68
+
69
+ controller = ProPresenterController(host="localhost", port=1025)
70
+
71
+ # Slide control
72
+ controller.next_slide()
73
+ controller.previous_slide()
74
+ controller.go_to_slide(1) # 1-indexed
75
+
76
+ # Presentations
77
+ controller.activate_presentation("92B5E6E2-5E99-4F54-BAD3-6FBD7D2EE675")
78
+ controller.get_presentation_details("92B5E6E2-5E99-4F54-BAD3-6FBD7D2EE675")
79
+ controller.activate_first_library_presentation("Default")
80
+ ```
81
+
82
+ ## Interactive Presentation Mode
83
+
84
+ ```bash
85
+ # Default: activates first presentation in Default library
86
+ propresenter-client --host=192.168.1.100
87
+
88
+ # Activate a specific presentation by name before entering interactive mode
89
+ propresenter-client --host=192.168.1.100 --presentation="Amazing Grace"
90
+
91
+ # Print presentation details and exit (no interactive mode)
92
+ propresenter-client --host=192.168.1.100 --presentation="Amazing Grace" --list-details
93
+
94
+ # Use a different library
95
+ propresenter-client --host=192.168.1.100 --library="Worship"
96
+
97
+ # Enable request diagnostics
98
+ propresenter-client --host=192.168.1.100 --log-level=DEBUG
99
+ ```
100
+
101
+ Interactive commands (no Enter required for single-key commands):
102
+ - `n` — Next slide
103
+ - `b` — Back to previous slide
104
+ - `q` — Quit
105
+ - `1`, `2`, `3`, … — Go to specific slide (1-indexed, press Enter to confirm)
106
+ - `Escape` — Cancel a partially typed slide number
107
+
108
+ ## Testing
109
+
110
+ ```bash
111
+ # Run all tests
112
+ pytest
113
+
114
+ # Run specific test file
115
+ pytest tests/test_propresenter_controller.py -v
116
+
117
+ # Run specific test class
118
+ pytest tests/test_propresenter_controller.py::TestProPresenterController -v
119
+
120
+ # Run individual test
121
+ pytest tests/test_propresenter_controller.py::TestProPresenterController::test_get_status_success -v
122
+
123
+ # Run tests matching a pattern
124
+ pytest -k "test_get" -v
125
+
126
+ # Generate coverage report
127
+ pytest --cov=propresenter_client
128
+ ```
129
+
130
+ ## Releasing
131
+
132
+ Releases are published to [PyPI](https://pypi.org/project/propresenter-client/)
133
+ automatically by the `Publish` GitHub Actions workflow when a version tag is
134
+ pushed. The version number is derived from the git tag via `setuptools_scm`.
135
+
136
+ ```bash
137
+ git tag v1.2.3
138
+ git push origin v1.2.3
139
+ ```
140
+
141
+ Publishing uses [PyPI Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/),
142
+ so no API tokens are stored in the repository. One-time setup on PyPI:
143
+
144
+ 1. Create the project's pending publisher at
145
+ <https://pypi.org/manage/account/publishing/>.
146
+ 2. Owner: `scaperothian`, repository: `propresenter-client`,
147
+ workflow: `publish.yml`, environment: `pypi`.
148
+
149
+ ## Requirements
150
+
151
+ - Python 3.10+
152
+ - requests
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,10 @@
1
+ propresenter_client/__init__.py,sha256=IBg3Zlw76Z8uld_CQiM682h1jetTugyfldGWb8cofsU,202
2
+ propresenter_client/main.py,sha256=kofJ9zaI8VckdlDdzH4GULcZ0F_44ZOJ9ahXIKqIn9A,20470
3
+ propresenter_client-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
4
+ propresenter_client-0.1.0.dist-info/METADATA,sha256=Dgi89qPdUHcQFvArlBiRFWVmGVQwWki2WGuVHxIBrDQ,4854
5
+ propresenter_client-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ propresenter_client-0.1.0.dist-info/entry_points.txt,sha256=sXuO0k2yP1p4M84utvYn_CAkzP4yUkV0sUaZv50z9CE,70
7
+ propresenter_client-0.1.0.dist-info/scm_file_list.json,sha256=7RqUiEauulWgg7XKOg_pEr-BxOEInWyUSFiFyBCWTdg,330
8
+ propresenter_client-0.1.0.dist-info/scm_version.json,sha256=ldZmJYrNSqp1X5Ju93qHdKwI6LwTnXs_C0Fh3YZ74rk,160
9
+ propresenter_client-0.1.0.dist-info/top_level.txt,sha256=D0HGQ-zoxnFIewHZQjO5BJcUr4CrNMq0LQxL-Y187tg,20
10
+ propresenter_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ propresenter-client = propresenter_client.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "LICENSE",
5
+ "pyproject.toml",
6
+ "Claude.md",
7
+ ".gitignore",
8
+ "src/propresenter_client/__init__.py",
9
+ "src/propresenter_client/main.py",
10
+ "tests/__init__.py",
11
+ "tests/test_propresenter_controller.py",
12
+ ".github/workflows/ci.yml",
13
+ ".github/workflows/publish.yml"
14
+ ]
15
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.0",
3
+ "distance": 0,
4
+ "node": "g6200d60c1839eaf95b920484fd7af3525af84bd3",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-02"
8
+ }
@@ -0,0 +1 @@
1
+ propresenter_client