mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 05:16:40 +08:00
* docs: add design spec for lfx serve multi-flow startup and dynamic upload
* docs: add implementation plan for lfx serve multi-flow startup and dynamic upload
* feat(serve): add FlowRegistry for mutable in-process flow management
* feat(serve): add flow_id_from_content for content-based flow ID generation
* fix(test): update streaming tests to use FlowRegistry API
* test(serve): add upload endpoint tests
* feat(serve): add build_registry_from_directory and build_registry_from_paths helpers
* refactor(serve): remove dead _analyze_graph_structure and _generate_dynamic_run_description helpers
* feat(serve): accept directory and multiple file paths at startup; wire registry to serve_command
* [autofix.ci] apply automated fixes
* fix(serve): move traceback import to module level; remove planning docs from branch
* [autofix.ci] apply automated fixes
* fix(serve): deepcopy graph in stream_flow; use json.dumps for error stream payload
* test(serve): verify stream setup error payload is valid JSON with special characters
* [autofix.ci] apply automated fixes
* fix(serve): use load_flow_from_json directly in _load_graph_and_meta for clear error messages
* docs(serve): document multi-worker and persistence limitations on FlowRegistry
* feat(serve): allow starting server with no flows; accept full Langflow flow JSON on upload
- Remove the requirement for a flow path/JSON/stdin at startup; lfx serve
now starts with an empty FlowRegistry when no input is provided, ready
to accept flows via POST /flows/upload/
- UploadFlowRequest now accepts the full Langflow export JSON directly
(extra="allow", id field added) so callers can do -d @flow.json without
wrapping; load_flow_from_json receives the full dict and the flow's own
stable id is used when present
* fix(serve): harden upload endpoint and remove unused verbose_print param
- Pass body.model_dump(exclude={"replace"}) to load_flow_from_json instead of
body.model_dump(); keeps the top-level "data" key the function expects while
excluding the internal replace flag
- Set graph.flow_id after upload so it matches the registry key
- Return 409 Conflict on duplicate flow ID; add replace=true field to allow
explicit overwrite
- Remove unused verbose_print param from _load_graph_and_meta and update callers
- Clarify directory scan is top-level only (non-recursive) in CLI help text
- Add test_upload_full_export_as_body regression test: sends a complete Langflow
export JSON as the request body (curl -d @flow.json pattern) where body.data
has no nested "data" key — would have caught the KeyError('data') regression
* fix(serve): address PR review comments
- Restore .py script support: _load_graph_and_meta now dispatches to
load_graph_from_script for .py paths; CLI validation accepts .json and .py;
help text updated in both _running_commands.py and commands.py
- Fix flow ID collision in build_registry_from_paths: compute a shared common
root from all supplied paths so same-named files in different directories
get distinct UUIDv5 IDs instead of silently overwriting each other
- Harden FlowRegistry.add(): add overwrite=False parameter that raises
ValueError on duplicate IDs; startup paths keep default (collision = error),
upload endpoint passes overwrite=body.replace
- Add regression test: test_same_filename_in_different_dirs_gets_distinct_ids
- Update test_duplicate_add_replaces_graph to reflect new overwrite semantics
* test(serve): add unit tests for .py script path in serve command
- test_load_graph_and_meta_dispatches_to_script_loader_for_py: verifies
_load_graph_and_meta calls load_graph_from_script (not load_flow_from_json)
for .py paths, and that meta.title and relative_path are set correctly
- test_serve_command_accepts_py_file: end-to-end check that lfx serve my.py
reaches the server (exit_code == 0)
- test_serve_command_rejects_unsupported_extension: .txt and other unknown
extensions must exit non-zero with a helpful message
* [autofix.ci] apply automated fixes
* Add --no-env-fallback option to support scoped request_vars
* [autofix.ci] apply automated fixes
* feat(serve): filesystem-backed flow registry for multi-worker sharing
Adds a FlowStore abstraction with two implementations:
- NullFlowStore (default): in-memory only, existing behavior unchanged
- FilesystemFlowStore: persists each flow as {id}.json; writes are
atomic (tmp→rename); reads and deletes are TOCTOU-safe; path
traversal guard rejects unsafe flow IDs
FlowRegistry now delegates persistence to FlowStore:
- add(raw_json=...) writes to store when raw JSON is available
- get() falls through to store on cache miss, reconstructs + caches
- list_metas() merges in-memory and store-side entries
- remove() propagates deletion to store
- warm_from_store() pre-warms the in-memory cache at startup
New --flow-dir option on lfx serve activates the filesystem store.
All uvicorn workers pointing at the same directory serve the same
flows — use /tmp/lfx-flows for single-pod sharing or a PVC mount
for cross-pod sharing. Raw JSON is plumbed through _load_graph_and_meta
and both builder functions so startup-loaded .json flows are persisted
automatically; .py flows remain in-memory only.
* feat(serve): add --workers option for multi-worker uvicorn support
Adds --workers N to lfx serve, enabling multiple uvicorn processes to
share flows via --flow-dir.
serve_command is restructured from @syncify async to plain sync: all
async registry-building is extracted into _build_serve_registry() and
called via asyncio.run(), then uvicorn.run() handles the server.
For workers > 1, uvicorn requires an import string rather than an app
object. The factory create_serve_app() in serve_app.py reads
LFX_SERVE_FLOW_DIR and LFX_SERVE_NO_ENV_FALLBACK from env, pre-warms
from the shared FilesystemFlowStore, and returns a ready FastAPI app.
The parent sets those env vars unconditionally before dispatching.
For workers == 1, the pre-built app object is used directly (no change
to existing single-worker behavior). A warning is emitted when
--workers > 1 is used without --flow-dir since each worker would have
an isolated in-memory registry.
The startup panel flow count is now accurate for single-worker +
--flow-dir by calling warm_from_store() before printing.
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes (attempt 2/3)
* [autofix.ci] apply automated fixes (attempt 3/3)
* fix(serve): remove no_args_is_help, drop verbose_print param, harden _safe_path
- Remove no_args_is_help=True from serve command so lfx serve with no
arguments starts with an empty registry instead of showing help
- Remove unused verbose_print parameter from create_multi_serve_app and
update all callers (14 call sites across tests)
- Wrap _safe_path's relative_to() check in try/except for a clear error
message; restructure delete() to use try/except/else (TRY300)
- Add TYPE_CHECKING guard for FlowStore annotation in commands.py; wrap
long --workers help string; restore FBT003 noqa on no_env_fallback
- Fix test_env_fallback_skipped_when_flag_true to allow logger's getenv
calls while still asserting the credential variable is never looked up
- Fix ARG002/D205/RUF059 lint issues in test files
* [autofix.ci] apply automated fixes
* fix(serve): resolve merge conflicts and clean up test section comments
* ruff - test updates
* fix(serve): cross-worker delete propagation and HTTP 500 for execution failures
Cross-worker stale cache (multi-worker DELETE bug):
- Add FlowStore.is_persistent to distinguish real stores from NullFlowStore
- Track _store_sourced in FlowRegistry for flows written to a persistent store
- get() re-reads the store for store-sourced flows to detect cross-worker deletes;
evicts and returns None if the file is gone
- list_metas() skips store-sourced flows whose store key is no longer present
- Add _evict() helper to clean up _flows, _store_meta_cache, _store_keys, _store_sourced
HTTP status for execution failures:
- POST /flows/{id}/run now returns HTTP 500 (not 200) when execution raises or
when the flow returns success=false; body is still RunResponse for debug detail
- Updated OpenAPI responses declaration: 500 now maps to RunResponse model
* fix(serve): harden multi-worker registry correctness and safety
- Add FlowAlreadyRegisteredError (ValueError subclass) so upload_flow
catches only the specific race-condition exception rather than any
ValueError from unrelated internals.
- Add _SERVE_ENV_PREFIX constant; rebuild LFX_SERVE_* env var names from
it and add try/finally cleanup so those vars are always deleted after
uvicorn.run() returns in multi-worker mode.
- Reject .py startup files when --workers > 1: Python graphs cannot be
serialized to the filesystem store so workers would silently start with
empty registries for those flows.
- Remove warm_from_store() from build_registry_from_paths/directory; the
builders no longer pre-warm to avoid double-loading when the startup dir
and flow-dir overlap. serve_command still warms for single-worker mode.
- Fix FlowRegistry.__len__ to delegate to list_metas() so store-only
flows (not yet cache-loaded) are counted; this makes the startup panel
and /health flow_count accurate for multi-worker + pre-placed files.
- Fix FlowRegistry.remove() to delete both the primary store key file and
any alternate-keyed file (UUID or stem), ensuring cross-worker DELETE
propagation: after a delete, every worker's next request to that flow
triggers the per-request stale check, reads None from the store, and
returns 404 regardless of which key its cache entry uses.
- Document the per-request stale check in FlowRegistry docstring with
latency guidance for network volumes vs. local tmpfs.
- Add tests for all of the above, including a cross-worker DELETE
propagation scenario validated by a live 2-worker integration test.
* fix(serve): load startup flows in every uvicorn worker via LFX_SERVE_STARTUP_PATHS
Previously, lfx serve flow.json --workers 4 silently dropped startup flows:
the parent loaded them into a NullFlowStore registry that workers never
inherited. Each worker called create_serve_app() fresh and started empty,
returning flow_count: 0 from /health and 404 for the startup flow.
Fix: before calling uvicorn.run() in multi-worker mode, set
LFX_SERVE_STARTUP_PATHS to the JSON-encoded list of resolved startup file
paths (or the inline-JSON temp file path). create_serve_app() reads this env
var and re-loads the startup flows independently in each worker process using
a ThreadPoolExecutor to avoid RuntimeError from asyncio.run() being called
inside uvicorn's already-running event loop.
Startup flows loaded this way live in each worker's in-memory registry.
Uploaded flows still require --flow-dir for cross-worker sharing (existing
warning preserved). The LFX_SERVE_* env vars are cleaned up after
uvicorn.run() returns via the existing prefix-sweep finally block.
Tests added:
- test_serve_command_sets_startup_paths_env_for_multi_worker: verifies
LFX_SERVE_STARTUP_PATHS is set before uvicorn.run() and cleaned up after
- TestCreateServeAppFactory: unit tests for create_serve_app() in all three
modes (empty start, with startup paths, empty paths list)
* fix(serve): fix 3 worker/store consistency issues
Issue 1 — redundant store writes with --flow-dir + startup files + workers > 1:
When flow_dir is set the parent already persisted startup flows to the store.
create_serve_app() was re-loading and re-writing them in every worker.
Fix: create_serve_app() now skips LFX_SERVE_STARTUP_PATHS when flow_dir is
set and relies solely on warm_from_store(). Similarly, serve_command now only
populates LFX_SERVE_STARTUP_PATHS when flow_dir is NOT set.
Issue 2 — .py restriction was too broad:
The error blocked .py + --workers > 1 even without --flow-dir, where each
worker can safely re-execute the .py via LFX_SERVE_STARTUP_PATHS. The error
now only fires when --flow-dir is also set (workers would skip startup paths
and warm_from_store, missing the .py flow entirely since it can't be stored).
Issue 3 — parent built FastAPI app for multi-worker then discarded it:
create_multi_serve_app() was called before the workers > 1 / else branch,
building a full FastAPI app that was never served in multi-worker mode.
Moved into the else branch so it's only built for single-worker.
Tests:
- test_create_serve_app_with_flow_dir_skips_startup_paths_uses_store:
asserts build_registry_from_paths NOT called when flow_dir is set;
flow comes from store via warm_from_store instead.
- test_create_serve_app_without_flow_dir_loads_startup_paths_from_files:
asserts flow IS loaded when no flow_dir.
- test_serve_command_does_not_set_startup_paths_when_flow_dir_set:
asserts LFX_SERVE_STARTUP_PATHS is [] when flow_dir is set.
- test_serve_command_allows_py_with_multiple_workers_no_flow_dir:
asserts .py + workers>1 + no flow_dir is allowed and path appears in env.
* test(serve): remove duplicate test and fix mislabeled test name
- Remove test_create_serve_app_loads_startup_paths: functionally identical
to test_create_serve_app_without_flow_dir_loads_startup_paths_from_files
(same setup, same assertions, added in a prior session).
- Rename test_verify_api_key_header_takes_precedence to
test_verify_api_key_query_param_takes_precedence: the test passes the
correct key as the query_param argument and the wrong key as header_param,
then asserts the query param value is returned — query param has precedence,
not header. The old name was the opposite of what the test actually checks.
* [autofix.ci] apply automated fixes
* fix(serve): fix concurrent request isolation and startup UX
Three bug fixes discovered via live QA:
1. Component.__deepcopy__ produced shallow copies of _outputs_map,
_edges, and _components, causing concurrent requests to share the
same Output objects across graph copies. Output.cache=True by
default: the first request's output.value was served to all
subsequent concurrent requests, producing identical responses for
different inputs. Fixed by deep-copying all three fields with memo.
2. POST /flows/upload/ without an explicit id used uuid5(content),
which is deterministic — two uploads of any flow with the same
nodes/edges structure always generated the same UUID and the second
always returned 409. Fixed by using uuid4() (random).
3. Startup CLI emitted emoji and exclamation-point decoration in the
serve banner and verbose output. Replaced with plain text.
* [autofix.ci] apply automated fixes
* fix(serve): address PR review comments
- Move 409 conflict check before load_flow_from_json+prepare so callers
with an explicit id don't pay the parse cost for a flow that will be
rejected anyway
- Remove unreachable FlowAlreadyRegisteredError catch: no await exists
between registry.get() and registry.add(), so asyncio cannot
interleave another request in that window
- Update FlowMeta.id and UploadFlowResponse.id field descriptions from
stale "Deterministic UUIDv5" to plain "Flow identifier (UUID)"
* test(serve): fix upload duplicate/replace tests after uuid4 change
Both tests relied on uuid5(content) determinism to generate the same
flow ID from identical data. With uuid4, each upload gets a fresh ID,
so duplicate detection was never triggered. Tests now supply an
explicit id field to exercise the 409/replace paths directly.
* fix(serve): address code review suggestions
- Fix test_write_is_atomic to glob for any *.tmp files rather than
checking a hardcoded filename that could never exist
- Remove incorrect noqa: B904 from validate_id_is_uuid (not in an
except block); use `from None` instead
- Add TTL cache (1 s) for FlowStore.list_ids() in FlowRegistry to
avoid one filesystem glob per /health request; mutations invalidate
immediately, warm_from_store and delete-scan bypass it
- Document the UUID/stem dual-key alias invariant on _flows
- Add test verifying create_serve_app() propagates load errors from
the ThreadPoolExecutor startup path (fail fast, not silent empty registry)
* [autofix.ci] apply automated fixes
* fix: lfx serve no_env_fallback, deepcopy cycle, cross-worker delete (#13297)
fix: address concurrency and isolation issues in lfx serve
- Re-apply no_env_fallback to the per-request graph copy in the run and
stream endpoints; deepcopy() drops graph.context so the flag was lost.
- Register the copy in memo before recursive deepcopy calls in
Component.__deepcopy__ so reference cycles are preserved.
- Check the store directly in list_metas() so cross-worker deletes are
reflected immediately instead of after the TTL cache window.
- Remove the redundant upload-conflict test (already covered by
test_upload_duplicate_without_replace_returns_409).
Also mark test secrets with allowlist pragmas and fix two docstring
lint warnings in the touched test file.
* [autofix.ci] apply automated fixes
* feat(lfx): WXO/TRM request-scoped variables for lfx serve (child of #13121) (#13327)
* feat(lfx): WXO/TRM request-scoped variables for lfx serve global_vars
Align lfx serve with the TRM/WXO credential contract on top of #13121:
- Parse LANGFLOW_REQUEST_VARIABLES from global_vars payloads
- ContextVar-backed lookup in VariableService (no os.environ mutation)
- Activate request scope during execute_graph_with_capture
Child of add-workers-and-upload-lfx (PR #13121).
* [autofix.ci] apply automated fixes
* fix(lfx): harden request-scoped variable resolution + add isolation t… (#13347)
fix(lfx): harden request-scoped variable resolution + add isolation tests
Addresses PR review of WXO/TRM request-scoped variables for lfx serve:
- service: honor no_env_fallback in VariableService so model/KB credential
resolution no longer falls back to os.environ under --no-env-fallback;
correct the get_variable precedence docstring (5-step interleaved order)
- request_scope: add a per-request no_env_fallback ContextVar; use precise
Token type; document the None-vs-empty scope contract
- common: activate/reset the no_env_fallback scope from graph.context
- runtime_variables: log (instead of silently swallowing) a malformed or
non-object LANGFLOW_REQUEST_VARIABLES blob; anchor the override comment to
the TRM contract
Tests:
- add request-scope concurrency isolation + no-env-fallback suite
- add execute_graph_with_capture wiring test (graph.context -> ContextVars)
- give TestGraphExecution mock graphs a real context, fixing 11 tests that
broke once the helper began reading graph.context
* [autofix.ci] apply automated fixes
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com>
* Update use of stem alias tracking to ensure proper deletes
* fix(lfx): enforce no_env_fallback across all credential resolution paths
Closes credential-isolation gaps in lfx serve and hardens request-scoped
variable handling:
- VariableService: skip the process-wide LANGFLOW_REQUEST_VARIABLES env read
when env fallback is disabled (previously leaked process credentials for an
empty-global_vars request despite --no-env-fallback).
- Unified models: gate os.environ reads in get_api_key_for_provider and the
connection-config fallbacks in instantiation.py (WatsonX URL/project-id,
Ollama base-url, OpenRouter headers) on the no-env-fallback flag.
- run_until_complete: propagate the caller's contextvars into the worker
thread so request-scoped vars and the flag survive the thread hop.
- Request variables: serialize structured (dict/list) values as valid JSON and
drop nulls instead of producing lossy Python reprs or a truthy "None".
- FlowRegistry: extract the duplicated store-alias scan shared by add/remove.
Adds tests for request-scope isolation, credential/env gating, and contextvar
propagation.
* fix(lfx): close no_env_fallback credential leaks and harden serve isolation
- credentials: honor no_env_fallback in get_all_variables_for_provider so served
flows no longer leak process env (was defeating the _env_if_allowed guards in
instantiation.py via pre-filled provider_vars)
- loading: apply the no_env_fallback contract to table load_from_db column
resolution (sibling of load_from_env_vars, previously still read os.getenv)
- variable service: resolve request-scoped vars (exact name + x-langflow-global-var
alias) before env fallback so a caller's per-request credential is never shadowed
by a same-named process env var
- runtime_variables: flatten the LANGFLOW_REQUEST_VARIABLES blob into graph context
for parity between the ContextVar and load_from_db resolution paths
- flow_store: clean up the partial temp file when an atomic write fails
- commands: on multi-worker shutdown remove only the serve env vars we set, not the
whole LFX_SERVE_* namespace
- components: add check_component_env_writes lint banning os.environ writes; stop the
mem0 component from writing the OpenAI key into the process environment
* fix(lfx): contain corrupt flow-store files so one bad file can't crash worker startup
FilesystemFlowStore.read() only caught FileNotFoundError, so a corrupted/partial
{id}.json in a shared store (PVC corruption, an external writer killed mid-write)
raised JSONDecodeError. warm_from_store() calls get() for every store id at startup
with no per-file guard, so a single bad file would crash every worker's startup and
500 GET /flows.
- read(): catch JSONDecodeError/OSError/UnicodeDecodeError, log, return None (treat
as absent) so warm_from_store/list_metas/get skip it and other flows keep serving.
- warm_from_store(): skip+log a flow that fails to reconstruct (corrupt JSON, or valid
JSON referencing a component not available in this build) instead of aborting startup.
- tests: corrupt-JSON read returns None; warm_from_store skips unloadable flows.
* fix test -- request wins over env now
* fix test. mock graph context correctly
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@logspace.ai>
Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com>
227 lines
9.9 KiB
Python
Executable File
227 lines
9.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""CI guard: components must not WRITE to ``os.environ``.
|
|
|
|
``os.environ`` is process-global. A warm, long-lived serving process (``lfx serve``'s
|
|
worker pool, or the main Langflow server) handles many callers' requests in one
|
|
process, so a per-request write like ``os.environ["OPENAI_API_KEY"] = caller_key``
|
|
bleeds into other requests on that worker and persists until overwritten — leaking
|
|
one caller's credential to another. (The bleed is per-worker: uvicorn ``--workers N``
|
|
are separate processes, but every concurrent request inside one worker shares its
|
|
``os.environ``.)
|
|
|
|
READS are fine — this guard only flags WRITES. Pass per-request values through the
|
|
component's config/params instead of mutating the environment.
|
|
|
|
This walks every component source file (stdlib AST only — zero dependency on the lfx
|
|
package, since CI may run before it is importable) and flags:
|
|
|
|
os.environ[k] = v os.environ = {...} del os.environ[k]
|
|
os.environ.update(...) os.environ.setdefault(...) .pop/.clear/.popitem
|
|
os.putenv(...) os.unsetenv(...) load_dotenv(...)
|
|
|
|
Usage::
|
|
|
|
python scripts/lint/check_component_env_writes.py # scan default roots
|
|
python scripts/lint/check_component_env_writes.py FILE... # check specific files
|
|
python scripts/lint/check_component_env_writes.py --root path/to/components
|
|
|
|
Exit codes:
|
|
0 -- no disallowed env writes
|
|
1 -- one or more disallowed env writes (or a file failed to parse)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# Component source roots (matches scripts/migrate/check_bare_names.py coverage).
|
|
DEFAULT_ROOTS = (
|
|
"src/lfx/src/lfx/components",
|
|
"src/backend/base/langflow/components",
|
|
"src/bundles",
|
|
)
|
|
|
|
# Deliberate, reviewed exceptions: repo-relative path -> set of allowed violation kinds.
|
|
# Keep this list tiny and justified; an entry here means "this component is knowingly
|
|
# allowed to mutate the process environment". Prefer fixing the component over adding one.
|
|
ALLOWLIST: dict[str, set[str]] = {
|
|
# The "Dotenv" component's entire purpose is to load a user-supplied .env blob into
|
|
# the process environment. It is a known multi-tenant hazard flagged for separate
|
|
# review; allow only the load_dotenv call, still flagging any direct os.environ write.
|
|
"src/lfx/src/lfx/components/datastax/dotenv.py": {"load_dotenv"},
|
|
}
|
|
|
|
_MUTATING_METHODS = {"setdefault", "update", "pop", "clear", "popitem", "__setitem__", "__delitem__"}
|
|
|
|
|
|
class _EnvWriteVisitor(ast.NodeVisitor):
|
|
"""Flags writes to ``os.environ`` (and ``load_dotenv()``), import-alias-aware.
|
|
|
|
A first pass over the tree collects local names that refer to ``os``,
|
|
``os.environ``, ``os.putenv``, ``os.unsetenv`` and ``dotenv.load_dotenv``
|
|
(including ``as``-aliases), so ``import os as system; system.environ[X]=Y``
|
|
and ``from dotenv import load_dotenv as ld; ld()`` are caught.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.violations: list[tuple[int, str, str]] = [] # (lineno, kind, snippet)
|
|
self.os_aliases: set[str] = {"os"}
|
|
self.environ_aliases: set[str] = {"environ"}
|
|
self.putenv_aliases: set[str] = set()
|
|
self.unsetenv_aliases: set[str] = set()
|
|
self.load_dotenv_aliases: set[str] = {"load_dotenv"}
|
|
|
|
# First pass: collect import aliases module-wide (over-broad on scope is fine for a
|
|
# security lint — we'd rather flag a write that imports `os` locally than miss it).
|
|
def collect_aliases(self, tree: ast.AST) -> None:
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name == "os":
|
|
self.os_aliases.add(alias.asname or alias.name)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.module == "os":
|
|
for alias in node.names:
|
|
local = alias.asname or alias.name
|
|
if alias.name == "environ":
|
|
self.environ_aliases.add(local)
|
|
elif alias.name == "putenv":
|
|
self.putenv_aliases.add(local)
|
|
elif alias.name == "unsetenv":
|
|
self.unsetenv_aliases.add(local)
|
|
elif node.module == "dotenv":
|
|
for alias in node.names:
|
|
if alias.name == "load_dotenv":
|
|
self.load_dotenv_aliases.add(alias.asname or alias.name)
|
|
|
|
def _is_environ(self, node: ast.AST) -> bool:
|
|
"""True for ``<os_alias>.environ`` or a bare ``<environ_alias>``."""
|
|
if isinstance(node, ast.Attribute):
|
|
return node.attr == "environ" and isinstance(node.value, ast.Name) and node.value.id in self.os_aliases
|
|
return isinstance(node, ast.Name) and node.id in self.environ_aliases
|
|
|
|
def _flag(self, lineno: int, kind: str, snippet: str) -> None:
|
|
self.violations.append((lineno, kind, snippet))
|
|
|
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Subscript) and self._is_environ(target.value):
|
|
self._flag(node.lineno, "assign", "os.environ[...] = ...")
|
|
elif self._is_environ(target):
|
|
self._flag(node.lineno, "rebind", "os.environ = ...")
|
|
self.generic_visit(node)
|
|
|
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
if isinstance(node.target, ast.Subscript) and self._is_environ(node.target.value):
|
|
self._flag(node.lineno, "assign", "os.environ[...]: ... = ...")
|
|
self.generic_visit(node)
|
|
|
|
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
target = node.target
|
|
if (isinstance(target, ast.Subscript) and self._is_environ(target.value)) or self._is_environ(target):
|
|
self._flag(node.lineno, "assign", "os.environ augmented assignment")
|
|
self.generic_visit(node)
|
|
|
|
def visit_Delete(self, node: ast.Delete) -> None:
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Subscript) and self._is_environ(target.value):
|
|
self._flag(node.lineno, "del", "del os.environ[...]")
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
func = node.func
|
|
if isinstance(func, ast.Attribute):
|
|
if func.attr in _MUTATING_METHODS and self._is_environ(func.value):
|
|
self._flag(node.lineno, f"mutate:{func.attr}", f"os.environ.{func.attr}(...)")
|
|
elif (
|
|
func.attr in {"putenv", "unsetenv"}
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id in self.os_aliases
|
|
):
|
|
self._flag(node.lineno, func.attr, f"os.{func.attr}(...)")
|
|
elif func.attr == "load_dotenv":
|
|
self._flag(node.lineno, "load_dotenv", "load_dotenv(...)")
|
|
elif isinstance(func, ast.Name):
|
|
if func.id in self.putenv_aliases:
|
|
self._flag(node.lineno, "putenv", f"{func.id}(...)")
|
|
elif func.id in self.unsetenv_aliases:
|
|
self._flag(node.lineno, "unsetenv", f"{func.id}(...)")
|
|
elif func.id in self.load_dotenv_aliases:
|
|
self._flag(node.lineno, "load_dotenv", f"{func.id}(...)")
|
|
self.generic_visit(node)
|
|
|
|
|
|
def _iter_py_files(roots: list[Path]) -> list[Path]:
|
|
files: list[Path] = []
|
|
for root in roots:
|
|
if not root.exists():
|
|
continue
|
|
for path in sorted(root.rglob("*.py")):
|
|
parts = path.parts
|
|
if "tests" in parts or path.name.startswith("test_"):
|
|
continue
|
|
files.append(path)
|
|
return files
|
|
|
|
|
|
def _check_file(path: Path) -> list[tuple[str, int, str, str]]:
|
|
try:
|
|
rel = path.resolve().relative_to(REPO_ROOT).as_posix()
|
|
except ValueError:
|
|
rel = path.as_posix()
|
|
try:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
except (OSError, SyntaxError) as exc:
|
|
return [(rel, getattr(exc, "lineno", 0) or 0, "parse-error", str(exc))]
|
|
visitor = _EnvWriteVisitor()
|
|
visitor.collect_aliases(tree)
|
|
visitor.visit(tree)
|
|
allowed = ALLOWLIST.get(rel, set())
|
|
out: list[tuple[str, int, str, str]] = []
|
|
for lineno, kind, snippet in visitor.violations:
|
|
if kind in allowed:
|
|
continue
|
|
out.append((rel, lineno, kind, snippet))
|
|
return out
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("files", nargs="*", help="Specific files to check (default: scan component roots).")
|
|
parser.add_argument("--root", action="append", default=None, help="Override component root(s) to scan.")
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.files:
|
|
targets = [Path(f).resolve() for f in args.files if f.endswith(".py")]
|
|
else:
|
|
roots = [Path(r) if Path(r).is_absolute() else REPO_ROOT / r for r in (args.root or DEFAULT_ROOTS)]
|
|
targets = _iter_py_files(roots)
|
|
|
|
violations: list[tuple[str, int, str, str]] = []
|
|
for path in targets:
|
|
violations.extend(_check_file(path))
|
|
|
|
if not violations:
|
|
return 0
|
|
|
|
print("ERROR: component(s) write to os.environ (process-global — bleeds across requests")
|
|
print(" in a shared serving process such as `lfx serve`):\n")
|
|
for rel, lineno, kind, snippet in sorted(violations):
|
|
print(f" {rel}:{lineno} [{kind}] {snippet}")
|
|
print(
|
|
"\nFix: pass per-request values through the component's config/params, not the\n"
|
|
"process environment (reads are fine; only writes bleed). If a write is a\n"
|
|
"deliberate, reviewed exception, add it to ALLOWLIST in\n"
|
|
"scripts/lint/check_component_env_writes.py with a justification."
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|