vnc-remote-control 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.
Files changed (115) hide show
  1. vnc_remote_control-0.1.0/.claude/skills/vnc-remote-control/SKILL.md +109 -0
  2. vnc_remote_control-0.1.0/.devcontainer/devcontainer.json +22 -0
  3. vnc_remote_control-0.1.0/.devcontainer/settings.json +6 -0
  4. vnc_remote_control-0.1.0/.github/actions/extract-metadata/action.yml +92 -0
  5. vnc_remote_control-0.1.0/.github/dependabot.yml +22 -0
  6. vnc_remote_control-0.1.0/.github/workflows/codeql.yml +39 -0
  7. vnc_remote_control-0.1.0/.github/workflows/default_cicd_public.yml +327 -0
  8. vnc_remote_control-0.1.0/.github/workflows/default_release_public.yml +69 -0
  9. vnc_remote_control-0.1.0/.gitignore +188 -0
  10. vnc_remote_control-0.1.0/.qlty/qlty.toml +2 -0
  11. vnc_remote_control-0.1.0/.snyk +5 -0
  12. vnc_remote_control-0.1.0/CHANGELOG.md +31 -0
  13. vnc_remote_control-0.1.0/CONFIG.md +512 -0
  14. vnc_remote_control-0.1.0/CONTRIBUTING.md +46 -0
  15. vnc_remote_control-0.1.0/DEVELOPMENT.md +104 -0
  16. vnc_remote_control-0.1.0/INSTALL.md +157 -0
  17. vnc_remote_control-0.1.0/LICENSE +22 -0
  18. vnc_remote_control-0.1.0/Makefile +266 -0
  19. vnc_remote_control-0.1.0/PKG-INFO +295 -0
  20. vnc_remote_control-0.1.0/README.md +240 -0
  21. vnc_remote_control-0.1.0/SECURITY.md +37 -0
  22. vnc_remote_control-0.1.0/ai-disclosure.md +58 -0
  23. vnc_remote_control-0.1.0/ai-stance.md +90 -0
  24. vnc_remote_control-0.1.0/codecov.yml +27 -0
  25. vnc_remote_control-0.1.0/docs/systemdesign/module_reference.md +108 -0
  26. vnc_remote_control-0.1.0/pyproject.toml +275 -0
  27. vnc_remote_control-0.1.0/src/vnc_remote_control/__init__.py +40 -0
  28. vnc_remote_control-0.1.0/src/vnc_remote_control/__init__conf__.py +90 -0
  29. vnc_remote_control-0.1.0/src/vnc_remote_control/__main__.py +9 -0
  30. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/__init__.py +15 -0
  31. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/__init__.py +66 -0
  32. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/__init__.py +44 -0
  33. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/config.py +335 -0
  34. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/__init__.py +16 -0
  35. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/_common.py +285 -0
  36. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/send_email.py +139 -0
  37. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/send_notification.py +98 -0
  38. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/info.py +74 -0
  39. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/logging.py +33 -0
  40. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/vnc.py +217 -0
  41. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/constants.py +28 -0
  42. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/context.py +169 -0
  43. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/exit_codes.py +52 -0
  44. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/main.py +120 -0
  45. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/py.typed +0 -0
  46. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/root.py +200 -0
  47. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/typed_click.py +42 -0
  48. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/__init__.py +25 -0
  49. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/40-layered-config.toml +71 -0
  50. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/50-mail.toml +222 -0
  51. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/90-logging.toml +432 -0
  52. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.toml +46 -0
  53. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/deploy.py +118 -0
  54. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/display.py +56 -0
  55. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/loader.py +188 -0
  56. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/overrides.py +189 -0
  57. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/permissions.py +185 -0
  58. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/py.typed +0 -0
  59. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/__init__.py +28 -0
  60. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/config.py +311 -0
  61. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/py.typed +0 -0
  62. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/sender.py +26 -0
  63. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/transport.py +274 -0
  64. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/validation.py +62 -0
  65. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/__init__.py +13 -0
  66. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/py.typed +0 -0
  67. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/setup.py +132 -0
  68. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/__init__.py +50 -0
  69. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/config.py +62 -0
  70. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/email.py +179 -0
  71. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/logging.py +15 -0
  72. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/ocr.py +178 -0
  73. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/py.typed +0 -0
  74. vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/rfb.py +329 -0
  75. vnc_remote_control-0.1.0/src/vnc_remote_control/application/__init__.py +32 -0
  76. vnc_remote_control-0.1.0/src/vnc_remote_control/application/ports.py +115 -0
  77. vnc_remote_control-0.1.0/src/vnc_remote_control/application/py.typed +0 -0
  78. vnc_remote_control-0.1.0/src/vnc_remote_control/composition/__init__.py +128 -0
  79. vnc_remote_control-0.1.0/src/vnc_remote_control/composition/py.typed +0 -0
  80. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/__init__.py +32 -0
  81. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/behaviors.py +28 -0
  82. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/enums.py +55 -0
  83. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/errors.py +56 -0
  84. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/keymap.py +69 -0
  85. vnc_remote_control-0.1.0/src/vnc_remote_control/domain/py.typed +0 -0
  86. vnc_remote_control-0.1.0/src/vnc_remote_control/entry.py +26 -0
  87. vnc_remote_control-0.1.0/src/vnc_remote_control/py.typed +0 -0
  88. vnc_remote_control-0.1.0/tests/conftest.py +644 -0
  89. vnc_remote_control-0.1.0/tests/test_behaviors.py +13 -0
  90. vnc_remote_control-0.1.0/tests/test_cache_effectiveness.py +155 -0
  91. vnc_remote_control-0.1.0/tests/test_cli_config.py +802 -0
  92. vnc_remote_control-0.1.0/tests/test_cli_core.py +222 -0
  93. vnc_remote_control-0.1.0/tests/test_cli_email.py +781 -0
  94. vnc_remote_control-0.1.0/tests/test_cli_env_file.py +177 -0
  95. vnc_remote_control-0.1.0/tests/test_cli_exit_codes.py +158 -0
  96. vnc_remote_control-0.1.0/tests/test_cli_overrides.py +164 -0
  97. vnc_remote_control-0.1.0/tests/test_cli_validation.py +132 -0
  98. vnc_remote_control-0.1.0/tests/test_cli_vnc.py +215 -0
  99. vnc_remote_control-0.1.0/tests/test_config_overrides.py +327 -0
  100. vnc_remote_control-0.1.0/tests/test_deploy_permissions.py +520 -0
  101. vnc_remote_control-0.1.0/tests/test_display.py +121 -0
  102. vnc_remote_control-0.1.0/tests/test_drawing.py +97 -0
  103. vnc_remote_control-0.1.0/tests/test_enums.py +78 -0
  104. vnc_remote_control-0.1.0/tests/test_errors.py +39 -0
  105. vnc_remote_control-0.1.0/tests/test_keymap.py +31 -0
  106. vnc_remote_control-0.1.0/tests/test_logging.py +31 -0
  107. vnc_remote_control-0.1.0/tests/test_mail.py +1228 -0
  108. vnc_remote_control-0.1.0/tests/test_metadata.py +84 -0
  109. vnc_remote_control-0.1.0/tests/test_metadata_sync.py +80 -0
  110. vnc_remote_control-0.1.0/tests/test_module_entry.py +179 -0
  111. vnc_remote_control-0.1.0/tests/test_ocr.py +104 -0
  112. vnc_remote_control-0.1.0/tests/test_ports.py +188 -0
  113. vnc_remote_control-0.1.0/tests/test_property_email.py +284 -0
  114. vnc_remote_control-0.1.0/tests/test_property_overrides.py +165 -0
  115. vnc_remote_control-0.1.0/tests/test_rfb.py +312 -0
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: vnc-remote-control
3
+ description: Use when you need to observe and control a machine over VNC/RFB - take a screenshot, read the screen (including OCR), click a pixel or a labeled UI element, type text, or press keys - using the vnc-remote-control CLI. Triggers on driving or automating a guest GUI through VNC (a VM console, a boot screen, a login prompt, a dialog).
4
+ ---
5
+
6
+ # Driving a machine over VNC with vnc-remote-control
7
+
8
+ `vnc-remote-control` is a small CLI that drives a VNC/RFB server: screenshot,
9
+ click, type, press keys, and OCR the screen. This skill explains how to use it
10
+ to reliably control a guest, including how to click the right pixel every time.
11
+
12
+ ## Prerequisites
13
+
14
+ - The `vnc-remote-control` command on PATH (`pip install vnc-remote-control` or
15
+ `uv tool install vnc-remote-control`).
16
+ - `tesseract` installed (required for `ocr` and `click-text`):
17
+ `apt-get install tesseract-ocr`, `brew install tesseract`, or
18
+ `choco install tesseract`.
19
+ - The VNC server's host and port. Every command takes `--port`; `--host`
20
+ defaults to `127.0.0.1`. Examples below use `--port 5901`.
21
+
22
+ ## The one rule that makes clicking reliable
23
+
24
+ RFB pointer coordinates are absolute framebuffer pixels, and a screenshot is
25
+ taken at the native framebuffer resolution. So a coordinate you read off the
26
+ screenshot is the exact coordinate to click. There is no scale factor.
27
+
28
+ The usual cause of "I clicked the wrong place" is reading coordinates off a
29
+ scaled image. Do not let your image viewer downscale the screenshot, and do not
30
+ guess at coordinates from a resized view.
31
+
32
+ ## The control loop
33
+
34
+ 1. Capture and look. Run `screenshot out.png`. It prints `resolution: WxH` on
35
+ stdout. View `out.png` at native size.
36
+ 2. Decide where to act. Read the (x, y) directly off the screenshot, or use OCR
37
+ to locate UI text (see below).
38
+ 3. Verify a coordinate before committing (optional but cheap). Run
39
+ `screenshot out.png --mark X,Y` and confirm the crosshair sits on the target.
40
+ `--grid 50` overlays a coordinate grid if you want a ruler.
41
+ 4. Act: `click X Y`, or click by label with `click-text`. Before you `type`,
42
+ click into the target text field: a window you just opened or launched
43
+ usually does NOT have keyboard focus yet, so typing goes nowhere. Click the
44
+ field first, then `type` / `key`.
45
+ 5. Confirm. Take a fresh screenshot and check the result before the next step.
46
+ Always re-screenshot; never assume an action landed.
47
+
48
+ ## Commands
49
+
50
+ ```bash
51
+ # see the screen (prints "resolution: WxH")
52
+ vnc-remote-control --port 5901 screenshot /tmp/s.png
53
+
54
+ # verify a candidate click point with a crosshair
55
+ vnc-remote-control --port 5901 screenshot /tmp/s.png --mark 640,480
56
+
57
+ # read the screen: each line is "<cx> <cy> <w>x<h> conf=<n> <text>"
58
+ # cx,cy is the center, i.e. the point to click
59
+ vnc-remote-control --port 5901 ocr
60
+ vnc-remote-control --port 5901 ocr --grep "Sign in"
61
+
62
+ # click: by pixel, or (more reliable) by on-screen label
63
+ vnc-remote-control --port 5901 click 640 480
64
+ vnc-remote-control --port 5901 click-text "Next"
65
+
66
+ # type text (literal keysyms; the server maps the layout) and press keys
67
+ vnc-remote-control --port 5901 type "chkdsk c: /f" --enter
68
+ vnc-remote-control --port 5901 type "user@host"
69
+ vnc-remote-control --port 5901 key enter
70
+ vnc-remote-control --port 5901 key esc
71
+ ```
72
+
73
+ ## Clicking: prefer labels over pixels
74
+
75
+ When the target has visible text, `click-text "Label"` (or `ocr --grep` then
76
+ `click <cx> <cy>`) is more reliable than reading a pixel, because it clicks the
77
+ center of the recognized word and does not depend on your coordinate estimate.
78
+ Fall back to pixel clicks for icons and controls with no text, and use
79
+ `--mark X,Y` to confirm the pixel first.
80
+
81
+ ## Typing and keyboard layout
82
+
83
+ `type` sends literal keysyms: each character's keysym is its code point, exactly
84
+ like a standard VNC client. It does no client-side translation. The guest
85
+ keyboard layout is a server setting: openvmm is layout-aware (its
86
+ `--vnc-keyboard-layout` flag, which on Proxmox the ovm shim sets from the VM's
87
+ `keyboard:` key) and maps keysyms into the guest layout. When the server layout
88
+ matches the guest, every character types correctly, including symbols.
89
+
90
+ A text field must have keyboard focus before you `type` (see the focus tip
91
+ below).
92
+
93
+ ## Focus before typing
94
+
95
+ This is the most common typing failure. After you open or launch a window
96
+ (taskbar search, the Run box, an application), it often does NOT have keyboard
97
+ focus, so `type` goes nowhere. Concrete lesson: a freshly-opened editor showed
98
+ nothing typed until it was clicked to focus. Always click into the actual text
99
+ field first, then `type`. If text seems to vanish, re-screenshot, click the
100
+ field, and type again.
101
+
102
+ ## Practical tips
103
+
104
+ - Focus before typing: click the target field, then `type` (see above).
105
+ - One observable step at a time: act, then screenshot to confirm.
106
+ - Boot screens and dialogs: screenshot first to see the current state (spinner,
107
+ login, BSOD, dialog) before deciding what to send.
108
+ - If OCR misreads small text, take the screenshot and read the coordinates
109
+ yourself off the native image.
@@ -0,0 +1,22 @@
1
+ {
2
+ "image": "mcr.microsoft.com/devcontainers/python:3.13",
3
+
4
+ "postCreateCommand": "bash -lc '\nPY=/usr/local/bin/python;\n$PY -m pip install -U pip && \\\n$PY -m pip install ipykernel && \\\n$PY -m pip install -e . && \\\n# register kernel using this exact interpreter\n$PY -m ipykernel install --name=python3 --display-name=\"Python 3 (3.13)\" --user && \\\n# patch the notebook to point to the python3 kernelspec (VS Code will bind to /usr/local/bin/python)\n$PY - <<\"PY\"\nimport json, pathlib\np = pathlib.Path(\"notebooks/Quickstart.ipynb\")\nif p.exists():\n nb = json.loads(p.read_text(encoding=\"utf-8\"))\n md = nb.setdefault(\"metadata\", {})\n md[\"kernelspec\"] = {\n \"name\": \"python3\",\n \"display_name\": \"Python 3 (3.13)\",\n \"language\": \"python\"\n }\n md[\"language_info\"] = {\"name\": \"python\"}\n p.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding=\"utf-8\")\n print(\"Pinned kernelspec to python3 in\", p)\nelse:\n print(\"Notebook not found:\", p)\nPY'\n",
5
+
6
+ "customizations": {
7
+ "vscode": {
8
+ "extensions": ["ms-toolsai.jupyter", "ms-python.python"],
9
+ "settings": {
10
+ "workbench.startupEditor": "none",
11
+ "jupyter.alwaysTrustNotebooks": true,
12
+ "jupyter.kernelPickerType": "OnlyRecommended",
13
+ "python.defaultInterpreterPath": "/usr/local/bin/python"
14
+ }
15
+ },
16
+ "codespaces": {
17
+ "openFiles": ["README.md"]
18
+ }
19
+ },
20
+
21
+ "postAttachCommand": "bash -lc 'if [ -f notebooks/Quickstart.ipynb ]; then code -r notebooks/Quickstart.ipynb; fi'"
22
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "python.defaultInterpreterPath": "/usr/local/bin/python3.13",
3
+ "jupyter.alwaysTrustNotebooks": true,
4
+ "jupyter.kernelPickerType": "OnlyRecommended",
5
+ "jupyter.askForKernelRestart": false
6
+ }
@@ -0,0 +1,92 @@
1
+ name: Extract project metadata
2
+ description: >-
3
+ Parse pyproject.toml and expose project metadata as step outputs.
4
+ Requires Python to be available in the runner environment.
5
+ Uses the standard-library tomllib (with a tomli fallback on Python 3.10).
6
+
7
+ outputs:
8
+ PACKAGE_MODULE:
9
+ description: Python-importable module name (underscored)
10
+ value: ${{ steps.meta.outputs.PACKAGE_MODULE }}
11
+ PROJECT_NAME:
12
+ description: Project name from pyproject.toml
13
+ value: ${{ steps.meta.outputs.PROJECT_NAME }}
14
+ CLI_BIN:
15
+ description: CLI entry-point binary name
16
+ value: ${{ steps.meta.outputs.CLI_BIN }}
17
+ SRC_PATH:
18
+ description: Source directory path (e.g. "src")
19
+ value: ${{ steps.meta.outputs.SRC_PATH }}
20
+ COV_FAIL_UNDER:
21
+ description: Minimum coverage percentage from tool.coverage.report.fail_under
22
+ value: ${{ steps.meta.outputs.COV_FAIL_UNDER }}
23
+ COV_REPORT_FILE:
24
+ description: Coverage report filename (e.g. "coverage.xml")
25
+ value: ${{ steps.meta.outputs.COV_REPORT_FILE }}
26
+ PYTEST_VERBOSITY:
27
+ description: Pytest verbosity flag (e.g. "-vv")
28
+ value: ${{ steps.meta.outputs.PYTEST_VERBOSITY }}
29
+ BANDIT_SKIP_FLAG:
30
+ description: Bandit skip flag string (e.g. "-s B101,B601") or empty
31
+ value: ${{ steps.meta.outputs.BANDIT_SKIP_FLAG }}
32
+
33
+ runs:
34
+ using: composite
35
+ steps:
36
+ - name: Extract project metadata from pyproject.toml
37
+ id: meta
38
+ shell: python
39
+ run: |
40
+ import os, subprocess, sys
41
+ try:
42
+ import tomllib
43
+ except ModuleNotFoundError:
44
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "tomli", "-q"])
45
+ import tomli as tomllib
46
+
47
+ with open('pyproject.toml', 'rb') as fh:
48
+ data = tomllib.load(fh)
49
+
50
+ # --- [project] section ---
51
+ project = data['project']['name']
52
+ module = project.replace('-', '_')
53
+ dash = project.replace('_', '-')
54
+ scripts = list(data['project'].get('scripts', {}).keys())
55
+ cli_bin = scripts[0] if scripts else dash
56
+
57
+ # --- [tool.scripts.test] section ---
58
+ scripts_test = data.get('tool', {}).get('scripts', {}).get('test', {})
59
+ src_path = scripts_test.get('src-path', 'src')
60
+ pytest_verbosity = scripts_test.get('pytest-verbosity', '-vv')
61
+ cov_report_file = scripts_test.get('coverage-report-file', 'coverage.xml')
62
+
63
+ # --- [tool.coverage.report] section ---
64
+ cov_fail_under = (
65
+ data.get('tool', {}).get('coverage', {}).get('report', {}).get('fail_under', 90)
66
+ )
67
+
68
+ # --- [tool.bandit] section ---
69
+ bandit_skips = data.get('tool', {}).get('bandit', {}).get('skips', [])
70
+ bandit_skip_flag = f"-s {','.join(bandit_skips)}" if bandit_skips else ""
71
+
72
+ # Write to GITHUB_OUTPUT for composite action outputs
73
+ with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as out:
74
+ out.write(f"PROJECT_NAME={project}\n")
75
+ out.write(f"PACKAGE_MODULE={module}\n")
76
+ out.write(f"CLI_BIN={cli_bin}\n")
77
+ out.write(f"SRC_PATH={src_path}\n")
78
+ out.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
79
+ out.write(f"COV_REPORT_FILE={cov_report_file}\n")
80
+ out.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
81
+ out.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
82
+
83
+ # Also write to GITHUB_ENV so subsequent steps can use ${{ env.VAR }}
84
+ with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as env:
85
+ env.write(f"PROJECT_NAME={project}\n")
86
+ env.write(f"PACKAGE_MODULE={module}\n")
87
+ env.write(f"CLI_BIN={cli_bin}\n")
88
+ env.write(f"SRC_PATH={src_path}\n")
89
+ env.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
90
+ env.write(f"COV_REPORT_FILE={cov_report_file}\n")
91
+ env.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
92
+ env.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
@@ -0,0 +1,22 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ allow:
8
+ - dependency-type: "direct"
9
+ labels:
10
+ - "dependencies"
11
+ commit-message:
12
+ prefix: "deps"
13
+ include: "scope"
14
+ - package-ecosystem: "github-actions"
15
+ directory: "/"
16
+ schedule:
17
+ interval: "weekly"
18
+ labels:
19
+ - "dependencies"
20
+ commit-message:
21
+ prefix: "deps"
22
+ include: "scope"
@@ -0,0 +1,39 @@
1
+ name: CodeQL
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+ schedule:
9
+ - cron: '0 8 * * 1'
10
+
11
+ jobs:
12
+ analyze:
13
+ name: CodeQL Analyze
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ actions: read
17
+ contents: read
18
+ security-events: write
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ language: [ 'python' ]
23
+
24
+ steps:
25
+ - name: Checkout repository
26
+ uses: actions/checkout@v7
27
+
28
+ - name: Initialize CodeQL
29
+ uses: github/codeql-action/init@v4
30
+ with:
31
+ languages: ${{ matrix.language }}
32
+
33
+ - name: Autobuild
34
+ uses: github/codeql-action/autobuild@v4
35
+
36
+ - name: Perform CodeQL Analysis
37
+ uses: github/codeql-action/analyze@v4
38
+ with:
39
+ category: "/language:${{ matrix.language }}"
@@ -0,0 +1,327 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+ schedule:
9
+ # Run daily at 3:17 AM UTC (off-peak hours, odd minute to avoid congestion)
10
+ - cron: '17 3 * * *'
11
+
12
+ jobs:
13
+ setup:
14
+ name: Extract Python versions and OS matrix from pyproject.toml
15
+ runs-on: ubuntu-latest
16
+ outputs:
17
+ python-versions: ${{ steps.metadata.outputs.python-versions }}
18
+ python-latest: ${{ steps.metadata.outputs.python-latest }}
19
+ os-matrix: ${{ steps.metadata.outputs.os-matrix }}
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ - uses: actions/setup-python@v6
23
+ with:
24
+ python-version: "3.x"
25
+ - name: Parse pyproject.toml for Python versions and OS matrix
26
+ id: metadata
27
+ shell: python
28
+ run: |
29
+ import json, os, tomllib
30
+
31
+ with open("pyproject.toml", "rb") as f:
32
+ data = tomllib.load(f)
33
+
34
+ # Python versions from classifiers
35
+ classifiers = data.get("project", {}).get("classifiers", [])
36
+ prefix = "Programming Language :: Python :: "
37
+ versions = [
38
+ c[len(prefix):]
39
+ for c in classifiers
40
+ if c.startswith(prefix) and "." in c[len(prefix):]
41
+ ]
42
+ if not versions:
43
+ raise SystemExit("No Python X.Y versions found in classifiers")
44
+
45
+ # OS matrix from [tool.ci]
46
+ ci_config = data.get("tool", {}).get("ci", {})
47
+ os_list = ci_config.get("os", ["ubuntu-latest"])
48
+
49
+ with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
50
+ fh.write(f"python-versions={json.dumps(versions)}\n")
51
+ fh.write(f"python-latest={versions[-1]}\n")
52
+ fh.write(f"os-matrix={json.dumps(os_list)}\n")
53
+
54
+ test:
55
+ name: Tests (Python ${{ matrix.python }}, ${{ matrix.os }})
56
+ needs: setup
57
+ runs-on: ${{ matrix.os }}
58
+ strategy:
59
+ fail-fast: false
60
+ matrix:
61
+ os: ${{ fromJSON(needs.setup.outputs.os-matrix) }}
62
+ python: ${{ fromJSON(needs.setup.outputs.python-versions) }}
63
+ env:
64
+ PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"
65
+ PYTHONIOENCODING: "utf-8"
66
+ UV_LINK_MODE: "copy"
67
+ UV_BREAK_SYSTEM_PACKAGES: "1"
68
+ steps:
69
+ - uses: actions/checkout@v7
70
+ - uses: actions/setup-python@v6
71
+ with:
72
+ python-version: ${{ matrix.python }}
73
+ - name: Install uv
74
+ uses: astral-sh/setup-uv@v7
75
+ with:
76
+ enable-cache: true
77
+ prune-cache: false
78
+
79
+ - name: Install bash 4+ on macOS
80
+ if: runner.os == 'macOS'
81
+ run: |
82
+ brew install bash
83
+ echo "$(brew --prefix)/bin" >> "$GITHUB_PATH"
84
+
85
+ - name: Extract project metadata
86
+ id: metadata
87
+ uses: ./.github/actions/extract-metadata
88
+
89
+ - name: Install CI system dependencies (Ubuntu)
90
+ if: runner.os == 'Linux'
91
+ shell: python
92
+ run: |
93
+ import subprocess
94
+ try:
95
+ import tomllib
96
+ except ModuleNotFoundError:
97
+ import pip._vendor.tomli as tomllib
98
+ with open("pyproject.toml", "rb") as f:
99
+ data = tomllib.load(f)
100
+ deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-ubuntu", [])
101
+ if deps:
102
+ print(f"Installing system packages: {' '.join(deps)}")
103
+ subprocess.check_call(["sudo", "apt-get", "update"])
104
+ subprocess.check_call(["sudo", "apt-get", "install", "-y", *deps])
105
+ else:
106
+ print("No CI system dependencies configured for Ubuntu.")
107
+
108
+ - name: Install CI system dependencies (macOS)
109
+ if: runner.os == 'macOS'
110
+ shell: python
111
+ run: |
112
+ import subprocess
113
+ try:
114
+ import tomllib
115
+ except ModuleNotFoundError:
116
+ import pip._vendor.tomli as tomllib
117
+ with open("pyproject.toml", "rb") as f:
118
+ data = tomllib.load(f)
119
+ deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-macos", [])
120
+ if deps:
121
+ print(f"Installing system packages: {' '.join(deps)}")
122
+ subprocess.check_call(["brew", "install", *deps])
123
+ else:
124
+ print("No CI system dependencies configured for macOS.")
125
+
126
+ - name: Install dev deps
127
+ shell: bash
128
+ run: |
129
+ uv pip install -e .[dev] --system
130
+
131
+ - name: Install CI system dependencies (Windows/choco)
132
+ if: runner.os == 'Windows'
133
+ shell: python
134
+ run: |
135
+ import subprocess
136
+ try:
137
+ import tomllib
138
+ except ModuleNotFoundError:
139
+ import pip._vendor.tomli as tomllib
140
+ with open("pyproject.toml", "rb") as f:
141
+ data = tomllib.load(f)
142
+ deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-choco", [])
143
+ if deps:
144
+ print(f"Installing choco packages: {' '.join(deps)}")
145
+ subprocess.check_call(["choco", "install", "-y", *deps])
146
+ else:
147
+ print("No CI choco dependencies configured for Windows.")
148
+
149
+ - name: Install CI pip dependencies (Windows)
150
+ if: runner.os == 'Windows'
151
+ shell: python
152
+ run: |
153
+ import subprocess
154
+ try:
155
+ import tomllib
156
+ except ModuleNotFoundError:
157
+ import pip._vendor.tomli as tomllib
158
+ with open("pyproject.toml", "rb") as f:
159
+ data = tomllib.load(f)
160
+ deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-pip", [])
161
+ if deps:
162
+ print(f"Installing pip packages: {' '.join(deps)}")
163
+ subprocess.check_call(["uv", "pip", "install", *deps, "--system"])
164
+ else:
165
+ print("No CI pip dependencies configured for Windows.")
166
+
167
+ # Self-hosted runners persist ruff cache via RUFF_CACHE_DIR volume mount,
168
+ # so actions/cache is only needed on GitHub-hosted runners.
169
+ - name: Restore ruff cache
170
+ if: runner.environment == 'github-hosted'
171
+ id: ruff-cache
172
+ uses: actions/cache/restore@v5
173
+ with:
174
+ path: .ruff_cache
175
+ key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
176
+ restore-keys: |
177
+ ruff-${{ runner.os }}-py${{ matrix.python }}-
178
+ ruff-${{ runner.os }}-
179
+
180
+ - name: Run ruff (lint + format)
181
+ id: ruff
182
+ continue-on-error: true
183
+ shell: bash
184
+ run: |
185
+ ruff check .
186
+ ruff format --check .
187
+
188
+ - name: Run pyright (type check)
189
+ id: pyright
190
+ continue-on-error: true
191
+ shell: bash
192
+ run: pyright
193
+
194
+ - name: Run pytest (tests + coverage)
195
+ id: pytest
196
+ continue-on-error: true
197
+ shell: bash
198
+ env:
199
+ TEST_VERBOSE: "1"
200
+ # Write coverage SQLite database to local disk. Network mounts
201
+ # and stale journal files cause "database is locked" errors.
202
+ COVERAGE_FILE: "${{ runner.temp }}/.coverage"
203
+ run: >-
204
+ pytest -m "not local_only"
205
+ --cov=${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }}
206
+ --cov-report=term-missing
207
+ --cov-report=xml:${{ env.COV_REPORT_FILE }}
208
+ --cov-fail-under=${{ env.COV_FAIL_UNDER }}
209
+ ${{ env.PYTEST_VERBOSITY }}
210
+
211
+ - name: Upload coverage to Codecov
212
+ if: >-
213
+ always()
214
+ && steps.pytest.outcome == 'success'
215
+ && matrix.os == 'ubuntu-latest'
216
+ && matrix.python == needs.setup.outputs.python-latest
217
+ uses: codecov/codecov-action@v7
218
+ with:
219
+ files: ${{ env.COV_REPORT_FILE }}
220
+ fail_ci_if_error: false
221
+
222
+ - name: Run bandit (security scan)
223
+ id: bandit
224
+ continue-on-error: true
225
+ shell: bash
226
+ run: bandit -q -c pyproject.toml -r ${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }} ${{ env.BANDIT_SKIP_FLAG }}
227
+
228
+ - name: Run pip-audit (vulnerability scan)
229
+ id: pipaudit
230
+ continue-on-error: true
231
+ shell: python
232
+ run: |
233
+ import subprocess, sys, tomllib
234
+ with open("pyproject.toml", "rb") as fh:
235
+ data = tomllib.load(fh)
236
+ vulns = data.get("tool", {}).get("pip-audit", {}).get("ignore-vulns", [])
237
+ cmd = ["pip-audit", "--skip-editable"]
238
+ for v in vulns:
239
+ cmd.extend(["--ignore-vuln", v])
240
+ sys.exit(subprocess.call(cmd))
241
+
242
+ - name: Check all steps passed
243
+ shell: bash
244
+ run: |
245
+ if [[ "${{ steps.ruff.outcome }}" != "success" ]]; then
246
+ echo "::error::Ruff check failed"
247
+ exit 1
248
+ fi
249
+ if [[ "${{ steps.pyright.outcome }}" != "success" ]]; then
250
+ echo "::error::Pyright check failed"
251
+ exit 1
252
+ fi
253
+ if [[ "${{ steps.pytest.outcome }}" != "success" ]]; then
254
+ echo "::error::Pytest failed"
255
+ exit 1
256
+ fi
257
+ if [[ "${{ steps.bandit.outcome }}" != "success" ]]; then
258
+ echo "::error::Bandit security scan failed"
259
+ exit 1
260
+ fi
261
+ if [[ "${{ steps.pipaudit.outcome }}" != "success" ]]; then
262
+ echo "::warning::pip-audit vulnerability scan failed"
263
+ fi
264
+ echo "All checks passed!"
265
+
266
+ - name: Build wheel/sdist
267
+ shell: bash
268
+ run: uv build
269
+
270
+ - name: Verify wheel install in clean env
271
+ shell: bash
272
+ run: |
273
+ uv venv .venv_wheel
274
+ . .venv_wheel/bin/activate 2>/dev/null || . .venv_wheel/Scripts/activate 2>/dev/null
275
+ uv pip install dist/*.whl
276
+ "$CLI_BIN" --version 2>/dev/null || python -m "$PACKAGE_MODULE" --version
277
+
278
+ - name: Prune uv cache
279
+ continue-on-error: true
280
+ shell: bash
281
+ run: uv cache prune --ci
282
+
283
+ - name: Save ruff cache
284
+ if: always() && runner.environment == 'github-hosted' && steps.ruff-cache.outputs.cache-hit != 'true'
285
+ uses: actions/cache/save@v5
286
+ with:
287
+ path: .ruff_cache
288
+ key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
289
+
290
+ pipx-uv:
291
+ name: pipx/uv verification (ubuntu)
292
+ runs-on: ubuntu-latest
293
+ steps:
294
+ - uses: actions/checkout@v7
295
+ - uses: actions/setup-python@v6
296
+ with:
297
+ python-version: "3.14"
298
+
299
+ - name: Install uv
300
+ uses: astral-sh/setup-uv@v7
301
+ with:
302
+ enable-cache: true
303
+ prune-cache: false
304
+
305
+ - name: Extract project metadata
306
+ id: metadata
307
+ uses: ./.github/actions/extract-metadata
308
+
309
+ - name: Build wheel
310
+ run: uv build
311
+ - name: pipx install from wheel
312
+ run: |
313
+ uv pip install --system pipx
314
+ pipx ensurepath
315
+ export PATH="$HOME/.local/bin:$PATH"
316
+ pipx install dist/*.whl
317
+ "$CLI_BIN" --version
318
+
319
+ - name: uv tool install
320
+ run: |
321
+ export PATH="$HOME/.local/bin:$PATH"
322
+ uv tool install --force .
323
+ "$CLI_BIN" --version
324
+
325
+ - name: Prune uv cache
326
+ continue-on-error: true
327
+ run: uv cache prune --ci
@@ -0,0 +1,69 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ push:
7
+ tags:
8
+ - 'v*'
9
+ workflow_dispatch: {}
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: read
16
+ env:
17
+ UV_LINK_MODE: "copy"
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: actions/setup-python@v6
21
+ with:
22
+ python-version: '3.14'
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v7
26
+ with:
27
+ enable-cache: true
28
+ prune-cache: false
29
+
30
+ - name: Build artifacts
31
+ run: uv build
32
+
33
+ - name: Upload artifacts
34
+ uses: actions/upload-artifact@v7
35
+ with:
36
+ name: dist
37
+ path: dist/*
38
+
39
+ - name: Prune uv cache
40
+ continue-on-error: true
41
+ run: uv cache prune --ci
42
+
43
+ publish:
44
+ needs: build
45
+ runs-on: ubuntu-latest
46
+ environment: pypi
47
+ permissions:
48
+ contents: read
49
+ steps:
50
+ - name: Verify PyPI token is set
51
+ run: |
52
+ if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then
53
+ echo "::error title=Missing PyPI token::Set the PYPI_API_TOKEN secret in GitHub repository settings to enable PyPI publishing on releases."
54
+ echo "See: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions"
55
+ exit 1
56
+ fi
57
+
58
+ - name: Download artifacts
59
+ uses: actions/download-artifact@v8
60
+ with:
61
+ name: dist
62
+ path: dist/
63
+
64
+ - name: Publish to PyPI
65
+ uses: pypa/gh-action-pypi-publish@release/v1
66
+ with:
67
+ password: ${{ secrets.PYPI_API_TOKEN }}
68
+ attestations: false
69
+ skip-existing: true