@cryptotaxi247 / CoPilot / commits / eb0742f3

fix: load .env into os.environ after environs 9 → 15 bump (#872)

environs 14.0.0 changed Env.read_env() to populate the Env instance's private _environ dict instead of mutating os.environ. PR #844 bumped environs 9.5.0 → 15.0.1 without catching this. Code paths reading typed values through env.str() / env.bool() kept working because Env._get_value() falls back to self._environ, but every os.environ.get() and os.getenv() callsite — including _load_jwt_secret() in app/auth/utils.py, which is invoked at class-body import time — silently stopped seeing .env values. The symptom: `uvicorn copilot:app` from backend/ now needs `--env-file ../.env` to boot. Previously the .env was loaded by the transitive import chain (copilot.py → app.auth.utils → app.auth.services.universal → app.db.db_session → env.read_env). That chain still runs, but no longer publishes anything to os.environ. Replace the two env.read_env(...) calls with python-dotenv's load_dotenv(...), which still mutates os.environ. Add a third load_dotenv at the very top of copilot.py (before any `from app...` import) so JWT_SECRET is available the instant AuthHandler's class body executes — independent of the import order downstream. Also fix a latent path bug in both db_session.py and data_store_session.py: Path(__file__).parent.parent / ".env" resolved to backend/app/.env, not the repo-root .env. environs' recurse=True walked up and found the real file by accident; with load_dotenv (no recurse) the path has to be correct, so this is now four parents up. Verified by force-recreating the backend container off the patched image: "Application startup complete" logs cleanly and POST /api/auth/token returns a real 401 from the DB-backed auth flow, confirming JWT_SECRET loaded at import time. Co-authored-by: taylor_socfortress <taylor.walton@socfortress.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylorcopilot committed May 13, 2026 at 11:10 UTC eb0742f39487423761961a050b6c1038bb476d99
4 files changed +40 -22
backend/app/data_store/data_store_session.py
+7 -3
@@ -1,13 +1,17 @@
1 from pathlib import Path
2
3 +from dotenv import load_dotenv
4 from environs import Env
5 from loguru import logger
6 from miniopy_async import Minio
7
8 +# Repo root is four parents up (backend/app/data_store/data_store_session.py).
9 +# environs >= 14 stopped mutating os.environ from read_env(); use python-dotenv
10 +# so os.environ.get() callsites elsewhere in the app see .env values.
11 +_DOTENV_PATH = Path(__file__).parent.parent.parent.parent / ".env"
12 +load_dotenv(_DOTENV_PATH)
13 env = Env()
8 -env.read_env(Path(__file__).parent.parent / ".env")
9 -# env.read_env(Path(__file__).parent.parent.parent / "docker-env" / ".env")
10 -logger.info(f"Loading environment from {Path(__file__).parent.parent.parent.parent / '.env'}")
14 +logger.info(f"Loading environment from {_DOTENV_PATH}")
15
16
17 minio_root_user = env.str("MINIO_ROOT_USER", default="admin")
backend/app/db/db_session.py
+7 -3
@@ -6,6 +6,7 @@ from contextlib import contextmanager
6 # from settings import SQLALCHEMY_DATABASE_URI
7 from pathlib import Path
8
9 +from dotenv import load_dotenv
10 from environs import Env
11 from loguru import logger
12 from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,10 +15,13 @@ from sqlalchemy.orm import sessionmaker
15 from sqlmodel import Session
16 from sqlmodel import create_engine
17
18 +# Repo root is four parents up from this file (backend/app/db/db_session.py).
19 +# environs >= 14 stopped mutating os.environ from read_env(); use python-dotenv so
20 +# os.environ.get() callsites (e.g. _load_jwt_secret) see values from .env.
21 +_DOTENV_PATH = Path(__file__).parent.parent.parent.parent / ".env"
22 +load_dotenv(_DOTENV_PATH)
23 env = Env()
18 -env.read_env(Path(__file__).parent.parent / ".env")
19 -# env.read_env(Path(__file__).parent.parent.parent / "docker-env" / ".env")
20 -logger.info(f"Loading environment from {Path(__file__).parent.parent.parent.parent / '.env'}")
24 +logger.info(f"Loading environment from {_DOTENV_PATH}")
25
26 db_user = env.str("MYSQL_USER", default="copilot")
27 db_password = env.str("MYSQL_PASSWORD")
backend/copilot.py
+19 -12
@@ -1,17 +1,26 @@
1 import os
2 from contextlib import asynccontextmanager
3 +from pathlib import Path
4
4 -import uvicorn
5 from dotenv import load_dotenv
6 -from fastapi import APIRouter
7 -from fastapi import FastAPI
8 -from fastapi import HTTPException
9 -from fastapi.exceptions import RequestValidationError
10 -from fastapi.middleware.cors import CORSMiddleware
11 -from fastapi.staticfiles import StaticFiles
12 -from loguru import logger
13 -
14 -from app.auth.utils import AuthHandler
6 +
7 +# Load .env into os.environ before any `from app...` import. AuthHandler's class body
8 +# (app/auth/utils.py) calls _load_jwt_secret() at import time and reads JWT_SECRET
9 +# directly from os.environ — environs >= 14 no longer populates os.environ from
10 +# read_env(), so without this line the backend refuses to boot unless JWT_SECRET is
11 +# already exported in the shell (or uvicorn was invoked with --env-file).
12 +load_dotenv(Path(__file__).parent.parent / ".env")
13 +
14 +import uvicorn # noqa: E402
15 +from fastapi import APIRouter # noqa: E402
16 +from fastapi import FastAPI # noqa: E402
17 +from fastapi import HTTPException # noqa: E402
18 +from fastapi.exceptions import RequestValidationError # noqa: E402
19 +from fastapi.middleware.cors import CORSMiddleware # noqa: E402
20 +from fastapi.staticfiles import StaticFiles # noqa: E402
21 +from loguru import logger # noqa: E402
22 +
23 +from app.auth.utils import AuthHandler # noqa: E402
24 from app.data_store.data_store_setup import create_buckets
25 from app.db.db_session import SQLALCHEMY_DATABASE_URI_NO_DB
26 from app.db.db_session import async_engine
@@ -91,8 +100,6 @@ from app.schedulers.scheduler import init_scheduler
100
101
102 auth_handler = AuthHandler()
94 -# Get the `SERVER_IP` from the `.env` file
95 -load_dotenv()
103 server_ip = os.getenv("SERVER_IP", "localhost")
104 environment = os.getenv("ENVIRONMENT", "PRODUCTION")
105
backend/settings.py
+7 -4
@@ -7,14 +7,17 @@ environment variables.
7 """
8 from pathlib import Path
9
10 +from dotenv import load_dotenv
11 from environs import Env
12 from loguru import logger
13
14 +# environs >= 14 stopped mutating os.environ from read_env() — values only land on the
15 +# Env instance. Use python-dotenv to populate os.environ for the many os.environ.get()
16 +# callsites across the app (notably _load_jwt_secret in app/auth/utils.py).
17 +_DOTENV_PATH = Path(__file__).parent.parent / ".env"
18 +load_dotenv(_DOTENV_PATH)
19 env = Env()
14 -env.read_env(Path(__file__).parent.parent / ".env")
15 -# env.read_env(Path(__file__).parent.parent.parent / "docker-env" / ".env")
16 -logger.info(f"Loading environment from {Path(__file__).parent.parent / '.env'}")
17 -# logger.info(f"Loading environment from {Path(__file__).parent.parent.parent / 'docker-env' / '.env'}")
20 +logger.info(f"Loading environment from {_DOTENV_PATH}")
21
22
23 basedir = Path().absolute()