From 71e04ca2272d504583e004650394fe25ec2d480b Mon Sep 17 00:00:00 2001 From: leon Date: Fri, 27 Mar 2026 16:12:41 +0800 Subject: [PATCH 1/2] Refactor CLI version path lazily --- opencontext/cli.py | 153 ++++++++++++++++-------------- opencontext/server/opencontext.py | 7 +- tests/test_cli_version.py | 67 +++++++++++++ 3 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 tests/test_cli_version.py diff --git a/opencontext/cli.py b/opencontext/cli.py index d1a9e1e8..feaa97ba 100755 --- a/opencontext/cli.py +++ b/opencontext/cli.py @@ -14,17 +14,7 @@ from pathlib import Path from typing import Optional -import uvicorn -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles - -from opencontext.config.config_manager import ConfigManager -from opencontext.server.api import router as api_router -from opencontext.server.opencontext import OpenContext -from opencontext.utils.logging_utils import get_logger, setup_logging - -logger = get_logger(__name__) +from opencontext import __version__ # Global variables for multi-process support _config_path = None @@ -42,43 +32,24 @@ def get_or_create_context_lab(): @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app): """Lifespan context manager for FastAPI.""" - # Startup if not hasattr(app.state, "context_lab_instance"): app.state.context_lab_instance = get_or_create_context_lab() yield - # Shutdown - cleanup if needed - pass - - -app = FastAPI(title="OpenContext", version="1.0.0", lifespan=lifespan) - -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:5173", - "http://localhost"], # React dev server - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Project root -if hasattr(sys, "_MEIPASS"): - project_root = Path(sys._MEIPASS) -else: - project_root = Path(__file__).parent.parent.parent.resolve() def _get_project_root() -> Path: """Get the project root directory.""" - return project_root + if hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS) + return Path(__file__).parent.parent.parent.resolve() -def _setup_static_files() -> None: +def _mount_static_files(app) -> None: """Setup static file mounts for the FastAPI app.""" - # Mount static files + from fastapi.staticfiles import StaticFiles + if hasattr(sys, "_MEIPASS"): static_path = Path(sys._MEIPASS) / "opencontext/web/static" else: @@ -89,26 +60,41 @@ def _setup_static_files() -> None: print(f"Static path absolute: {static_path.resolve()}") if static_path.exists(): - app.mount("/static", StaticFiles(directory=str(static_path)), - name="static") + app.mount("/static", StaticFiles(directory=str(static_path)), name="static") print(f"Mounted static files from: {static_path}") else: print(f"Static path does not exist: {static_path}") - # Mount screenshots directory screenshots_path = Path("./screenshots").resolve() if screenshots_path.exists(): - app.mount("/screenshots", - StaticFiles(directory=screenshots_path), name="screenshots") + app.mount( + "/screenshots", + StaticFiles(directory=screenshots_path), + name="screenshots", + ) -_setup_static_files() +def create_app(): + """Create and configure the FastAPI app.""" + from fastapi import FastAPI + from fastapi.middleware.cors import CORSMiddleware + from opencontext.server.api import router as api_router -app.include_router(api_router) + app = FastAPI(title="OpenContext", version=__version__, lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + _mount_static_files(app) + app.include_router(api_router) + return app def start_web_server( - context_lab_instance: OpenContext, + context_lab_instance, host: str, port: int, workers: int = 1, @@ -123,21 +109,30 @@ def start_web_server( workers: Number of worker processes config_path: Configuration file path for multi-process mode """ + import uvicorn + + logger = _get_logger() + global _config_path _config_path = config_path if workers > 1: logger.info(f"Starting with {workers} worker processes") - # For multi-process mode, use import string to avoid the warning - uvicorn.run("opencontext.cli:app", host=host, port=port, - log_level="info", workers=workers) + uvicorn.run( + "opencontext.cli:create_app", + host=host, + port=port, + log_level="info", + workers=workers, + factory=True, + ) else: - # For single process mode, use the existing instance + app = create_app() app.state.context_lab_instance = context_lab_instance uvicorn.run(app, host=host, port=port, log_level="info") -def parse_args() -> argparse.Namespace: +def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: """Parse command line arguments. Returns: @@ -146,27 +141,33 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="OpenContext - Context capture, processing, storage and consumption system" ) + parser.add_argument( + "--version", + action="version", + version=f"opencontext {__version__}", + ) - subparsers = parser.add_subparsers( - dest="command", help="Available commands") + subparsers = parser.add_subparsers(dest="command", help="Available commands") - # Start command - start_parser = subparsers.add_parser( - "start", help="Start OpenContext server") - start_parser.add_argument("--config", type=str, - help="Configuration file path") + start_parser = subparsers.add_parser("start", help="Start OpenContext server") + start_parser.add_argument("--config", type=str, help="Configuration file path") start_parser.add_argument( - "--host", type=str, help="Host address (overrides config file)") + "--host", type=str, help="Host address (overrides config file)" + ) start_parser.add_argument( - "--port", type=int, help="Port number (overrides config file)") + "--port", type=int, help="Port number (overrides config file)" + ) start_parser.add_argument( - "--workers", type=int, default=1, help="Number of worker processes (default: 1)" + "--workers", + type=int, + default=1, + help="Number of worker processes (default: 1)", ) - return parser.parse_args() + return parser.parse_args(argv) -def _initialize_context_lab(config_path: Optional[str]) -> OpenContext: +def _initialize_context_lab(config_path: Optional[str]): """Initialize the OpenContext instance. Args: @@ -178,6 +179,10 @@ def _initialize_context_lab(config_path: Optional[str]) -> OpenContext: Raises: RuntimeError: If initialization fails """ + from opencontext.server.opencontext import OpenContext + + logger = _get_logger() + try: lab_instance = OpenContext(config_path=config_path) lab_instance.initialize() @@ -187,12 +192,14 @@ def _initialize_context_lab(config_path: Optional[str]) -> OpenContext: raise RuntimeError(f"OpenContext initialization failed: {e}") from e -def _run_headless_mode(lab_instance: OpenContext) -> None: +def _run_headless_mode(lab_instance) -> None: """Run in headless mode without web server. Args: lab_instance: The opencontext instance """ + logger = _get_logger() + try: logger.info("Running in headless mode. Press Ctrl+C to exit.") while True: @@ -211,6 +218,8 @@ def handle_start(args: argparse.Namespace) -> int: Returns: Exit code (0 for success, 1 for failure) """ + logger = _get_logger() + try: lab_instance = _initialize_context_lab(args.config) except RuntimeError: @@ -223,7 +232,6 @@ def handle_start(args: argparse.Namespace) -> int: web_config = get_config("web") if web_config.get("enabled", True): - # Command line arguments override config file host = args.host if args.host else web_config.get("host", "localhost") port = args.port if args.port else web_config.get("port", 1733) @@ -247,12 +255,19 @@ def _setup_logging(config_path: Optional[str]) -> None: config_path: Optional path to configuration file """ from opencontext.config.global_config import GlobalConfig + from opencontext.utils.logging_utils import setup_logging GlobalConfig.get_instance().initialize(config_path) - setup_logging(GlobalConfig.get_instance().get_config("logging")) +def _get_logger(): + """Create the CLI logger lazily.""" + from opencontext.utils.logging_utils import get_logger + + return get_logger(__name__) + + def main() -> int: """Main entry point. @@ -261,9 +276,9 @@ def main() -> int: """ args = parse_args() - # Setup logging first _setup_logging(getattr(args, "config", None)) + logger = _get_logger() logger.debug(f"Command line arguments: {args}") if not args.command: @@ -274,9 +289,9 @@ def main() -> int: if args.command == "start": return handle_start(args) - else: - logger.error(f"Unknown command: {args.command}") - return 1 + + logger.error(f"Unknown command: {args.command}") + return 1 if __name__ == "__main__": diff --git a/opencontext/server/opencontext.py b/opencontext/server/opencontext.py index f676cef2..dd688f87 100755 --- a/opencontext/server/opencontext.py +++ b/opencontext/server/opencontext.py @@ -291,7 +291,12 @@ def main(): print(f"Using config file: {args.config}") uvicorn.run( - "opencontext.cli:app", host=args.host, port=args.port, reload=args.reload, log_level="info" + "opencontext.cli:create_app", + host=args.host, + port=args.port, + reload=args.reload, + log_level="info", + factory=True, ) diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py new file mode 100644 index 00000000..f1b382a0 --- /dev/null +++ b/tests/test_cli_version.py @@ -0,0 +1,67 @@ +import os +import subprocess +import sys +import tempfile +from pathlib import Path +import unittest + +from opencontext import __version__ + + +class TestCliVersion(unittest.TestCase): + expected_version_output = f"opencontext {__version__}\n" + + def setUp(self): + self.project_root = Path(__file__).resolve().parents[1] + + def test_version_flag_prints_version(self): + result = subprocess.run( + [sys.executable, "-m", "opencontext.cli", "--version"], + capture_output=True, + text=True, + cwd=self.project_root, + timeout=10, + ) + + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, self.expected_version_output) + self.assertEqual(result.stderr, "") + + def test_version_flag_succeeds_when_fastapi_and_uvicorn_imports_are_blocked(self): + with tempfile.TemporaryDirectory() as temp_dir: + sitecustomize = Path(temp_dir) / "sitecustomize.py" + sitecustomize.write_text( + """import builtins +_real_import = builtins.__import__ + +def guarded_import(name, *args, **kwargs): + if ( + name in {"fastapi", "uvicorn"} + or name.startswith("fastapi.") + or name.startswith("uvicorn.") + ): + raise ImportError(f"blocked import: {name}") + return _real_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import +""", + encoding="utf-8", + ) + + env = os.environ.copy() + env["PYTHONPATH"] = temp_dir if "PYTHONPATH" not in env else ( + os.pathsep.join([temp_dir, env["PYTHONPATH"]]) + ) + + result = subprocess.run( + [sys.executable, "-m", "opencontext.cli", "--version"], + capture_output=True, + text=True, + cwd=self.project_root, + env=env, + timeout=10, + ) + + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, self.expected_version_output) + self.assertEqual(result.stderr, "") From c330d863c8d665083b281d67fe6802fcb8a9f22e Mon Sep 17 00:00:00 2001 From: leon Date: Fri, 27 Mar 2026 16:29:51 +0800 Subject: [PATCH 2/2] Restore lazy cli app compatibility shim --- opencontext/cli.py | 21 +++++++++++++ tests/test_cli_version.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/opencontext/cli.py b/opencontext/cli.py index feaa97ba..7f7a787e 100755 --- a/opencontext/cli.py +++ b/opencontext/cli.py @@ -21,6 +21,27 @@ _context_lab_instance = None +class _LazyApp: + """Compatibility shim that defers app creation until first use.""" + + def __init__(self) -> None: + self._app = None + + def _get_app(self): + if self._app is None: + self._app = create_app() + return self._app + + def __getattr__(self, name): + return getattr(self._get_app(), name) + + async def __call__(self, scope, receive, send): + await self._get_app()(scope, receive, send) + + +app = _LazyApp() + + def get_or_create_context_lab(): """Get or create the global OpenContext instance for the current process.""" global _context_lab_instance, _config_path diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py index f1b382a0..8721b4fa 100644 --- a/tests/test_cli_version.py +++ b/tests/test_cli_version.py @@ -65,3 +65,67 @@ def guarded_import(name, *args, **kwargs): self.assertEqual(result.returncode, 0) self.assertEqual(result.stdout, self.expected_version_output) self.assertEqual(result.stderr, "") + + def test_app_compatibility_shim_exists_without_eager_runtime_imports(self): + with tempfile.TemporaryDirectory() as temp_dir: + sitecustomize = Path(temp_dir) / "sitecustomize.py" + sitecustomize.write_text( + """import builtins +import json +import sys + +real_import = builtins.__import__ +blocked = [] + +def guarded_import(name, *args, **kwargs): + if ( + name in {"fastapi", "uvicorn"} + or name.startswith("fastapi.") + or name.startswith("uvicorn.") + ): + blocked.append(name) + raise ImportError(f"blocked import: {name}") + return real_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import + +def print_result(payload): + print(json.dumps(payload, sort_keys=True)) +""", + encoding="utf-8", + ) + + env = os.environ.copy() + env["PYTHONPATH"] = temp_dir if "PYTHONPATH" not in env else ( + os.pathsep.join([temp_dir, env["PYTHONPATH"]]) + ) + + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sitecustomize; " + "import opencontext.cli as cli; " + "sitecustomize.print_result({" + "'has_app': hasattr(cli, 'app'), " + "'app_type': type(cli.app).__name__, " + "'fastapi_loaded': 'fastapi' in __import__('sys').modules, " + "'uvicorn_loaded': 'uvicorn' in __import__('sys').modules, " + "'blocked': sitecustomize.blocked" + "})" + ), + ], + capture_output=True, + text=True, + cwd=self.project_root, + env=env, + timeout=10, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + payload = __import__("json").loads(result.stdout) + self.assertTrue(payload["has_app"]) + self.assertFalse(payload["fastapi_loaded"]) + self.assertFalse(payload["uvicorn_loaded"]) + self.assertEqual(payload["blocked"], [])