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 1a2970f5..451f9357 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -37,4 +37,5 @@ Read this index first, then open the records relevant to the change. Before impl | [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. | | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | +| [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. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | 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 e9c93c13..dd52bf05 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_private_channel_auth.py b/fastapi_startkit/tests/broadcasting/test_private_channel_auth.py index 13833b94..f584b8e3 100644 --- a/fastapi_startkit/tests/broadcasting/test_private_channel_auth.py +++ b/fastapi_startkit/tests/broadcasting/test_private_channel_auth.py @@ -47,7 +47,7 @@ def base_path(tmp_path): def make_client(base_path, providers, user=None): - app = Application(base_path=base_path, env="testing", providers=providers) + app = Application(base_path=base_path, providers=providers) @app.fastapi.middleware("http") async def attach_user(request, call_next): diff --git a/fastapi_startkit/tests/broadcasting/test_provider.py b/fastapi_startkit/tests/broadcasting/test_provider.py index d685f7fa..509800b8 100644 --- a/fastapi_startkit/tests/broadcasting/test_provider.py +++ b/fastapi_startkit/tests/broadcasting/test_provider.py @@ -26,19 +26,22 @@ def test_refuses_to_boot_outside_local_and_testing_without_real_secret(self, mon monkeypatch.delenv("REVERB_APP_SECRET") else: monkeypatch.setenv("REVERB_APP_SECRET", secret) + monkeypatch.setenv("APP_ENV", "production") with pytest.raises(RuntimeError, match="REVERB_APP_SECRET"): - Application(base_path=tmp_path, env="production", providers=[ReverbProvider]) + Application(base_path=tmp_path, providers=[ReverbProvider]) def test_boots_in_production_with_real_secret(self, monkeypatch, tmp_path): monkeypatch.delenv("PYTEST_CURRENT_TEST") - app = Application(base_path=tmp_path, env="production", providers=[ReverbProvider]) + monkeypatch.setenv("APP_ENV", "production") + app = Application(base_path=tmp_path, providers=[ReverbProvider]) assert app.make("reverb.server").secret == SECRET @pytest.mark.parametrize("env", ["local", "testing"]) def test_boots_in_local_and_testing_without_secret(self, monkeypatch, tmp_path, env): monkeypatch.delenv("PYTEST_CURRENT_TEST") monkeypatch.delenv("REVERB_APP_SECRET") - app = Application(base_path=tmp_path, env=env, providers=[ReverbProvider]) + monkeypatch.setenv("APP_ENV", env) + app = Application(base_path=tmp_path, providers=[ReverbProvider]) assert app.make("reverb.server").secret == "" def test_empty_secret_rejects_every_protected_subscription(self, monkeypatch, tmp_path): @@ -57,7 +60,7 @@ def test_empty_secret_rejects_every_protected_subscription(self, monkeypatch, tm def make_app(tmp_path): - return Application(base_path=tmp_path, env="testing", providers=[ReverbProvider]) + return Application(base_path=tmp_path, providers=[ReverbProvider]) def client_for(app): 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_http_exception_rendering.py b/fastapi_startkit/tests/fastapi/test_http_exception_rendering.py index 2f2842f8..96e4cab2 100644 --- a/fastapi_startkit/tests/fastapi/test_http_exception_rendering.py +++ b/fastapi_startkit/tests/fastapi/test_http_exception_rendering.py @@ -14,7 +14,7 @@ def make_application(base_path: Path) -> Application: - app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + app = Application(base_path=base_path, providers=[FastAPIProvider]) @app.fastapi.get("/email-taken") def email_taken(): @@ -75,9 +75,7 @@ def get_application(self) -> Application: original_instance = Container._instance self.addCleanup(lambda: setattr(Container, "_instance", original_instance)) with patch.dict(os.environ, {"APP_NAME": "Acme Portal"}): - self._application = Application( - base_path=Path(self._tmp_dir.name), env="testing", providers=[FastAPIProvider] - ) + self._application = Application(base_path=Path(self._tmp_dir.name), providers=[FastAPIProvider]) return self._application async def test_title_comes_from_app_name(self): @@ -91,7 +89,7 @@ def test_title_defaults_to_fastapi_startkit(): try: with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, clear=False) as environ: environ.pop("APP_NAME", None) - app = Application(base_path=Path(tmp), env="testing", providers=[FastAPIProvider]) + app = Application(base_path=Path(tmp), providers=[FastAPIProvider]) assert app.fastapi.title == "FastAPI Startkit" finally: Container._instance = original_instance 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_request_scope.py b/fastapi_startkit/tests/vite/test_request_scope.py index cdbe8c3c..f50a9d7f 100644 --- a/fastapi_startkit/tests/vite/test_request_scope.py +++ b/fastapi_startkit/tests/vite/test_request_scope.py @@ -182,6 +182,6 @@ def test_provider_registers_vite_middleware(tmp_path): from fastapi_startkit.application import Application from fastapi_startkit.vite import ViteProvider - app = Application(base_path=tmp_path, env="testing", providers=[ViteProvider]) + app = Application(base_path=tmp_path, providers=[ViteProvider]) assert ViteMiddleware in [m.cls for m in app.fastapi.user_middleware] 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):