Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/adr/040-deprecate-application-env-argument.md
Original file line number Diff line number Diff line change
@@ -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 <name>` / `--env=<name>` 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 <name>` and `--env=<name>` 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.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [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. |
| [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. |
38 changes: 20 additions & 18 deletions example/config-app/tests/test_config_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
9 changes: 9 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/application.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 [])
Expand Down
4 changes: 2 additions & 2 deletions fastapi_startkit/tests/broadcasting/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion fastapi_startkit/tests/cache/test_cache_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion fastapi_startkit/tests/cache/test_database_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions fastapi_startkit/tests/cache/test_redis_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion fastapi_startkit/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

@pytest.fixture(scope="session", autouse=True)
def init_app():
Application(env="testing")
Application()
4 changes: 2 additions & 2 deletions fastapi_startkit/tests/console/test_application_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading