From 6da18db9a296ba95147de2de0ad22d4914578160 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 13:20:44 -0700 Subject: [PATCH] Deprecate Application(env=...) for removal in 1.0 The deployment environment belongs outside the code: APP_ENV, the CLI --env option, or pytest detection. Passing env= now emits a DeprecationWarning at the caller; behaviour is unchanged until 1.0. Framework tests stop passing the redundant env="testing" and pytest treats this warning as an error so it cannot creep back in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww --- .../040-deprecate-application-env-argument.md | 46 ++++++++++++++ docs/adr/AGENTS.md | 1 + example/config-app/tests/test_config_app.py | 38 +++++------ fastapi_startkit/pyproject.toml | 1 + .../src/fastapi_startkit/application.py | 9 +++ .../tests/broadcasting/test_provider.py | 4 +- .../tests/cache/test_cache_provider.py | 2 +- .../tests/cache/test_database_store.py | 2 +- .../tests/cache/test_redis_store.py | 3 +- fastapi_startkit/tests/conftest.py | 2 +- .../tests/console/test_application_run.py | 4 +- .../tests/core/test_application.py | 51 ++++++++------- .../core/test_application_env_deprecation.py | 63 +++++++++++++++++++ .../tests/core/test_configuration.py | 2 +- fastapi_startkit/tests/core/test_container.py | 2 +- fastapi_startkit/tests/core/test_providers.py | 18 +++--- .../tests/environment/test_lazy_env.py | 2 +- .../tests/events/test_provider.py | 4 +- .../tests/facades/test_facades.py | 6 +- .../test_application_boot_benchmark.py | 4 +- .../fastapi/test_request_lifecycle_events.py | 2 +- .../tests/logging/test_logging.py | 2 +- .../config/test_database_config.py | 2 +- .../tests/masoniteorm/config/test_manager.py | 2 - fastapi_startkit/tests/redis/test_provider.py | 4 +- .../tests/skills/test_commands.py | 3 +- .../tests/skills/test_registry.py | 3 +- .../tests/storage/test_provider.py | 2 +- .../tests/vite/test_template_binding.py | 2 +- 29 files changed, 201 insertions(+), 85 deletions(-) create mode 100644 docs/adr/040-deprecate-application-env-argument.md create mode 100644 fastapi_startkit/tests/core/test_application_env_deprecation.py diff --git a/docs/adr/040-deprecate-application-env-argument.md b/docs/adr/040-deprecate-application-env-argument.md new file mode 100644 index 00000000..b91dcab2 --- /dev/null +++ b/docs/adr/040-deprecate-application-env-argument.md @@ -0,0 +1,46 @@ +# 040. Deprecate the `env` argument of `Application` + +Date: 2026-10-25 + +## Problem + +`Application(base_path=..., env="production")` hard-codes the deployment environment in source code. The same code base runs locally, in CI and in production, so the environment has to come from outside the code. The argument also outranks `APP_ENV`, so a hard-coded `env=` silently overrides the operator's choice. Only the pytest check runs before it. + +The framework already has three external sources: + +- the `APP_ENV` environment variable, set by the shell, the process manager or `.env`; +- the CLI `--env ` / `--env=` option, which `Environment.resolve_environment_from_argument` turns into `APP_ENV` before Cleo parses arguments; +- pytest auto-detection (`PYTEST_CURRENT_TEST` gives `testing`, which loads `.env.testing`). + +None of these depend on the constructor argument. Nothing in `src/`, `application/`, the installer stubs or the example bootstraps passes `env=`. Only the framework's own tests (`env="testing"`, which is redundant under pytest) and one `example/config-app` test pass it. + +## Alternatives + +1. Remove the argument now. That breaks every application that passes it, with no migration window, in a 0.x minor release. +2. Keep it and document `APP_ENV` as the preferred way. Hard-coded values keep winning over `APP_ENV`, which is the problem. +3. Deprecate it now and remove it in 1.0. + +## Decision + +Option 3. Passing a non-`None` `env` to `Application.__init__` emits a `DeprecationWarning` with `stacklevel=2`, so the warning points at the user's `Application(...)` call. It says the argument will be removed in 1.0 and to use `APP_ENV` or `--env`. Resolution order and behaviour stay the same until removal. + +`Application.set_environment()` is unchanged. It switches the environment of a running application, together with `load_environment()`, and does not put a deployment environment in the bootstrap file. + +## Implementation + +- `fastapi_startkit/src/fastapi_startkit/application.py`: warn when `env is not None`. +- Framework tests stop passing `env="testing"`, since pytest detection already selects `testing`. +- `fastapi_startkit/pyproject.toml` turns this deprecation into an error under pytest (`filterwarnings`), so the framework test suite cannot add new `env=` calls. +- `example/config-app/tests/test_config_app.py` selects environments through `APP_ENV` (`patch.dict`). + +## Validation + +- `tests/core/test_application_env_deprecation.py`: + - `env=` warns, and the warning's filename is the caller's file; + - `env=` still selects the environment; + - no warning without `env`; + - `APP_ENV`, `--env ` and `--env=` select the environment without a warning, and the `--env` tokens are removed from `sys.argv`; + - pytest detection gives `testing`. +- The warning tests fail before the change. +- The full suite passes with the error filter, and coverage stays above 80%. +- `example/config-app` tests pass. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index c13f7f1b..95e3d80e 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,3 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | +| [040](040-deprecate-application-env-argument.md) | 2026-10-25 | Deprecate the `env` argument of `Application` | The deployment environment must come from outside the code: `APP_ENV`, the CLI `--env` option (turned into `APP_ENV` before Cleo parses arguments) or pytest detection (`testing`). `Application(env=...)` now emits a `DeprecationWarning` at the caller (`stacklevel=2`) and will be removed in 1.0; behaviour and precedence are unchanged until then. `set_environment()` stays. Framework tests no longer pass `env=`, and pytest `filterwarnings` turns this warning into an error so the suite cannot reintroduce it. | diff --git a/example/config-app/tests/test_config_app.py b/example/config-app/tests/test_config_app.py index ed0c669a..8f8b7942 100644 --- a/example/config-app/tests/test_config_app.py +++ b/example/config-app/tests/test_config_app.py @@ -31,24 +31,26 @@ def test_it_loads_env_from_arguments(self): self.assertEqual(RedisConfig().port, 0000) self.assertEqual(RedisConfig().db, 0) - # We initialize the application with production env, so it should load the production env - Application(base_path=Path(__file__).resolve().parent.parent, env="production") - - self.assertEqual(RedisConfig().host, "host.production") - self.assertEqual(RedisConfig().port, 1111) - self.assertEqual(RedisConfig().db, 1) - - # We initialize the application with testing env, so it should load the testing env - Application(base_path=Path(__file__).resolve().parent.parent, env="testing") - self.assertEqual(RedisConfig().host, "host.testing") - self.assertEqual(RedisConfig().port, 2222) - self.assertEqual(RedisConfig().db, 2) - - # We initialize the application with wrong env, so it should load the default env - Application(base_path=Path(__file__).resolve().parent.parent, env="wrong-env") - self.assertEqual(RedisConfig().host, "host.default") - self.assertEqual(RedisConfig().port, 0000) - self.assertEqual(RedisConfig().db, 0) + # We initialize the application with APP_ENV=production, so it should load the production env + with patch.dict(os.environ, {"APP_ENV": "production"}): + Application(base_path=Path(__file__).resolve().parent.parent) + self.assertEqual(RedisConfig().host, "host.production") + self.assertEqual(RedisConfig().port, 1111) + self.assertEqual(RedisConfig().db, 1) + + # We initialize the application with APP_ENV=testing, so it should load the testing env + with patch.dict(os.environ, {"APP_ENV": "testing"}): + Application(base_path=Path(__file__).resolve().parent.parent) + self.assertEqual(RedisConfig().host, "host.testing") + self.assertEqual(RedisConfig().port, 2222) + self.assertEqual(RedisConfig().db, 2) + + # We initialize the application with an unknown APP_ENV, so it should load the default env + with patch.dict(os.environ, {"APP_ENV": "wrong-env"}): + Application(base_path=Path(__file__).resolve().parent.parent) + self.assertEqual(RedisConfig().host, "host.default") + self.assertEqual(RedisConfig().port, 0000) + self.assertEqual(RedisConfig().db, 0) def test_app_loads_env_data(self): os.environ.pop("PYTEST_CURRENT_TEST", None) diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index bb883fa6..8f1dc45e 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -160,6 +160,7 @@ markers = [ "benchmark: performance benchmark, excluded by default (run with -m benchmark)", ] addopts = "-m 'not benchmark'" +filterwarnings = ['error:Application\(env=\.\.\.\) is deprecated:DeprecationWarning'] [tool.coverage.run] source = ["src/fastapi_startkit"] diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index f117c16a..6ca041b2 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -1,5 +1,6 @@ import os import shlex +import warnings from fastapi_startkit.foundation.app_provider import AppProvider from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -42,6 +43,14 @@ def __init__( ): super().__init__() + if env is not None: + warnings.warn( + "Application(env=...) is deprecated and will be removed in 1.0. " + "Set the APP_ENV environment variable or pass --env on the command line instead.", + DeprecationWarning, + stacklevel=2, + ) + self.base_path: Path = Path(base_path) if base_path else Path(os.getcwd()) self.env: str | None = env self.providers = self.DEFAULT_PROVIDERS + (providers or []) diff --git a/fastapi_startkit/tests/broadcasting/test_provider.py b/fastapi_startkit/tests/broadcasting/test_provider.py index 8b3f0c10..8ebb5ead 100644 --- a/fastapi_startkit/tests/broadcasting/test_provider.py +++ b/fastapi_startkit/tests/broadcasting/test_provider.py @@ -10,7 +10,7 @@ def make_app(tmp_path): - return Application(base_path=tmp_path, env="testing", providers=[ReverbProvider]) + return Application(base_path=tmp_path, providers=[ReverbProvider]) def mount_auth(tmp_path): @@ -19,7 +19,7 @@ def mount_auth(tmp_path): The full ``boot()`` mounts a catch-all WebSocket sub-app at ``""`` which shadows the HTTP auth route, so exercise the auth handler on its own. """ - app = Application(base_path=tmp_path, env="testing", providers=[]) + app = Application(base_path=tmp_path, providers=[]) provider = ReverbProvider(app) provider.register() provider._mount_auth_endpoint() diff --git a/fastapi_startkit/tests/cache/test_cache_provider.py b/fastapi_startkit/tests/cache/test_cache_provider.py index 79e698d1..30d965c6 100644 --- a/fastapi_startkit/tests/cache/test_cache_provider.py +++ b/fastapi_startkit/tests/cache/test_cache_provider.py @@ -25,7 +25,7 @@ def setUp(self): self.base_path = Path(self.enterContext(tempfile.TemporaryDirectory())) def make_app(self, provider=(CacheProvider, ArrayCacheConfig)): - return Application(base_path=self.base_path, env="testing", providers=[provider]) + return Application(base_path=self.base_path, providers=[provider]) def test_binds_manager_and_merges_default_config(self): app = self.make_app(CacheProvider) diff --git a/fastapi_startkit/tests/cache/test_database_store.py b/fastapi_startkit/tests/cache/test_database_store.py index f99ae6c6..a3bd5593 100644 --- a/fastapi_startkit/tests/cache/test_database_store.py +++ b/fastapi_startkit/tests/cache/test_database_store.py @@ -205,7 +205,7 @@ async def asyncSetUp(self): self.base_path = Path(self.enterContext(tempfile.TemporaryDirectory())) def make_app(self): - app = Application(base_path=self.base_path, env="testing", providers=[CacheProvider]) + app = Application(base_path=self.base_path, providers=[CacheProvider]) app.bind("db", self.database) return app diff --git a/fastapi_startkit/tests/cache/test_redis_store.py b/fastapi_startkit/tests/cache/test_redis_store.py index c7851634..5e50ea20 100644 --- a/fastapi_startkit/tests/cache/test_redis_store.py +++ b/fastapi_startkit/tests/cache/test_redis_store.py @@ -153,7 +153,6 @@ def setUp(self): def make_app(self): app = Application( base_path=self.base_path, - env="testing", providers=[(RedisProvider, lambda: redis_config()), (CacheProvider, RedisCacheConfig)], ) app.make("redis").extend("fake", lambda parameters: fakeredis.FakeAsyncRedis(server=self.server, **parameters)) @@ -191,7 +190,7 @@ async def test_flush_through_facade_keeps_unrelated_keys(self): self.assertEqual(await self.database(1).keys("*"), ["app_queue:1"]) async def test_missing_redis_provider_raises_on_first_use(self): - Application(base_path=self.base_path, env="testing", providers=[(CacheProvider, RedisCacheConfig)]) + Application(base_path=self.base_path, providers=[(CacheProvider, RedisCacheConfig)]) repository = Cache.store("redis") diff --git a/fastapi_startkit/tests/conftest.py b/fastapi_startkit/tests/conftest.py index bf98a4f2..9efb7f2c 100644 --- a/fastapi_startkit/tests/conftest.py +++ b/fastapi_startkit/tests/conftest.py @@ -4,4 +4,4 @@ @pytest.fixture(scope="session", autouse=True) def init_app(): - Application(env="testing") + Application() diff --git a/fastapi_startkit/tests/console/test_application_run.py b/fastapi_startkit/tests/console/test_application_run.py index ed8bbeb7..9b35dce3 100644 --- a/fastapi_startkit/tests/console/test_application_run.py +++ b/fastapi_startkit/tests/console/test_application_run.py @@ -34,7 +34,7 @@ def setUp(self) -> None: DummyCommand.received = {} self._previous = Container._instance - self.app = Application(env="testing") + self.app = Application() self.app.add_commands([DummyCommand]) def tearDown(self) -> None: @@ -105,7 +105,7 @@ def test_can_be_invoked_repeatedly(self): class HandleCommandTest(unittest.TestCase): def setUp(self) -> None: self._previous = Container._instance - self.app = Application(env="testing") + self.app = Application() def tearDown(self) -> None: Container.set_instance(self._previous) diff --git a/fastapi_startkit/tests/core/test_application.py b/fastapi_startkit/tests/core/test_application.py index d08d6273..80948fc9 100644 --- a/fastapi_startkit/tests/core/test_application.py +++ b/fastapi_startkit/tests/core/test_application.py @@ -25,7 +25,7 @@ def restore_container_instance(): @pytest.fixture def app(tmp_path): - return Application(base_path=tmp_path, env="testing") + return Application(base_path=tmp_path) # --------------------------------------------------------------------------- @@ -48,23 +48,23 @@ def boot(self): class TestApplicationInit: def test_sets_container_singleton_on_init(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert Container.instance() is a def test_second_application_replaces_singleton(self, tmp_path): - a1 = Application(base_path=tmp_path, env="testing") - a2 = Application(base_path=tmp_path, env="testing") + a1 = Application(base_path=tmp_path) + a2 = Application(base_path=tmp_path) assert Container.instance() is a2 assert Container.instance() is not a1 def test_base_path_stored_as_path_object(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) from pathlib import Path assert a.base_path == Path(tmp_path) def test_base_path_from_string(self, tmp_path): - a = Application(base_path=str(tmp_path), env="testing") + a = Application(base_path=str(tmp_path)) from pathlib import Path assert a.base_path == Path(tmp_path) @@ -83,25 +83,25 @@ def test_is_testing_when_env_is_testing(self, app): class TestConfigurePaths: def test_config_location_is_bound(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.make("config.location") == tmp_path / "config" def test_use_config_path_overrides_binding(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) custom = tmp_path / "my_config" a.use_config_path(str(custom)) assert a.make("config.location") == str(custom) def test_storage_path_appends_to_base(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.storage_path("logs") == str(tmp_path / "storage" / "logs") def test_storage_path_empty_returns_base_storage(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.storage_path() == str(tmp_path / "storage") def test_public_path_appends_to_base(self, tmp_path): - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.public_path("css") == str(tmp_path / "public" / "css") @@ -121,7 +121,7 @@ def register(self): def boot(self): call_log.append("boot") - Application(base_path=tmp_path, env="testing", providers=[OrderedProvider]) + Application(base_path=tmp_path, providers=[OrderedProvider]) assert "register" in call_log assert "boot" in call_log assert call_log.index("register") < call_log.index("boot") @@ -144,7 +144,7 @@ def register(self): def boot(self): call_log.append("boot_b") - Application(base_path=tmp_path, env="testing", providers=[ProviderA, ProviderB]) + Application(base_path=tmp_path, providers=[ProviderA, ProviderB]) register_indices = [i for i, x in enumerate(call_log) if x.startswith("register")] boot_indices = [i for i, x in enumerate(call_log) if x.startswith("boot")] assert register_indices and boot_indices @@ -162,11 +162,11 @@ class ConsumerProvider(Provider): def boot(self): resolved.append(self.app.make("shared_service")) - Application(base_path=tmp_path, env="testing", providers=[BindingProvider, ConsumerProvider]) + Application(base_path=tmp_path, providers=[BindingProvider, ConsumerProvider]) assert resolved == ["hello"] def test_extra_providers_appended_to_defaults(self, tmp_path): - a = Application(base_path=tmp_path, env="testing", providers=[StubProvider]) + a = Application(base_path=tmp_path, providers=[StubProvider]) assert a.make("stub_service") == "stub_value" def test_tuple_provider_with_config_dict(self, tmp_path): @@ -178,7 +178,6 @@ def register(self): Application( base_path=tmp_path, - env="testing", providers=[(ConfiguredProvider, {"key": "value"})], ) assert received_config == {"key": "value"} @@ -198,7 +197,7 @@ def test_load_environment_reads_env_file(self, tmp_path, monkeypatch): (tmp_path / ".env").write_text("APP_NAME=BaseApp\n") monkeypatch.delenv("APP_NAME", raising=False) - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) a.load_environment() assert os.environ.get("APP_NAME") == "BaseApp" @@ -207,14 +206,14 @@ def test_load_environment_env_specific_overrides_base(self, tmp_path, monkeypatc (tmp_path / ".env.testing").write_text("APP_NAME=TestingApp\n") monkeypatch.delenv("APP_NAME", raising=False) - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) a.load_environment() assert os.environ.get("APP_NAME") == "TestingApp" def test_load_environment_missing_env_file_is_silent(self, tmp_path, monkeypatch): monkeypatch.delenv("APP_NAME", raising=False) # No .env or .env.testing file — should not raise - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) a.load_environment() # must not raise @@ -240,7 +239,7 @@ def test_fastapi_property_returns_same_instance_each_time(self, app): def test_use_fastapi_sets_instance(self, tmp_path): from fastapi import FastAPI - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) custom = FastAPI() a.use_fastapi(custom) assert a.fastapi is custom @@ -289,7 +288,7 @@ def test_config_instantiated_from_class(self, tmp_path): class MyConfig: name: str = "test" - a = Application(base_path=tmp_path, env="testing", config=MyConfig) + a = Application(base_path=tmp_path, config=MyConfig) assert a.config.name == "test" def test_explicit_config_class_is_not_replaced_by_default(self, tmp_path): @@ -299,23 +298,23 @@ def test_explicit_config_class_is_not_replaced_by_default(self, tmp_path): class MyConfig: name: str = "test" - a = Application(base_path=tmp_path, env="testing", config=MyConfig) + a = Application(base_path=tmp_path, config=MyConfig) assert not isinstance(a.config, AppConfig) def test_default_config_reads_app_name_from_env(self, tmp_path, monkeypatch): monkeypatch.setenv("APP_NAME", "Env Named App") - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.config.name == "Env Named App" def test_default_config_reads_app_debug_false_from_env(self, tmp_path, monkeypatch): monkeypatch.setenv("APP_DEBUG", "false") - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.config.debug is False assert a.is_debug() is False def test_default_config_reads_app_debug_true_from_env(self, tmp_path, monkeypatch): monkeypatch.setenv("APP_DEBUG", "true") - a = Application(base_path=tmp_path, env="testing") + a = Application(base_path=tmp_path) assert a.config.debug is True assert a.is_debug() is True @@ -326,5 +325,5 @@ def test_is_debug_reflects_config_value(self, tmp_path): class DebugConfig: debug: bool = True - a = Application(base_path=tmp_path, env="testing", config=DebugConfig) + a = Application(base_path=tmp_path, config=DebugConfig) assert a.is_debug() is True diff --git a/fastapi_startkit/tests/core/test_application_env_deprecation.py b/fastapi_startkit/tests/core/test_application_env_deprecation.py new file mode 100644 index 00000000..6a1a6144 --- /dev/null +++ b/fastapi_startkit/tests/core/test_application_env_deprecation.py @@ -0,0 +1,63 @@ +import sys +import warnings + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container + + +@pytest.fixture(autouse=True) +def restore_container_instance(): + original = Container._instance + yield + Container._instance = original + + +def test_passing_env_warns_at_the_call_site(tmp_path): + with pytest.warns(DeprecationWarning, match=r"removed in 1\.0.*APP_ENV.*--env") as record: + Application(base_path=tmp_path, env="testing") + + assert record[0].filename == __file__ + + +def test_passing_env_still_selects_the_environment(tmp_path, monkeypatch): + monkeypatch.delenv("PYTEST_CURRENT_TEST") + + with pytest.warns(DeprecationWarning): + app = Application(base_path=tmp_path, env="staging") + + assert app.env == "staging" + + +def test_no_warning_without_env(tmp_path): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + app = Application(base_path=tmp_path) + + assert app.env == "testing" + + +def test_app_env_variable_selects_the_environment_without_warning(tmp_path, monkeypatch): + monkeypatch.delenv("PYTEST_CURRENT_TEST") + monkeypatch.setenv("APP_ENV", "production") + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + app = Application(base_path=tmp_path) + + assert app.env == "production" + + +@pytest.mark.parametrize("argv", [["--env", "production"], ["--env=production"]]) +def test_cli_env_option_selects_the_environment_without_warning(tmp_path, monkeypatch, argv): + monkeypatch.delenv("PYTEST_CURRENT_TEST") + monkeypatch.setenv("APP_ENV", "") + monkeypatch.setattr(sys, "argv", ["artisan", "serve", *argv]) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + app = Application(base_path=tmp_path) + + assert app.env == "production" + assert sys.argv == ["artisan", "serve"] diff --git a/fastapi_startkit/tests/core/test_configuration.py b/fastapi_startkit/tests/core/test_configuration.py index 98684f2a..8219df39 100644 --- a/fastapi_startkit/tests/core/test_configuration.py +++ b/fastapi_startkit/tests/core/test_configuration.py @@ -24,7 +24,7 @@ def restore_container_instance(): def app(tmp_path): from fastapi_startkit.application import Application - return Application(base_path=tmp_path, env="testing") + return Application(base_path=tmp_path) @pytest.fixture diff --git a/fastapi_startkit/tests/core/test_container.py b/fastapi_startkit/tests/core/test_container.py index 04f6fd65..9c7fd6bd 100644 --- a/fastapi_startkit/tests/core/test_container.py +++ b/fastapi_startkit/tests/core/test_container.py @@ -543,7 +543,7 @@ def test_make_with_string_key_is_typed_as_any(self, container): def test_application_make_inherits_typing(self, tmp_path): from fastapi_startkit.application import Application - app = Application(base_path=tmp_path, env="testing") + app = Application(base_path=tmp_path) app.bind("service_a", ServiceA) assert_type(app.make(ServiceA), ServiceA) diff --git a/fastapi_startkit/tests/core/test_providers.py b/fastapi_startkit/tests/core/test_providers.py index 8c92f75f..743411df 100644 --- a/fastapi_startkit/tests/core/test_providers.py +++ b/fastapi_startkit/tests/core/test_providers.py @@ -23,7 +23,7 @@ def restore_container_instance(): @pytest.fixture def app(tmp_path): - return Application(base_path=tmp_path, env="testing") + return Application(base_path=tmp_path) # --------------------------------------------------------------------------- @@ -145,7 +145,7 @@ class AssetsProvider(Provider): class TestRegisterPhase: def test_register_adds_binding_to_container(self, tmp_path): - app = Application(base_path=tmp_path, env="testing", providers=[BindingProvider]) + app = Application(base_path=tmp_path, providers=[BindingProvider]) assert app.make("binding_provider_service") == "bound_value" def test_register_multiple_providers(self, tmp_path): @@ -157,7 +157,7 @@ class ProviderB(Provider): def register(self): self.app.bind("svc_b", "value_b") - app = Application(base_path=tmp_path, env="testing", providers=[ProviderA, ProviderB]) + app = Application(base_path=tmp_path, providers=[ProviderA, ProviderB]) assert app.make("svc_a") == "value_a" assert app.make("svc_b") == "value_b" @@ -166,7 +166,7 @@ class NoopProvider(Provider): pass # Should not raise - Application(base_path=tmp_path, env="testing", providers=[NoopProvider]) + Application(base_path=tmp_path, providers=[NoopProvider]) # --------------------------------------------------------------------------- @@ -186,12 +186,12 @@ def register(self): def boot(self): call_order.append("boot") - Application(base_path=tmp_path, env="testing", providers=[TrackProvider]) + Application(base_path=tmp_path, providers=[TrackProvider]) assert call_order == ["register", "boot"] def test_boot_can_resolve_bindings_from_register(self, tmp_path): BootAccessProvider.resolved_in_boot = None - Application(base_path=tmp_path, env="testing", providers=[BindingProvider, BootAccessProvider]) + Application(base_path=tmp_path, providers=[BindingProvider, BootAccessProvider]) assert BootAccessProvider.resolved_in_boot == "bound_value" def test_all_registers_run_before_any_boot(self, tmp_path): @@ -211,7 +211,7 @@ def register(self): def boot(self): combined_log.append("PB.boot") - Application(base_path=tmp_path, env="testing", providers=[PA, PB]) + Application(base_path=tmp_path, providers=[PA, PB]) # All registers must precede all boots registers = [i for i, e in enumerate(combined_log) if "register" in e] @@ -223,7 +223,7 @@ class NoBootProvider(Provider): def register(self): self.app.bind("nb_svc", "val") - Application(base_path=tmp_path, env="testing", providers=[NoBootProvider]) # Should not raise + Application(base_path=tmp_path, providers=[NoBootProvider]) # Should not raise # --------------------------------------------------------------------------- @@ -233,7 +233,7 @@ def register(self): class TestDuplicateKey: def test_last_write_wins(self, tmp_path): - app = Application(base_path=tmp_path, env="testing", providers=[DuplicateKeyProvider]) + app = Application(base_path=tmp_path, providers=[DuplicateKeyProvider]) assert app.make("dup_key") == "second" diff --git a/fastapi_startkit/tests/environment/test_lazy_env.py b/fastapi_startkit/tests/environment/test_lazy_env.py index c44a93e7..22b27ae4 100644 --- a/fastapi_startkit/tests/environment/test_lazy_env.py +++ b/fastapi_startkit/tests/environment/test_lazy_env.py @@ -123,7 +123,7 @@ def test_application_loads_environment_before_database_settings(tmp_path, monkey for key in ("DB_CONNECTION", "DB_DATABASE", "DB_ECHO"): monkeypatch.setenv(key, "") (tmp_path / ".env").write_text("DB_CONNECTION=sqlite\nDB_DATABASE=loaded.sqlite\nDB_ECHO=true\n") - application = Application(base_path=tmp_path, env="testing", providers=[(DatabaseProvider, DatabaseConfig)]) + application = Application(base_path=tmp_path, providers=[(DatabaseProvider, DatabaseConfig)]) config = application.make("db").config assert config.default == "sqlite" assert config.connections["sqlite"].database == "loaded.sqlite" diff --git a/fastapi_startkit/tests/events/test_provider.py b/fastapi_startkit/tests/events/test_provider.py index 378e3d4b..6ce54f09 100644 --- a/fastapi_startkit/tests/events/test_provider.py +++ b/fastapi_startkit/tests/events/test_provider.py @@ -39,10 +39,10 @@ def tearDown(self): Container._instance = self._original_container_instance def _make_app(self): - return Application(base_path=self.base_path, env="testing", providers=[AppEventServiceProvider]) + return Application(base_path=self.base_path, providers=[AppEventServiceProvider]) def test_dispatcher_is_bound_by_default(self): - app = Application(base_path=self.base_path, env="testing") + app = Application(base_path=self.base_path) self.assertTrue(app.has("events")) self.assertIsInstance(app.make("events"), Dispatcher) diff --git a/fastapi_startkit/tests/facades/test_facades.py b/fastapi_startkit/tests/facades/test_facades.py index a5533807..ce8026e0 100644 --- a/fastapi_startkit/tests/facades/test_facades.py +++ b/fastapi_startkit/tests/facades/test_facades.py @@ -21,7 +21,7 @@ def restore_container_instance(): def app(tmp_path): from fastapi_startkit.application import Application - return Application(base_path=tmp_path, env="testing") + return Application(base_path=tmp_path) # --------------------------------------------------------------------------- @@ -130,13 +130,13 @@ def test_config_does_not_bleed_between_apps(self, tmp_path): from fastapi_startkit.application import Application from fastapi_startkit.configuration.config import Config - app1 = Application(base_path=tmp_path / "app1", env="testing") + app1 = Application(base_path=tmp_path / "app1") Config.set("isolated", "from_app1") assert Config.get("isolated") == "from_app1" # Resetting singleton resets the config Container._instance = None - app2 = Application(base_path=tmp_path / "app2", env="testing") + app2 = Application(base_path=tmp_path / "app2") assert Config.get("isolated") is None diff --git a/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py b/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py index 93ded9f6..489cac41 100644 --- a/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py +++ b/fastapi_startkit/tests/fastapi/benchmarks/test_application_boot_benchmark.py @@ -25,11 +25,11 @@ def restore_container_instance(): class TestApplicationBootBenchmarks: def test_boot_default_providers(self, benchmark, tmp_path): """Full Application() init: environment resolution, config, default providers register+boot.""" - benchmark(Application, base_path=tmp_path, env="testing") + benchmark(Application, base_path=tmp_path) def test_boot_with_fastapi_provider(self, benchmark, tmp_path): """Same as above, plus FastAPIProvider register+boot (FastAPI instance, exception handlers, command).""" - benchmark(Application, base_path=tmp_path, env="testing", providers=[FastAPIProvider]) + benchmark(Application, base_path=tmp_path, providers=[FastAPIProvider]) def test_boot_raw_fastapi_baseline(self, benchmark): """Raw FastAPI() instantiation — the floor that Application's boot overhead is measured against.""" diff --git a/fastapi_startkit/tests/fastapi/test_request_lifecycle_events.py b/fastapi_startkit/tests/fastapi/test_request_lifecycle_events.py index 74ab551b..ad304092 100644 --- a/fastapi_startkit/tests/fastapi/test_request_lifecycle_events.py +++ b/fastapi_startkit/tests/fastapi/test_request_lifecycle_events.py @@ -37,7 +37,7 @@ def setUp(self): self._original_container_instance = Container._instance self.addCleanup(self._restore_container) - self.app = Application(base_path=Path(self._tmp_dir.name), env="testing", providers=[FastAPIProvider]) + self.app = Application(base_path=Path(self._tmp_dir.name), providers=[FastAPIProvider]) self.client = make_client(self.app) def _restore_container(self): diff --git a/fastapi_startkit/tests/logging/test_logging.py b/fastapi_startkit/tests/logging/test_logging.py index 716416d5..6fb49227 100644 --- a/fastapi_startkit/tests/logging/test_logging.py +++ b/fastapi_startkit/tests/logging/test_logging.py @@ -54,7 +54,7 @@ def setUpModule(): except Exception: _saved_state["app"] = None - Application(env="testing", providers=[LogProvider]) + Application(providers=[LogProvider]) def tearDownModule(): diff --git a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py index 96bd1449..9f0cbe03 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py @@ -73,7 +73,7 @@ def test_connection_rejects_mismatched_driver(): def test_provider_rejects_dictionary_connection_settings(tmp_path): - app = Application(base_path=tmp_path, env="testing") + app = Application(base_path=tmp_path) config = DatabaseConfig(default="sqlite", connections={"sqlite": {"driver": "sqlite"}}) with pytest.raises(TypeError, match="Database connection 'sqlite' requires a database config object"): DatabaseProvider(app, config).register() diff --git a/fastapi_startkit/tests/masoniteorm/config/test_manager.py b/fastapi_startkit/tests/masoniteorm/config/test_manager.py index 0f59d111..623de1ef 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_manager.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_manager.py @@ -62,7 +62,6 @@ def test_provider_wires_a_resolvable_connection(self): settings = DatabaseConfig(default="sqlite", connections={"sqlite": connection_config}) app = Application( base_path=self.base_path, - env="testing", providers=[ ( DatabaseProvider, @@ -83,6 +82,5 @@ def test_provider_rejects_dictionary_settings(self): with self.assertRaisesRegex(TypeError, "requires a database settings dataclass"): Application( base_path=self.base_path, - env="testing", providers=[(DatabaseProvider, {"default": "sqlite"})], ) diff --git a/fastapi_startkit/tests/redis/test_provider.py b/fastapi_startkit/tests/redis/test_provider.py index 73527517..dfba88fe 100644 --- a/fastapi_startkit/tests/redis/test_provider.py +++ b/fastapi_startkit/tests/redis/test_provider.py @@ -11,7 +11,7 @@ def make_app(tmp_path, provider=RedisProvider): - return Application(base_path=tmp_path, env="testing", providers=[provider]) + return Application(base_path=tmp_path, providers=[provider]) def test_binds_manager_and_merges_config(tmp_path): @@ -69,7 +69,7 @@ async def lifespan(app): yield {"ready": True} events.append("shutdown") - app = Application(base_path=tmp_path, env="testing", providers=[]) + app = Application(base_path=tmp_path, providers=[]) app.fastapi.router.lifespan_context = lifespan provider = RedisProvider(app, config=make_config()) provider.register() diff --git a/fastapi_startkit/tests/skills/test_commands.py b/fastapi_startkit/tests/skills/test_commands.py index 6bfd104d..609391fb 100644 --- a/fastapi_startkit/tests/skills/test_commands.py +++ b/fastapi_startkit/tests/skills/test_commands.py @@ -39,7 +39,6 @@ def app(tmp_path): """App with the real skill-declaring FastAPIProvider and a bound registry.""" return Application( base_path=tmp_path, - env="testing", providers=[AISkillProvider, FastAPIProvider], ) @@ -47,7 +46,7 @@ def app(tmp_path): @pytest.fixture def empty_app(tmp_path): """App with no skill-declaring providers (registry still bound).""" - return Application(base_path=tmp_path, env="testing", providers=[AISkillProvider]) + return Application(base_path=tmp_path, providers=[AISkillProvider]) def run(app, argv: str = "") -> CommandTester: diff --git a/fastapi_startkit/tests/skills/test_registry.py b/fastapi_startkit/tests/skills/test_registry.py index 9e935afa..cdf718a4 100644 --- a/fastapi_startkit/tests/skills/test_registry.py +++ b/fastapi_startkit/tests/skills/test_registry.py @@ -34,14 +34,13 @@ def app(tmp_path): """App with a skill-declaring provider (fastapi) and a non-declaring one.""" return Application( base_path=tmp_path, - env="testing", providers=[AISkillProvider, FastAPIProvider], ) @pytest.fixture def empty_app(tmp_path): - return Application(base_path=tmp_path, env="testing") + return Application(base_path=tmp_path) # --------------------------------------------------------------------------- diff --git a/fastapi_startkit/tests/storage/test_provider.py b/fastapi_startkit/tests/storage/test_provider.py index 4cdda8d7..7be5de72 100644 --- a/fastapi_startkit/tests/storage/test_provider.py +++ b/fastapi_startkit/tests/storage/test_provider.py @@ -8,7 +8,7 @@ def make_app(tmp_path): - return Application(base_path=tmp_path, env="testing", providers=[StorageProvider]) + return Application(base_path=tmp_path, providers=[StorageProvider]) class TestRegister: diff --git a/fastapi_startkit/tests/vite/test_template_binding.py b/fastapi_startkit/tests/vite/test_template_binding.py index a7425951..84743b36 100644 --- a/fastapi_startkit/tests/vite/test_template_binding.py +++ b/fastapi_startkit/tests/vite/test_template_binding.py @@ -12,7 +12,7 @@ def make_app(tmp_path, providers=None) -> Application: if providers is None: providers = [ViteProvider] - return Application(base_path=tmp_path, env="testing", providers=providers) + return Application(base_path=tmp_path, providers=providers) class _SentinelTemplatesProvider(Provider):