From 2bdf783c9474900f7870ec212ba8ccde0b0809a6 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 14 Jul 2026 16:33:49 +0200 Subject: [PATCH] fix: Make sitecustomize the single bootstrap path and give every wildedge run flag real behavior --- CHANGELOG.md | 34 +++++++ CONTRIBUTING.md | 3 +- README.md | 3 +- docs/configuration.md | 4 +- docs/deployment.md | 101 +++++++++++++++++++ tests/test_autoload.py | 150 +++++++++++++++++++++++++++++ tests/test_cli.py | 72 +++----------- tests/test_client.py | 12 ++- wildedge/autoload/sitecustomize.py | 65 ++++++++++++- wildedge/cli.py | 9 ++ wildedge/client.py | 13 ++- wildedge/constants.py | 1 + wildedge/runtime/bootstrap.py | 6 ++ wildedge/runtime/runner.py | 73 -------------- wildedge/settings.py | 2 + 15 files changed, 406 insertions(+), 142 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/deployment.md delete mode 100644 wildedge/runtime/runner.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ce7ad4f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +Notable changes to wildedge-sdk. Every behavior change lands here; entries go +under Unreleased and move into a version section at release time. + +## Unreleased + +### Added + +- Process-wide default client: `wildedge.get_client()`, `wildedge.set_default_client()`, and module-level `trace`, `span`, `track_span`, `register_model`, `flush` delegating to it (#41) +- `init()` reuses the default client installed by `wildedge run` unless `dsn` is passed, so CLI and in-process init share one client (#41) +- `SpanContextManager.set_attributes()` and `fail()` for recording outcomes on an open span (#41) +- `wildedge run --strict` (env `WILDEDGE_STRICT`): exit with a reserved code instead of running untracked when bootstrap fails (120 config error, 122 internal error) +- Lazy default-client creation honors `WILDEDGE_INTEGRATIONS` and `WILDEDGE_HUBS` from the environment (#41) +- `docs/deployment.md`: the deployment contract (no-DSN behavior, strict mode, fork/exec servers, one client per process) + +### Changed + +- `--strict-integrations` now takes effect: a failed required integration exits the process with code 121. Previously the enforcing code path was never invoked, so the flag was silently ignored. +- `--print-startup-report` and `--no-propagate` now work under `wildedge run`; both were only wired to the unused runner path before. +- Constructing a client without a DSN logs once per process at INFO. Previously every construction logged a WARNING. + +### Removed + +- `wildedge.runtime.runner`: an alternative bootstrap entry point that `wildedge run` never invoked. `sitecustomize` is the single bootstrap path; the runner's exit codes moved to `wildedge.runtime.bootstrap` and now apply under `--strict`. + +## 0.1.5 - 2026-06-23 + +- Opt-in inference attachments (raw input/output upload) +- Fixed accelerator detection wiring; macOS CPU frequency and thermal sampling + +## 0.1.4 and earlier + +Predate this changelog; see the git history. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5de252b..baa954d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,8 @@ 1. Fork the repository and create a feature branch off `devel`. 2. Make your changes and ensure tests pass. 3. Update documentation if needed. -4. Submit a pull request targeting `devel` with a clear description of the changes. +4. Add a `CHANGELOG.md` entry under Unreleased for any user-visible or behavioral change. +5. Submit a pull request targeting `devel` with a clear description of the changes. ## Release process diff --git a/README.md b/README.md index 4c95621..0d3f1e9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ Useful flags: | `--integrations` | Comma-separated list of integrations to activate (or `all`) | | `--hubs` | Hub trackers to activate: `huggingface`, `torchhub` | | `--print-startup-report` | Print per-integration status at startup | -| `--strict-integrations` | Fail if a requested integration can't be loaded | +| `--strict-integrations` | Exit (code 121) if a requested integration can't be instrumented | +| `--strict` | Exit (120 config, 122 internal) instead of running untracked when bootstrap fails | | `--attachments` | Enable opt-in raw input/output attachment upload | | `--no-propagate` | Don't pass WildEdge env vars to child processes | diff --git a/docs/configuration.md b/docs/configuration.md index e3cf982..2487ebe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,8 @@ # Configuration -Full reference for all `WildEdge` client parameters. +Full reference for all `WildEdge` client parameters. For runtime behavior +(no-DSN mode, strict mode and exit codes, forking servers), see +[Deployment](deployment.md). ## Core diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..16e49aa --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,101 @@ +# Deployment + +How the SDK behaves in real process models: servers that fork, workers that +exec, build steps, and processes with no DSN configured. Everything on this +page is a contract the SDK keeps, not an implementation detail. + +## How initialization happens + +There are three ways a process gets its client, in order of coverage: + +| Mode | Instrumentation coverage | Use when | +|---|---|---| +| `wildedge run -- ` | Guaranteed: patches before any user code runs | You control the start command | +| `wildedge.init(...)` at startup | Everything created after the call | You control application code | +| Lazy, from the first module-level call | Best effort: only what is created afterwards | Manual tracking only | + +All three produce the same thing: one process-wide default client that +`wildedge.span()`, `wildedge.register_model()` and the rest delegate to. +`init()` without `dsn` reuses whatever client already exists, so code written +for in-process init runs unchanged under `wildedge run`. + +The lazy mode configures itself from the environment: `WILDEDGE_DSN`, +`WILDEDGE_INTEGRATIONS`, `WILDEDGE_HUBS`. Unset integration variables mean +nothing is patched; auto-instrumentation is always opt-in. + +## The no-DSN contract + +Without `WILDEDGE_DSN`, the client is a no-op: no background threads, no +network, no patched frameworks, every event dropped. This is a supported mode +for development and CI, not an error; the SDK logs one INFO line per process +and stays quiet. Leave the SDK integrated and unset the variable wherever you +do not want telemetry (local dev, test runs, build and migration steps). + +Under `wildedge run`, a missing DSN warns on stderr and your program runs +untracked. Telemetry failing must not take production down; that is the +default policy. + +## Strict mode + +`wildedge run --strict` inverts the failure policy for deployments where +running unobserved is worse than not running. Bootstrap failures then +terminate the process before your program starts: + +| Exit code | Meaning | +|---|---| +| 120 | Configuration error (missing or invalid DSN) | +| 121 | A requested integration could not be instrumented (requires `--strict-integrations`) | +| 122 | Internal bootstrap error | + +`--strict-integrations` is its own opt-in and exits with 121 on failure even +without `--strict`; asking for it is the request to fail. + +## Forking and multi-process servers + +`wildedge run` prepends `wildedge/autoload/` to `PYTHONPATH` and replaces +itself with your command. Every Python interpreter that starts under it runs +the bundled `sitecustomize.py`, which bootstraps the runtime before any user +code. Two mechanisms make this safe across worker models: + +- Fresh interpreters (exec or spawn) bootstrap themselves via sitecustomize; + a `sys.modules` marker prevents double initialization within one + interpreter. +- Forked children inherit the parent's initialized runtime, and + `os.register_at_fork` hooks stop the SDK's background threads before each + fork and start fresh ones in parent and child afterward, so forked workers + transmit normally instead of inheriting dead threads. + +How that plays out on common servers: + +| Server | Worker model | Behavior under `wildedge run` | +|---|---|---| +| gunicorn (sync/gthread, with or without `preload_app`) | fork from master | Master bootstraps once; each worker gets fresh SDK threads via the at-fork hooks | +| uvicorn (`--workers`, `--reload`) | spawn / exec | Every worker is a fresh interpreter and bootstraps itself | +| granian | spawned worker processes | Each worker bootstraps itself | +| daphne | single process | One bootstrap, nothing special | +| waitress | single process, thread pool | One bootstrap; the client is thread-safe | +| celery (prefork pool) | fork from master | Same as gunicorn: at-fork hooks restart threads per worker | + +For managed platforms, wrap only the serving command. Build steps and +migrations run in separate processes that gain nothing from telemetry; leave +them unwrapped or unset `WILDEDGE_DSN` there. + +## One client per process + +- Framework patches are installed at most once per process; the first client + to instrument owns the patch. +- `init()` reuses the existing default client unless you pass `dsn` + explicitly, so the CLI-installed client and application code share one + client instead of racing. +- Trace and span correlation lives in contextvars at module level, not on a + client. Auto-instrumented events emitted inside `wildedge.trace(...)` / + `wildedge.span(...)` blocks correlate into the same trace no matter which + client emits them. + +## Environment propagation + +By default, `wildedge run` leaves its `WILDEDGE_*` variables in the +environment so that exec'd children (reload workers) can bootstrap. Pass +`--no-propagate` to have each bootstrapped process scrub the run-scoped +variables after initialization, keeping them away from nested subprocesses +you spawn yourself. diff --git a/tests/test_autoload.py b/tests/test_autoload.py index d16c04a..578af6a 100644 --- a/tests/test_autoload.py +++ b/tests/test_autoload.py @@ -176,3 +176,153 @@ def test_uvicorn_reload_worker_bootstraps(tmp_path): ) assert result.returncode == 0, result.stderr assert "installed: True" in result.stdout + + +def test_strict_missing_dsn_exits_120(monkeypatch): + from wildedge.runtime.bootstrap import RuntimeConfigError + + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.setenv("WILDEDGE_STRICT", "1") + monkeypatch.delenv("WILDEDGE_DSN", raising=False) + + with ( + patch( + "wildedge.runtime.bootstrap.install_runtime", + side_effect=RuntimeConfigError("WILDEDGE_DSN must be set"), + ), + patch("os._exit") as fake_exit, + ): + _reload_sitecustomize() + + fake_exit.assert_called_once_with(120) + + +def test_strict_integration_error_exits_121_without_strict_env(monkeypatch): + """--strict-integrations is its own opt-in; it exits even without --strict.""" + from wildedge.runtime.bootstrap import RuntimeStrictIntegrationError + + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.delenv("WILDEDGE_STRICT", raising=False) + + with ( + patch( + "wildedge.runtime.bootstrap.install_runtime", + side_effect=RuntimeStrictIntegrationError("strict fail"), + ), + patch("os._exit") as fake_exit, + ): + _reload_sitecustomize() + + fake_exit.assert_called_once_with(121) + + +def test_strict_internal_error_exits_122(monkeypatch): + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.setenv("WILDEDGE_STRICT", "1") + + with ( + patch( + "wildedge.runtime.bootstrap.install_runtime", + side_effect=RuntimeError("boom"), + ), + patch("os._exit") as fake_exit, + ): + _reload_sitecustomize() + + fake_exit.assert_called_once_with(122) + + +def test_non_strict_config_error_warns_and_continues(monkeypatch, capsys): + from wildedge.runtime.bootstrap import RuntimeConfigError + + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.delenv("WILDEDGE_STRICT", raising=False) + + with patch( + "wildedge.runtime.bootstrap.install_runtime", + side_effect=RuntimeConfigError("no dsn"), + ): + _reload_sitecustomize() # must not raise + + assert "running without telemetry" in capsys.readouterr().err + + +def test_startup_report_printed_from_env(monkeypatch, capsys): + class FakeContext: + debug = False + print_startup_report = False + integration_statuses: list = [] + + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.setenv("WILDEDGE_PRINT_STARTUP_REPORT", "1") + + with ( + patch( + "wildedge.runtime.bootstrap.install_runtime", + return_value=FakeContext(), + ), + patch( + "wildedge.runtime.bootstrap.format_startup_report", + return_value="report-text", + ), + ): + _reload_sitecustomize() + + assert "report-text" in capsys.readouterr().err + + +def test_no_propagate_clears_runtime_env(monkeypatch): + import os + + class FakeContext: + debug = False + print_startup_report = False + + monkeypatch.delitem(sys.modules, _MARKER, raising=False) + monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") + monkeypatch.setenv("WILDEDGE_PROPAGATE", "0") + monkeypatch.setenv("WILDEDGE_INTEGRATIONS", "all") + + with patch( + "wildedge.runtime.bootstrap.install_runtime", + return_value=FakeContext(), + ): + _reload_sitecustomize() + + assert "WILDEDGE_AUTOLOAD" not in os.environ + assert "WILDEDGE_INTEGRATIONS" not in os.environ + + +def test_strict_missing_dsn_exit_code_in_real_interpreter(tmp_path): + """sys.exit inside sitecustomize must terminate interpreter startup with our code.""" + import os + import pathlib + + autoload_dir = str( + pathlib.Path( + importlib.util.find_spec("wildedge.autoload.sitecustomize").origin + ).parent + ) + + env = os.environ.copy() + env["WILDEDGE_AUTOLOAD"] = "1" + env["WILDEDGE_STRICT"] = "1" + env.pop("WILDEDGE_DSN", None) + pythonpath = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = autoload_dir + (os.pathsep + pythonpath if pythonpath else "") + + result = subprocess.run( + [sys.executable, "-c", "print('should not run')"], + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 120, (result.returncode, result.stderr) + assert "should not run" not in result.stdout + assert "WILDEDGE_DSN must be set" in result.stderr diff --git a/tests/test_cli.py b/tests/test_cli.py index 1d2ba1f..0dd405c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,6 @@ from wildedge import cli, constants from wildedge.integrations.registry import IntegrationSpec from wildedge.runtime import bootstrap -from wildedge.runtime import runner as runtime_runner def _fake_execle(captured: dict): @@ -347,26 +346,6 @@ def test_doctor_uses_app_identity_override_for_namespace(monkeypatch, capsys): assert str(Path("my-app") / "dead_letters") in out -def test_runner_clears_runtime_env_when_no_propagate(monkeypatch): - class FakeContext: - debug = False - print_startup_report = False - - def shutdown(self): # type: ignore[no-untyped-def] - return None - - monkeypatch.setenv(constants.ENV_PROPAGATE, "0") - monkeypatch.setenv(constants.ENV_DSN, "https://secret@ingest.wildedge.dev/key") - monkeypatch.setenv(constants.ENV_INTEGRATIONS, "all") - monkeypatch.setattr(runtime_runner, "install_runtime", lambda: FakeContext()) - monkeypatch.setattr(runtime_runner.runpy, "run_path", lambda *a, **k: None) - - rc = runtime_runner.main(["--mode", "script", "--target", "app.py"]) - assert rc == 0 - assert constants.WILDEDGE_AUTOLOAD not in os.environ - assert constants.ENV_INTEGRATIONS not in os.environ - - def test_install_runtime_tracks_missing_dependency_status(monkeypatch): class FakeWildEdge: def __init__(self, *, dsn, app_version, debug, sampling_interval_s=None): # type: ignore[no-untyped-def] @@ -404,48 +383,23 @@ def close(self): # type: ignore[no-untyped-def] context.shutdown() -def test_runner_returns_reserved_exit_codes(monkeypatch, capsys): - monkeypatch.setattr( - runtime_runner, - "install_runtime", - lambda: (_ for _ in ()).throw(bootstrap.RuntimeConfigError("bad config")), - ) - assert runtime_runner.main(["--mode", "script", "--target", "app.py"]) == 120 - - monkeypatch.setattr( - runtime_runner, - "install_runtime", - lambda: (_ for _ in ()).throw( - bootstrap.RuntimeStrictIntegrationError("strict fail") - ), - ) - assert runtime_runner.main(["--mode", "script", "--target", "app.py"]) == 121 - - monkeypatch.setattr( - runtime_runner, - "install_runtime", - lambda: (_ for _ in ()).throw(RuntimeError("boom")), - ) - assert runtime_runner.main(["--mode", "script", "--target", "app.py"]) == 122 - assert "wildedge:" in capsys.readouterr().err - +def test_run_exports_strict_env(monkeypatch): + captured: dict = {} -def test_runner_prints_startup_report_when_enabled(monkeypatch, capsys): - class FakeContext: - debug = False - print_startup_report = True - integration_statuses = [] + def fake_execle(path, *args): + captured["env"] = args[-1] + raise SystemExit(0) - def shutdown(self): # type: ignore[no-untyped-def] - return None + monkeypatch.setattr(cli.os, "execle", fake_execle) + monkeypatch.setattr(cli.shutil, "which", lambda _: "/usr/bin/true") - monkeypatch.setattr(runtime_runner, "install_runtime", lambda: FakeContext()) - monkeypatch.setattr(runtime_runner, "format_startup_report", lambda _: "report") - monkeypatch.setattr(runtime_runner.runpy, "run_path", lambda *a, **k: None) + with pytest.raises(SystemExit): + cli.main(["run", "--strict", "--", "true"]) + assert captured["env"][constants.ENV_STRICT] == "1" - rc = runtime_runner.main(["--mode", "script", "--target", "app.py"]) - assert rc == 0 - assert "report" in capsys.readouterr().err + with pytest.raises(SystemExit): + cli.main(["run", "--", "true"]) + assert captured["env"][constants.ENV_STRICT] == "0" def test_parse_run_args_without_double_dash(): diff --git a/tests/test_client.py b/tests/test_client.py index 3417ba8..56a2a2b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -32,15 +32,23 @@ def test_batch_size_too_low(): def test_no_dsn_is_noop(monkeypatch, caplog): + import wildedge.client as client_module from wildedge.client import WildEdge monkeypatch.delenv(constants.ENV_DSN, raising=False) - with caplog.at_level("WARNING"): + monkeypatch.setattr(client_module, "_noop_notice_logged", False) + with caplog.at_level("INFO"): client = WildEdge() assert client.noop is True assert client.closed is True - assert "no DSN configured" in caplog.text + notices = [r for r in caplog.records if "no DSN configured" in r.message] + assert [r.levelname for r in notices] == ["INFO"] + + caplog.clear() + with caplog.at_level("INFO"): + WildEdge() + assert "no DSN configured" not in caplog.text def test_no_dsn_instrument_does_not_raise(monkeypatch): diff --git a/wildedge/autoload/sitecustomize.py b/wildedge/autoload/sitecustomize.py index da87129..e3a9dbd 100644 --- a/wildedge/autoload/sitecustomize.py +++ b/wildedge/autoload/sitecustomize.py @@ -4,6 +4,13 @@ prepended to PYTHONPATH (by `wildedge run`). Calls install_runtime() before any user code runs, enabling framework instrumentation and fork-safe operation for gunicorn, celery, and other pre-fork servers. + +This is the only bootstrap path for `wildedge run`. Failure policy: by +default a bootstrap failure (missing DSN, config error, internal error) warns +on stderr and the wrapped program runs untracked. With WILDEDGE_STRICT set +(`wildedge run --strict`) the process exits instead: 120 for config errors, +122 for internal errors. A strict-integration failure exits 121 whenever +`--strict-integrations` was requested, since that flag is itself the opt-in. """ from __future__ import annotations @@ -22,6 +29,14 @@ # sys.modules is process-local and survives module re-execution. _INSTALLED_MARKER = "wildedge.__autoload_installed__" + +def _die(code: int) -> None: + # sys.exit cannot be used during sitecustomize import: site.py reports the + # SystemExit as a fatal startup error with a traceback and exit code 1. + sys.stderr.flush() + os._exit(code) + + if _INSTALLED_MARKER not in sys.modules: # Activate if the CLI launched this process, or if WILDEDGE_DSN is set # and the user has manually prepended wildedge/autoload/ to PYTHONPATH. @@ -29,11 +44,53 @@ # Set the guard before importing wildedge to prevent re-entry. sys.modules[_INSTALLED_MARKER] = True # type: ignore[assignment] try: - from wildedge.runtime.bootstrap import install_runtime # noqa: PLC0415 + from wildedge.runtime.bootstrap import ( # noqa: PLC0415 + EXIT_BOOTSTRAP_INTERNAL_ERROR, + EXIT_CONFIG_ERROR, + EXIT_STRICT_INTEGRATION_ERROR, + RuntimeConfigError, + RuntimeStrictIntegrationError, + clear_runtime_env, + format_startup_report, + install_runtime, + ) + from wildedge.settings import read_runner_env # noqa: PLC0415 + + _runner_env = read_runner_env() + _context = None + try: + # Don't install signal handlers: the host process (gunicorn, + # celery, etc.) manages SIGTERM/SIGINT itself. + _context = install_runtime(install_signal_handlers=False) + except RuntimeStrictIntegrationError as exc: + # Only raised when --strict-integrations was requested. + print(f"wildedge: {exc}", file=sys.stderr) + _die(EXIT_STRICT_INTEGRATION_ERROR) + except RuntimeConfigError as exc: + if _runner_env.strict: + print(f"wildedge: {exc}", file=sys.stderr) + _die(EXIT_CONFIG_ERROR) + print( + f"wildedge: {exc}; running without telemetry", + file=sys.stderr, + ) + except Exception as exc: + if _runner_env.strict: + print( + f"wildedge: bootstrap internal error: {exc}", + file=sys.stderr, + ) + _die(EXIT_BOOTSTRAP_INTERNAL_ERROR) + print(f"wildedge: bootstrap failed: {exc}", file=sys.stderr) - # Don't install signal handlers: the host process (gunicorn, celery, - # etc.) manages SIGTERM/SIGINT itself. - install_runtime(install_signal_handlers=False) + if _context is not None and ( + getattr(_context, "debug", False) + or getattr(_context, "print_startup_report", False) + or _runner_env.print_startup_report + ): + print(format_startup_report(_context), file=sys.stderr) + if not _runner_env.propagate: + clear_runtime_env() except Exception as exc: # pragma: no cover print(f"wildedge: bootstrap failed: {exc}", file=sys.stderr) diff --git a/wildedge/cli.py b/wildedge/cli.py index 967b23b..cbb4710 100644 --- a/wildedge/cli.py +++ b/wildedge/cli.py @@ -78,6 +78,14 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Fail startup if any requested integration cannot be instrumented.", ) + run.add_argument( + "--strict", + action="store_true", + help=( + "Exit instead of running untracked when bootstrap fails " + "(120 config error, 122 internal error). Default: warn and run." + ), + ) propagation = run.add_mutually_exclusive_group() propagation.add_argument( "--propagate", @@ -251,6 +259,7 @@ def run_command(parsed: argparse.Namespace) -> int: env[constants.ENV_ATTACHMENTS_ENABLED] = "1" env[constants.ENV_PROPAGATE] = "1" if parsed.propagate else "0" env[constants.ENV_STRICT_INTEGRATIONS] = "1" if parsed.strict_integrations else "0" + env[constants.ENV_STRICT] = "1" if parsed.strict else "0" env[constants.ENV_PRINT_STARTUP_REPORT] = ( "1" if parsed.print_startup_report else "0" ) diff --git a/wildedge/client.py b/wildedge/client.py index 9a46357..976322f 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -109,6 +109,9 @@ def parse_dsn(dsn: str) -> tuple[str, str, str]: return parsed.username, host, project_key +# One informative log line per process for the intentional no-DSN mode. +_noop_notice_logged = False + DEFAULT_EXTRACTORS: list[BaseExtractor] = [ OnnxExtractor(), GgufExtractor(), @@ -443,12 +446,20 @@ def __init__( logger.debug("wildedge: client initialized (session=%s)", self.session_id) def _init_noop(self, *, debug: bool, device: DeviceInfo | None) -> None: + global _noop_notice_logged self.noop = True self.debug = debug self.closed = True - logger.warning( + # Running without a DSN is a supported mode (dev, CI), not a fault: + # say so once per process at info, then stay quiet. + message = ( "wildedge: no DSN configured; client is disabled (events will be dropped)" ) + if _noop_notice_logged: + logger.debug(message) + else: + logger.info(message) + _noop_notice_logged = True self.api_key = None self.device = device self.session_id = str(uuid.uuid4()) diff --git a/wildedge/constants.py b/wildedge/constants.py index 87b5afd..98776fa 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -16,6 +16,7 @@ ENV_INTEGRATIONS = "WILDEDGE_INTEGRATIONS" ENV_HUBS = "WILDEDGE_HUBS" ENV_STRICT_INTEGRATIONS = "WILDEDGE_STRICT_INTEGRATIONS" +ENV_STRICT = "WILDEDGE_STRICT" ENV_PROPAGATE = "WILDEDGE_PROPAGATE" ENV_PRINT_STARTUP_REPORT = "WILDEDGE_PRINT_STARTUP_REPORT" ENV_SAMPLING_INTERVAL = "WILDEDGE_SAMPLING_INTERVAL_S" diff --git a/wildedge/runtime/bootstrap.py b/wildedge/runtime/bootstrap.py index f94fc52..63e0ee4 100644 --- a/wildedge/runtime/bootstrap.py +++ b/wildedge/runtime/bootstrap.py @@ -28,6 +28,11 @@ STATUS_ERROR_PATCH_FAILED = "ERROR_PATCH_FAILED" STRICT_FAILURE_STATUSES = {STATUS_SKIP_MISSING_DEP, STATUS_ERROR_PATCH_FAILED} +# Exit codes used when strict mode turns a bootstrap failure fatal. +EXIT_CONFIG_ERROR = 120 +EXIT_STRICT_INTEGRATION_ERROR = 121 +EXIT_BOOTSTRAP_INTERNAL_ERROR = 122 + def clear_runtime_env() -> None: """Remove run-scoped env vars so nested processes do not inherit runtime config.""" @@ -39,6 +44,7 @@ def clear_runtime_env() -> None: constants.ENV_INTEGRATIONS, constants.ENV_HUBS, constants.ENV_STRICT_INTEGRATIONS, + constants.ENV_STRICT, constants.ENV_PROPAGATE, constants.ENV_PRINT_STARTUP_REPORT, constants.ENV_SAMPLING_INTERVAL, diff --git a/wildedge/runtime/runner.py b/wildedge/runtime/runner.py deleted file mode 100644 index bda0009..0000000 --- a/wildedge/runtime/runner.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Child process runner used by `wildedge run`.""" - -from __future__ import annotations - -import argparse -import runpy -import sys - -from wildedge.runtime.bootstrap import ( - RuntimeConfigError, - RuntimeStrictIntegrationError, - clear_runtime_env, - format_startup_report, - install_runtime, -) -from wildedge.settings import read_runner_env - -EXIT_CONFIG_ERROR = 120 -EXIT_STRICT_INTEGRATION_ERROR = 121 -EXIT_BOOTSTRAP_INTERNAL_ERROR = 122 - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="python -m wildedge.runtime.runner") - parser.add_argument("--mode", choices=["script", "module"], required=True) - parser.add_argument("--target", required=True) - parser.add_argument("args", nargs=argparse.REMAINDER) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - parsed = parser.parse_args(argv) - args = parsed.args - if args and args[0] == "--": - args = args[1:] - - try: - context = install_runtime() - except RuntimeConfigError as exc: - print(f"wildedge: {exc}", file=sys.stderr) - return EXIT_CONFIG_ERROR - except RuntimeStrictIntegrationError as exc: - print(f"wildedge: {exc}", file=sys.stderr) - return EXIT_STRICT_INTEGRATION_ERROR - except Exception as exc: - print(f"wildedge: bootstrap internal error: {exc}", file=sys.stderr) - return EXIT_BOOTSTRAP_INTERNAL_ERROR - - runner_env = read_runner_env() - if ( - getattr(context, "debug", False) - or getattr(context, "print_startup_report", False) - or runner_env.print_startup_report - ): - print(format_startup_report(context), file=sys.stderr) - - if not runner_env.propagate: - clear_runtime_env() - try: - if parsed.mode == "script": - sys.argv = [parsed.target, *args] - runpy.run_path(parsed.target, run_name="__main__") - else: - sys.argv = [parsed.target, *args] - runpy.run_module(parsed.target, run_name="__main__", alter_sys=True) - finally: - context.shutdown() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/wildedge/settings.py b/wildedge/settings.py index 138832d..6c445b8 100644 --- a/wildedge/settings.py +++ b/wildedge/settings.py @@ -35,6 +35,7 @@ class RuntimeEnv: class RunnerEnv: print_startup_report: bool propagate: bool + strict: bool def parse_bool(value: str | None) -> bool: @@ -134,4 +135,5 @@ def read_runner_env( return RunnerEnv( print_startup_report=parse_bool(env.get(constants.ENV_PRINT_STARTUP_REPORT)), propagate=parse_bool(env.get(constants.ENV_PROPAGATE, "1")), + strict=parse_bool(env.get(constants.ENV_STRICT)), )