fix(google-workspace): defer PEP 604 unions so setup.py runs on Py3.9 (#14688)#15158
fix(google-workspace): defer PEP 604 unions so setup.py runs on Py3.9 (#14688)#15158briandevans wants to merge 1 commit into
Conversation
|
Heads-up on CI: the Actual files touched in this PR ( None match any of the scanner's attack patterns (no Other CI lanes ( |
There was a problem hiding this comment.
Pull request overview
This PR fixes a Python 3.9 import-time crash in the Google Workspace skill setup flow by deferring evaluation of PEP 604 (X | None) annotations, preventing macOS system /usr/bin/python3 (3.9) from failing at module import time.
Changes:
- Add
from __future__ import annotationstohermes_constants.pyand the Google Workspace skill scripts to defer annotation evaluation on Py3.9. - Add a regression test ensuring the future-import guard is present and that the affected modules still contain PEP 604 unions.
- Add a smoke test for
hermes_constantsbehavior and a compile step intended to guard against newer syntax.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
hermes_constants.py |
Adds deferred-annotations future import to prevent Py3.9 import-time crashes from PEP 604 unions. |
skills/productivity/google-workspace/scripts/setup.py |
Adds deferred-annotations future import to keep the auth entrypoint importable on Py3.9. |
skills/productivity/google-workspace/scripts/google_api.py |
Adds deferred-annotations future import to avoid Py3.9 import-time evaluation of unions. |
tests/skills/test_py39_compat_google_workspace.py |
Adds regression tests to ensure the pragma stays in place and basic behavior remains intact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def test_google_workspace_setup_script_compiles() -> None: | ||
| """``py_compile`` proves the script is syntactically valid. Combined | ||
| with the AST check above, this is the strongest proof we can give under | ||
| a Python 3.11 test runner that the #14688 crash is gone. | ||
|
|
||
| A real Python 3.9 regression run would fail at *import* (TypeError on | ||
| PEP 604 union), which py_compile does NOT catch — the syntax itself is | ||
| valid in 3.9, it's the runtime evaluation that fails. The AST check | ||
| confirms the deferral pragma is in place; this confirms the file hasn't | ||
| grown OTHER Python 3.10+ syntax (e.g. match/case) that the pragma | ||
| doesn't help with. | ||
| """ | ||
| import py_compile | ||
|
|
||
| for path in _PY39_COMPAT_FILES: | ||
| try: | ||
| py_compile.compile(str(path), doraise=True) | ||
| except py_compile.PyCompileError as e: | ||
| pytest.fail(f"{path.relative_to(REPO_ROOT)} failed to compile: {e}") |
There was a problem hiding this comment.
test_google_workspace_setup_script_compiles uses py_compile under the current interpreter (CI runs 3.11 per the docstring). That cannot detect Python 3.9-incompatible syntax like match/case—it will still compile successfully on 3.11—so the test/docstring currently give a false sense of Py3.9 safety. Consider replacing this with a parser-level compatibility check (e.g., ast.parse(..., feature_version=9) for 3.9 grammar) or adjusting the test name/docstring to reflect what it actually guarantees (3.11 syntax validity only).
| def test_google_workspace_setup_script_compiles() -> None: | |
| """``py_compile`` proves the script is syntactically valid. Combined | |
| with the AST check above, this is the strongest proof we can give under | |
| a Python 3.11 test runner that the #14688 crash is gone. | |
| A real Python 3.9 regression run would fail at *import* (TypeError on | |
| PEP 604 union), which py_compile does NOT catch — the syntax itself is | |
| valid in 3.9, it's the runtime evaluation that fails. The AST check | |
| confirms the deferral pragma is in place; this confirms the file hasn't | |
| grown OTHER Python 3.10+ syntax (e.g. match/case) that the pragma | |
| doesn't help with. | |
| """ | |
| import py_compile | |
| for path in _PY39_COMPAT_FILES: | |
| try: | |
| py_compile.compile(str(path), doraise=True) | |
| except py_compile.PyCompileError as e: | |
| pytest.fail(f"{path.relative_to(REPO_ROOT)} failed to compile: {e}") | |
| def test_google_workspace_setup_script_parses_as_python39() -> None: | |
| """Parse each guarded file using Python 3.9 grammar. | |
| Running ``py_compile`` under the active CI interpreter (3.11) only proves | |
| the file is valid for *that* interpreter, so it would miss newer syntax | |
| such as ``match/case`` that is invalid on Python 3.9. Parsing with | |
| ``feature_version=(3, 9)`` gives a parser-level compatibility check for | |
| the macOS system-``python3`` constraint documented in this module. | |
| This still does not simulate Python 3.9 runtime annotation evaluation; | |
| ``test_file_has_future_annotations_import`` remains the guard for the | |
| original #14688 import-time failure. | |
| """ | |
| for path in _PY39_COMPAT_FILES: | |
| source = path.read_text() | |
| try: | |
| ast.parse(source, filename=str(path), feature_version=(3, 9)) | |
| except SyntaxError as e: | |
| pytest.fail( | |
| f"{path.relative_to(REPO_ROOT)} is not valid Python 3.9 syntax: {e}" | |
| ) |
|
Thanks @copilot — fair catch. Pushed Empirical proof the new test is strictly stronger: Also parametrized the new test over the three guarded files to match the style of the other per-file tests in the module. All 10 tests pass locally. Kept |
|
CI triage for the Repro'd all 4 failures on clean None touch Happy to rebase once the main-branch failures are swept. |
|
Noting the cross-reference from @alt-glitch to #13765 and #13789 — framing this PR's scope vs theirs:
Why the narrow scope:
No objection to either broader PR; just offering the scoped alternative for the one skill that's already generating support tickets. |
…gle_api (NousResearch#14688) macOS ships /usr/bin/python3 = 3.9. When a local-model-backed agent uses the terminal tool to run `python3 scripts/setup.py --check`, it picks up that system interpreter rather than the Hermes venv's 3.11+. On Py3.9 expressions like `Path | None` in a function signature evaluate at def-time and raise `TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` BEFORE the script's try/except ModuleNotFoundError fallback can run. Agents interpret the crash as "auth broken" and loop through setup. `from __future__ import annotations` (PEP 563) converts every annotation into a string so the PEP 604 unions never fire at import time. Py3.10+ users are unaffected. Files in this PR: - hermes_constants.py — shared infra, imported transitively by the skill scripts; has `Path | None`, `str | None`, `dict | None`, and two module-level `bool | None` annotations. - skills/productivity/google-workspace/scripts/google_api.py — imported by setup.py's `--check` path; carries `str | None` and `dict | None` on `_gws_binary`/`_run_gws`. Note: the equivalent fix for `scripts/setup.py` already landed on main via NousResearch#13038 ("ship google-workspace deps as [google] extra; make setup.py 3.9-parseable"). The test file pins all three files (hermes_constants, setup, google_api) so the regression guard catches future drift on any of them. Tests (tests/skills/test_py39_compat_google_workspace.py): - AST check that each file starts with `from __future__ import annotations` (substring scan would pass false on a docstring mention). - Verifies each file still contains PEP 604 unions — if a future edit strips them all, the pinned list should be reconsidered. - Smoke test that hermes_constants loads and the previously-crashing `get_optional_skills_dir` and `parse_reasoning_effort` return their expected values. - ast.parse(source, feature_version=(3, 9)) on each file — catches any Py3.10+-only syntax beyond PEP 604 (e.g. match/case). Replaces the prior `py_compile`-based check, which under the CI interpreter (3.11) happily compiled 3.10+-only syntax — Copilot's review on NousResearch#15158 flagged this and suggested ast.parse with feature_version. No runtime behavior change on Py3.10+. On Py3.9 the difference is an import-time TypeError becoming a clean module load. Closes NousResearch#14688 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d20232f to
85a1cb3
Compare
|
Rebased onto current
Squashed the original two-commit history into one commit on rebase. Empirical verification of the test-file change Copilot suggested (ast.parse + feature_version=(3,9)) was preserved in the new commit message. Test run on rebased branch: Sanity-imported Cross-PR positioning unchanged: still narrower scope than #13765 / #13789 (broad Py3.9 compat sweeps that are still open). This PR ships only the two files that are demonstrably reachable from the agent terminal-tool path that triggered the original report. |
|
Closing to keep the queue clean — happy to reopen if this is still useful. The setup.py portion was already salvaged via #13038, and the project now requires Python ≥3.11 (per pyproject.toml), so the PEP 604 deferral is no longer load-bearing. |
What does this PR do?
Fixes
#14688: on macOS, the agent's terminal tool invokespython3 scripts/setup.py --check(the google-workspace skill's auth entry-point) via the system interpreter/usr/bin/python3= 3.9. The script crashes at import time ondef get_optional_skills_dir(default: Path | None = None)with:PEP 604 union syntax evaluates at function-def time on Py3.9 — the
try/except ModuleNotFoundErrorfallback insetup.pynever runs. The agent sees the crash, interprets it as "auth broken", and re-runs setup from scratch in a loop.SKILL.md does tell agents to use
$HERMES_HOME/hermes-agent/venv/bin/python, but smaller local models (e.g. Qwen3.5-35B-A3B-4bit) routinely simplify that down to plainpython3, reproducing the bug on every call.Fix
Add
from __future__ import annotations(PEP 563) to the three affected files. This converts every annotation to a string so the PEP 604 unions never fire at module-load time. Py3.10+ behavior is unchanged.Why this file set (grep'd exhaustively for PEP 604 syntax):
hermes_constants.py— shared infra with 5X | Nonesites, including two module-level assignments (_wsl_detected: bool | None = None,_container_detected: bool | None = None) that evaluate eagerly even without the future import.skills/productivity/google-workspace/scripts/setup.py— direct crash site; also usestuple[str, str | None]at line 239.skills/productivity/google-workspace/scripts/google_api.py— imported duringsetup.py --check; usesstr | Noneanddict | Noneon_gws_binary/_run_gws(called out by @thedavidweng in the issue comments).gws_bridge.pyhas no PEP 604 syntax — verified via AST-level scan, no pragma needed.Related Issue
Fixes #14688
Type of Change
Changes Made
from __future__ import annotationstohermes_constants.py,skills/productivity/google-workspace/scripts/setup.py, andskills/productivity/google-workspace/scripts/google_api.pywith an inline comment linking back to google-workspace setup.py crashes under macOS system python3 (auto-reexec into venv) #14688 so a future refactor doesn't drop the pragma without thinking.tests/skills/test_py39_compat_google_workspace.pywith 4 classes of assertion:from __future__ import annotations. Usesast.parsewalk rather than a substring scan — a docstring mentioning the pragma wouldn't pass.X | Nonestyle annotation. If a future edit strips them all, the pragma becomes cosmetic and the pinned list should be re-evaluated; the test will flag that.hermes_constantsimports cleanly and the two functions whose annotations were problematic (get_optional_skills_dir,parse_reasoning_effort) still return expected values.All 8 new tests pass. All 14 existing
tests/skills/test_google_oauth_setup.py+test_google_workspace_api.pytests still pass — no runtime behavior change under Py3.11+.Verification
$ python3 -m pytest tests/skills/test_py39_compat_google_workspace.py tests/skills/test_google_oauth_setup.py tests/skills/test_google_workspace_api.py -v ... ================== 22 passed, 1 warning in 0.09s ==================Proof that the pragma does what it claims (annotations become strings rather than being evaluated eagerly):
Why not the reporter's second proposed fix (parents[4] path)?
The reporter diagnosed two root causes; I only applied the real one.
Their claim was that
Path(__file__).resolve().parents[4]insetup.py's fallback resolves to~/.hermes/whenhermes_constants.pyactually lives at~/.hermes/hermes-agent/hermes_constants.py, so the fallbacksys.path.insertpoints at the wrong directory.Verified locally this is not the case: for
hermes-agent/skills/productivity/google-workspace/scripts/setup.py,parents[0..4]resolves toscripts/ → google-workspace/ → productivity/ → skills/ → hermes-agent/— exactly the directory containinghermes_constants.py. The reporter's own traceback showshermes_constants.pybeing found at the expected path before crashing withTypeErrorat line 60; that's inconsistent with a misrouted fallback. The crash is purely the Py3.9 type-evaluation failure.Adding a needless
/ \"hermes-agent\"suffix would break the fallback on other installation layouts. Leaving that code path alone.Out of scope for this PR (possible follow-ups)
match/case). Separate change.X | Nonepattern — this PR ships the narrow google-workspace fix.Test plan
get_optional_skills_dir.__annotations__['default']is the string'Path | None', not aUnionTypehermes_constants.get_optional_skills_dir()andparse_reasoning_effort()still return expected values