From aaac39ce4ed108320e743b9fa84b69b43cd101e6 Mon Sep 17 00:00:00 2001 From: paisley Date: Wed, 19 Nov 2025 17:17:32 +0800 Subject: [PATCH] feat: use system env file --- docs/CONFIGURATION_GUIDE.md | 13 +-- python/scripts/launch.py | 30 +++++-- python/valuecell/__init__.py | 80 ++++++++----------- python/valuecell/server/api/app.py | 38 ++++----- python/valuecell/server/api/routers/models.py | 18 +++-- python/valuecell/utils/env.py | 57 ++++++++++++- 6 files changed, 147 insertions(+), 89 deletions(-) diff --git a/docs/CONFIGURATION_GUIDE.md b/docs/CONFIGURATION_GUIDE.md index b4c804b3c..b00593fd0 100644 --- a/docs/CONFIGURATION_GUIDE.md +++ b/docs/CONFIGURATION_GUIDE.md @@ -31,20 +31,13 @@ ValueCell supports multiple LLM providers. Choose at least one: ### Step 2: Configure .env File Copy the example file and add your API keys: +Edit `.env` and add your credentials: -```bash # In project root cp .env.example .env -``` - -Edit `.env` and add your credentials: - -```bash -# OpenRouter (recommended for multi-model support) -OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxx # Or SiliconFlow (best for Chinese models and cost) -SILICONFLOW_API_KEY=sk-xxxxxxxxxxxxx +Edit `.env` and add your credentials: # Or Google Gemini GOOGLE_API_KEY=AIzaSyDxxxxxxxxxxxxx @@ -130,7 +123,7 @@ The system automatically reads `OPENROUTER_API_KEY` from `.env` or environment. When you create an agent (e.g., `research_agent`), the system: 1. **Loads agent YAML** (e.g., `configs/agents/research_agent.yaml`) -2. **Resolves environment variable syntax** (`${VAR:default}`) +The system automatically reads `OPENROUTER_API_KEY` from `.env` or environment. 3. **Applies environment variable overrides** via `env_overrides` map 4. **Merges with global defaults** from `config.yaml` 5. **Returns AgentConfig** object with complete configuration diff --git a/python/scripts/launch.py b/python/scripts/launch.py index b3634404e..067c901c2 100644 --- a/python/scripts/launch.py +++ b/python/scripts/launch.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Dict +from valuecell.utils.env import ensure_system_env_dir, get_system_env_path + # Mapping from agent name to analyst key (for ai-hedge-fund agents) MAP_NAME_ANALYST: Dict[str, str] = { "AswathDamodaranAgent": "aswath_damodaran", @@ -47,13 +49,14 @@ PROJECT_DIR = Path(__file__).resolve().parent.parent.parent PYTHON_DIR = PROJECT_DIR / "python" -ENV_PATH = PROJECT_DIR / ".env" +ENV_PATH = get_system_env_path() # Convert paths to POSIX format (forward slashes) for cross-platform compatibility # as_posix() works on both Windows and Unix systems PROJECT_DIR_STR = PROJECT_DIR.as_posix() PYTHON_DIR_STR = PYTHON_DIR.as_posix() -ENV_PATH_STR = ENV_PATH.as_posix() +# Quote path to handle spaces (e.g., macOS Application Support) +ENV_PATH_STR = f'"{ENV_PATH.as_posix()}"' AUTO_TRADING_ENV_OVERRIDES = { "AUTO_TRADING_EXCHANGE": os.getenv("AUTO_TRADING_EXCHANGE"), @@ -97,11 +100,24 @@ def check_envfile_is_set(): if not ENV_PATH.exists(): - print( - f".env file not found at {ENV_PATH}. Please create it with necessary environment variables. " - "check python/.env.example for reference." - ) - exit(1) + # Attempt to create system .env from repository example + example = PROJECT_DIR / ".env.example" + if example.exists(): + try: + import shutil + + ensure_system_env_dir() + shutil.copy(example, ENV_PATH) + print(f"Created system .env from example: {ENV_PATH}") + except Exception as e: + print(f"Failed to create system .env from example: {e}") + # Re-check after attempt + if not ENV_PATH.exists(): + print( + f"System .env not found at {ENV_PATH}. Please create it with necessary environment variables. " + "System paths — macOS: ~/Library/Application Support/ValueCell/.env; Linux: ~/.config/valuecell/.env; Windows: %APPDATA%\\ValueCell\\.env." + ) + exit(1) def main(): diff --git a/python/valuecell/__init__.py b/python/valuecell/__init__.py index eebb18e5c..240fcb311 100644 --- a/python/valuecell/__init__.py +++ b/python/valuecell/__init__.py @@ -16,57 +16,54 @@ import os from pathlib import Path +from valuecell.utils.env import ensure_system_env_dir, get_system_env_path + logger = logging.getLogger(__name__) def load_env_file_early() -> None: - """Load environment variables from .env file at package import time. - - Uses python-dotenv for reliable parsing and respects existing environment variables. - Looks for .env file in repository root (three levels up from this file). + """Load environment variables using only the system `.env` file. - Note: - - .env file variables override existing environment variables (override=True) - - This ensures LANG and other config vars from .env take precedence - - Debug logging can be enabled via VALUECELL_DEBUG=true - - Falls back to manual parsing if python-dotenv is unavailable + Behavior: + - If the system `.env` exists, load it with `override=True`. + - If it does not exist and the repository has `.env.example`, copy it to the system path and then load. + - Do not create or use the repository root `.env`. """ try: from dotenv import load_dotenv - # Look for .env file in repository root (up 3 levels from this file) + # Resolve system `.env` and fallback create from example current_dir = Path(__file__).parent project_root = current_dir.parent.parent.parent - env_file = project_root / ".env" + sys_env = get_system_env_path() example_file = project_root / ".env.example" - # If .env is missing but .env.example exists, copy it to create .env - if not env_file.exists() and example_file.exists(): - try: - import shutil + try: + import shutil - shutil.copy(example_file, env_file) + if not sys_env.exists() and example_file.exists(): + ensure_system_env_dir() + shutil.copy(example_file, sys_env) if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": - logger.info(f"✓ Created .env by copying .env.example to {env_file}") - except Exception as e: - # Only log errors if debug mode is enabled - if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": - logger.info(f"⚠️ Failed to copy .env.example to .env: {e}") + logger.info(f"✓ Created system .env from example: {sys_env}") + except Exception as e: + if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": + logger.info(f"⚠️ Failed to prepare system .env: {e}") - if env_file.exists(): + if sys_env.exists(): # Load with override=True to allow .env file to override system variables # This is especially important for LANG which is often set by the system - load_dotenv(env_file, override=True) + load_dotenv(sys_env, override=True) # Optional: Log successful loading if DEBUG is enabled if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": - logger.info(f"✓ Environment variables loaded from {env_file}") + logger.info(f"✓ Environment variables loaded from {sys_env}") logger.info(f" LANG: {os.environ.get('LANG', 'not set')}") logger.info(f" TIMEZONE: {os.environ.get('TIMEZONE', 'not set')}") else: # Only log if debug mode is enabled if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": - logger.info(f"ℹ️ No .env file found at {env_file}") + logger.info(f"ℹ️ No system .env file found at {sys_env}") except ImportError: # Fallback to manual parsing if python-dotenv is not available @@ -79,34 +76,25 @@ def load_env_file_early() -> None: def _load_env_file_manual() -> None: - """Fallback manual .env file parsing. - - This function provides a simple .env parser when python-dotenv is not available. - It overrides existing environment variables and handles basic quote removal. - - Note: - - Lines starting with # are treated as comments - - Only KEY=VALUE format is supported - - Environment variables are overwritten to match dotenv behavior - """ + """Fallback manual parsing: use only the system `.env`; create from example if needed.""" try: current_dir = Path(__file__).parent project_root = current_dir.parent.parent.parent - env_file = project_root / ".env" + sys_env = get_system_env_path() example_file = project_root / ".env.example" - # If .env is missing but .env.example exists, copy it to create .env - if not env_file.exists() and example_file.exists(): - try: - import shutil + try: + import shutil - shutil.copy(example_file, env_file) - except Exception: - # Fail silently to avoid breaking imports - pass + if not sys_env.exists() and example_file.exists(): + ensure_system_env_dir() + shutil.copy(example_file, sys_env) + except Exception: + # Fail silently to avoid breaking imports + pass - if env_file.exists(): - with open(env_file, "r", encoding="utf-8") as f: + if sys_env.exists(): + with open(sys_env, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: diff --git a/python/valuecell/server/api/app.py b/python/valuecell/server/api/app.py index 8d91cf04d..a2ce87cb8 100644 --- a/python/valuecell/server/api/app.py +++ b/python/valuecell/server/api/app.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from ...adapters.assets import get_adapter_manager +from ...utils.env import ensure_system_env_dir, get_system_env_path from ..config.settings import get_settings from ..db import init_database from .exceptions import ( @@ -36,37 +37,38 @@ from .schemas import AppInfoData, SuccessResponse -def _ensure_root_env_and_load() -> None: - """Ensure repo-root .env exists (copy from .env.example) and load it. +def _ensure_system_env_and_load() -> None: + """Ensure the system `.env` exists and is loaded; use only the system path. - This protects scenarios where the package-level early loader isn't executed - or resolves a different path (e.g., running via `uv run -m valuecell.server.main`). + Behavior: + - If the system `.env` exists, load it with `override=True`. + - If not, and the repository has `.env.example`, copy it to the system path and then load. + - Do not create or load the repository root `.env`. """ try: repo_root = Path(__file__).resolve().parents[4] - env_file = repo_root / ".env" + sys_env = get_system_env_path() example_file = repo_root / ".env.example" - # Create .env from example if missing - if not env_file.exists() and example_file.exists(): - try: - import shutil + try: + import shutil - shutil.copy(example_file, env_file) - except Exception: - # Best-effort; continue even if copy fails - pass + if not sys_env.exists() and example_file.exists(): + ensure_system_env_dir() + shutil.copy(example_file, sys_env) + except Exception: + pass - # Load .env into process environment - if env_file.exists(): + # Load system .env into process environment + if sys_env.exists(): try: from dotenv import load_dotenv - load_dotenv(env_file, override=True) + load_dotenv(sys_env, override=True) except Exception: # Fallback manual parsing try: - with open(env_file, "r", encoding="utf-8") as f: + with open(sys_env, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: @@ -88,7 +90,7 @@ def _ensure_root_env_and_load() -> None: def create_app() -> FastAPI: """Create and configure FastAPI application.""" # Ensure .env exists and is loaded before reading settings - _ensure_root_env_and_load() + _ensure_system_env_and_load() settings = get_settings() @asynccontextmanager diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index 8c7c1c53e..f6f67cd36 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -7,9 +7,10 @@ import yaml from fastapi import APIRouter, HTTPException, Query -from valuecell.config.constants import CONFIG_DIR, PROJECT_ROOT +from valuecell.config.constants import CONFIG_DIR from valuecell.config.loader import get_config_loader from valuecell.config.manager import get_config_manager +from valuecell.utils.env import get_system_env_path from ..schemas import LLMProviderConfigData, SuccessResponse from ..schemas.model import ( @@ -41,17 +42,20 @@ def create_models_router() -> APIRouter: # ---- Utility helpers (local to router) ---- def _env_paths() -> List[Path]: - """Return the repository root .env as the single source of truth. - - Only use repo-root/.env and do not write python/.env. - """ - repo_env = PROJECT_ROOT.parent / ".env" - return [repo_env] + """Return only system .env path for writes (single source of truth).""" + system_env = get_system_env_path() + return [system_env] def _set_env(key: str, value: str) -> bool: os.environ[key] = value updated_any = False for env_file in _env_paths(): + # Ensure parent directory exists for system env file + try: + env_file.parent.mkdir(parents=True, exist_ok=True) + except Exception: + # Best effort; continue even if directory creation fails + pass lines: List[str] = [] if env_file.exists(): with open(env_file, "r", encoding="utf-8") as f: diff --git a/python/valuecell/utils/env.py b/python/valuecell/utils/env.py index e2162088a..9f2564ce9 100644 --- a/python/valuecell/utils/env.py +++ b/python/valuecell/utils/env.py @@ -1,5 +1,60 @@ +"""Utilities for resolving system-level .env paths consistently across OSes. + +Provides helpers to locate the OS user configuration directory for ValueCell +and to construct the system `.env` file path. This centralizes path logic so +other modules can mirror or write environment variables consistently. +""" + import os +from pathlib import Path + + +def get_system_env_dir() -> Path: + """Return the OS user configuration directory for ValueCell. + + - macOS: ~/Library/Application Support/ValueCell + - Linux: ~/.config/valuecell + - Windows: %APPDATA%\ValueCell + """ + home = Path.home() + # Windows + if os.name == "nt": + appdata = os.getenv("APPDATA") + base = Path(appdata) if appdata else (home / "AppData" / "Roaming") + return base / "ValueCell" + # macOS (posix with darwin kernel) + if sys_platform_is_darwin(): + return home / "Library" / "Application Support" / "ValueCell" + # Linux and other Unix-like + return home / ".config" / "valuecell" + + +def get_system_env_path() -> Path: + """Return the full path to the system `.env` file.""" + return get_system_env_dir() / ".env" + + +def ensure_system_env_dir() -> Path: + """Ensure the system config directory exists and return it.""" + d = get_system_env_dir() + d.mkdir(parents=True, exist_ok=True) + return d + + +def sys_platform_is_darwin() -> bool: + """Detect macOS platform without importing `platform` globally.""" + try: + import sys + + return sys.platform == "darwin" + except Exception: + return False def agent_debug_mode_enabled() -> bool: - return os.getenv("AGENT_DEBUG_MODE", "false").lower() == "true" + """Return whether agent debug mode is enabled via environment. + + Checks `AGENT_DEBUG_MODE` first; falls back to `VALUECELL_DEBUG`. + """ + flag = os.getenv("AGENT_DEBUG_MODE", os.getenv("VALUECELL_DEBUG", "false")) + return str(flag).lower() == "true"