Add native Python integration - #952
Conversation
Adds a `varlock` Python package that resolves the env schema from inside a running process, so no wrapped launch is needed. This is the only workable path in a Jupyter notebook, where the kernel is spawned by the editor and there is no command line for `varlock run` to wrap. Architecturally it mirrors the deep JS integration: it resolves nothing itself, shells out to `varlock load --format json-full`, parses the graph, injects into os.environ, and exposes the values. When the process was started by `varlock run` the values are already there, so `load()` adopts them instead of resolving again and the same code works both ways. Behavior that intentionally differs from the JS runtime: - raises VarlockLoadError instead of exiting, since killing a kernel is the wrong response to a schema typo (opt into fail-fast via `import varlock.auto_load`) - unset optional keys are absent rather than present as None, matching the generated module's contract - repr() masks sensitive values, since Jupyter writes the repr of a cell's last expression into the saved notebook Also adds a Jupyter docs page and covers the package in the Python page.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-website | 1a8989a | Commit Preview URL Branch Preview URL |
Jul 28 2026, 09:00 PM |
Masks values marked @sensitive in output, the Python counterpart of the JS runtime's console patching. Output escapes a Python process in three separate places, so this covers all of them: - logging, via the record factory, which every logger and handler goes through regardless of when it was created (a filter would only cover the one logger or handler it was attached to) - print(), via builtins.print, which resolves its stream at call time and so survives a library or kernel replacing sys.stdout afterwards, plus the stream objects themselves for direct write() calls - notebook cell output, via IPython's display formatter, which does not go through stdout at all and is the main way a secret ends up saved in an .ipynb load() installs it, following the schema's @redactLogs setting, with a redact_logs argument to override per call. Also adds the redaction primitives: redact() walks strings, lists, and dicts; reveal() marks a value as deliberately shown; scan_for_leaks() raises VarlockLeakError naming the key, and respects @sensitive={preventLeaks=false}. Matching the JS runtime, only values that resolved to strings are registered, and elements of composite values register individually so leaking one item of a list is caught.
`load()` returns values typed as Any. Casting to the `Env` TypedDict that `@generatePythonEnv` already emits gives a type checker everything it needs, with no new codegen mode: mypy catches both wrong-typed assignments and unknown keys. The cast is a no-op at runtime, so the object is still the live env.
|
@pullfrog review this |
There was a problem hiding this comment.
Important
Redaction lifecycle failures can leave global patches active or break logging after uninstall. The package publication also needs to be sequenced before the new install docs ship.
Reviewed changes: This PR adds a native Python package that resolves Varlock schemas through the CLI, exposes a live typed environment mapping, and redacts sensitive output for scripts and notebooks.
- Add the
varlockPython package: Implements binary discovery, CLI execution, serialized graph parsing, environment injection, reload, unload, and public errors. - Add sensitive-output protection: Introduces redaction, leak scanning, deliberate reveal support, and global patches for
logging,print, streams, and IPython display output. - Add Python package coverage: Adds unit tests, a Python CI job, and smoke tests against both direct CLI resolution and
varlock runadoption. - Document Python and Jupyter usage: Adds native package setup, API, typing, redaction, reload, and notebook guidance.
⚠️ Install instructions precede package publication
The new user paths all begin with pip install varlock, but this package is not published and the PR adds no PyPI release workflow. If the docs deploy before a manual publication, every native Python and Jupyter setup attempt stops at installation.
Technical details
# Install instructions precede package publication
## Affected sites
- `packages/varlock-python/README.md:6` — presents the package as installable from PyPI.
- `packages/varlock-website/src/content/docs/integrations/jupyter.mdx:20` — requires the unpublished package during setup.
- `packages/varlock-website/src/content/docs/integrations/python.mdx:14` — requires the unpublished package during setup.
## Required outcome
- Ensure `pip install varlock` succeeds before these docs are deployed, or hold the install guidance until publication is complete.
- Establish how later Python package versions are published so package fixes can be released after merge.azure/gpt-5.6-sol | 𝕏
|
Pushed Task list (5/5 completed)
|
IPython only infers subscript completions for real dict instances, so a Mapping has to advertise its keys via _ipython_key_completions_ or `env["<TAB>"]` silently returns nothing. Attribute completion already worked through __dir__; this covers the subscript form, which is the access style the docs lead with. Also points the Jupyter page at the TypedDict cast, noting that runtime completion needs no setup while VS Code's static checking does.
subprocess.list2cmdline implements MSVCRT argv quoting, which only quotes an argument containing whitespace. cmd.exe parses its own metacharacters (&, |, <, >, ^, parens) out of unquoted text before argument splitting happens, so with shell=True a `&` in a --path value, or in the install path itself, could start a second command. Same class as Node's CVE-2024-27980, which Node fixed inside the runtime; Python offers no equivalent protection. Drops shell=True entirely. A .cmd shim is now run as an explicit `cmd.exe /d /s /c "..."` line with every token quoted individually, passed with shell=False so exactly that string reaches CreateProcess rather than one built by subprocess whose /c quote-stripping rules are much harder to reason about. /s makes cmd strip only the outer quote pair. The quoting is CPython's own list2cmdline algorithm with unconditional quoting. Tests pin it against the stdlib wherever both quote, and round-trip it through a from-scratch MSVCRT parser (itself cross-checked against list2cmdline) to prove arguments decode back to what went in. All of this is string construction, so it runs on every platform; the execution path itself is untested here for want of a Windows machine.

Adds a
varlockPython package (packages/varlock-python) that resolves the env schema from inside a running process, so no wrapped launch is needed.This is a follow-up to the Python codegen support, and the next step toward parity with the deeper JS/TS integrations. It came out of a user request about Jupyter: VS Code and JupyterLab spawn the kernel themselves, so there is no command line for
varlock runto wrap, which leaves shelling out tovarlock load --format jsonand parsing it by hand as the only option today.How it works
Same architecture as the deep JS integration, not a reimplementation. It resolves nothing itself: it shells out to
varlock load --format json-full --compact, parses the graph, injects intoos.environ, and exposes the values. Every plugin, cache, and validation behavior comes from the installed CLI._cli.pyexecSyncVarlock_binary.pyfindVarlockBin, plus the standalone installer's directories_runtime.pyinitVarlockEnvstate andprocess.envinjection_env.pyENVproxy_redaction.pyresetRedactionMap/redactSensitiveConfig/scanForLeaks_patch.pypatchGlobalConsoleauto_load.pyvarlock/auto-loadWhen the process was started by
varlock run, the values are already resolved, soload()adopts the blob instead of resolving again. The same code works both ways.ENVis a read-only mapping that also allows attribute access. Unknown keys raise rather than returningNone. Zero runtime dependencies, Python 3.9+.Redaction
load()masks values marked@sensitivein output, following the schema's@redactLogssetting. Output escapes a Python process in three separate places, so all three are covered:logging, via the record factory, which every logger and handler goes through no matter when it was created. A filter would only cover the one logger or handler it was attached to.print(), viabuiltins.print, which resolves its stream at call time and so survives a library or kernel replacingsys.stdoutafter redaction was installed. The stream objects are patched too, for directwrite()calls..ipynb.Plus the primitives:
redact()walks strings, lists, and dicts;reveal()marks a value as deliberately shown;scan_for_leaks()raisesVarlockLeakErrornaming the key and respects@sensitive={preventLeaks=false}.Matching the JS runtime, only values that resolved to strings are registered (masking every occurrence of a sensitive number would wreck unrelated output), and elements of composite values register individually so leaking one item of a list is caught.
Typing
No new codegen mode. The
EnvTypedDict that@generatePythonEnvalready emits types the native loader as-is:Verified with mypy, which catches both unknown keys and wrong-typed assignments. Separately,
ENV.<TAB>andENV["<TAB>"]complete schema keys at runtime in Jupyter and IPython (the subscript form needs_ipython_key_completions_, since IPython only infers keys for realdictinstances). The cast is a no-op at runtime, so the object is still the live env. This is documented rather than automated: a generated module that importsvarlockwould give up the current one's selling point of having no dependencies, to save a single line.Deliberate differences from the JS runtime
load()raisesVarlockLoadErrorwith the CLI's formatted stderr attached. Killing a kernel is the wrong response to a schema typo.import varlock.auto_loadopts into fail-fast for scripts and servers.None, matching the contract the generated module already documents.ENV["OPTIONAL"]reports "exists in your schema, but has no value in this environment", distinct from the unknown-key error.Trueinjects as"true", composites use the blob'senvStr, and unset keys are not injected at all (whatvarlock rundoes, since Node dropsundefinedenv values).reload()keeps the previous values. The uninjected environment is handed to the CLI as a copy rather than applied in place first, so a bad schema edit mid-notebook does not wipe a working env.varlock run, import sets up the redaction map soredact()works, but only an explicitload()installs the patches.Binary discovery reads
VARLOCK_BIN, thenPATH, then the standalone install directories, then walks up fornode_modules/.bin.Testing
python-packageCI job.varlock runblob, and error reporting. The in-process one also asserts redaction ofprint,logging,reveal(), andscan_for_leaks.reload()picking up a schema edit,unload()restoringos.environ, and the notebook path driven through a real IPython shell (cell output,print, and theENVrepr all masked).Docs
New Jupyter page covering setup, reloading, which schema gets loaded, redaction and its limits, auth prompts, and the
varlock run -- jupyter labalternative. The Python page now leads with the two paths and gains a native package section covering the API, typing, and redaction; the codegen content moves under a "Generated env module" heading.@redactLogsno longer says it is JavaScript-only.Reviewer notes
pip install varlock, but nothing is published to PyPI yet. Bothvarlockandvarlock-cliare available as names. Either publish an initial version before this merges, or hold the docs pages back. This is the one thing blocking merge as-is..cmdshims no longer go throughshell=True.list2cmdlineonly quotes arguments containing whitespace, butcmd.exeparses&/|/</>/^/parens out of unquoted text before argv splitting, so a metacharacter in a--pathvalue or in the install path could start a second command (the Node CVE-2024-27980 class). Now built as an explicitcmd.exe /d /s /cline with every token quoted, passed withshell=False. The quoting is tested against the stdlib and round-tripped through an MSVCRT parser, but the execution path itself is untested for want of a Windows machine, so a Windows reviewer would help.builtins.printis invasive. It is reversible viauninstall_redaction(), and it is the only way to keepprintredaction working across a stream swap, but worth a second opinion.uv, not bun. Thepackage.jsonis a private shim so bun workspaces and turbo can see the directory; it is never published to npm.varlock[cli]extra, sopip installalone is enough in a fresh notebook environment.