From 22881a3112c510e47ce0ec7561520f887087b750 Mon Sep 17 00:00:00 2001 From: Andreas Motl <andreas.motl@panodata.org> Date: Sun, 19 Jan 2025 00:21:08 +0100 Subject: [PATCH] SFA: Use application loader from `pueblo.sfa` --- docs/source/cli.rst | 6 +- responder/util/common.py | 9 --- responder/util/python.py | 129 +++------------------------------------ setup.py | 9 ++- 4 files changed, 20 insertions(+), 133 deletions(-) delete mode 100644 responder/util/common.py diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 47f3e7e..9298642 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -68,7 +68,11 @@ Launch Remote File ------------------ You can also launch a single-file application where its Python file is stored -on a remote location. +on a remote location after installing the ``cli-full`` extra. + +.. code-block:: shell + + uv pip install 'responder[cli-full]' Responder supports all filesystem adapters compatible with `fsspec`_, and installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs), diff --git a/responder/util/common.py b/responder/util/common.py deleted file mode 100644 index f8389cb..0000000 --- a/responder/util/common.py +++ /dev/null @@ -1,9 +0,0 @@ -from urllib.parse import urlparse - - -def is_valid_url(url): - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except ValueError: - return False diff --git a/responder/util/python.py b/responder/util/python.py index 1953ed4..2a2afd6 100644 --- a/responder/util/python.py +++ b/responder/util/python.py @@ -1,32 +1,17 @@ -import importlib -import importlib.util import logging -import sys import typing as t -import uuid -from pathlib import Path -from tempfile import NamedTemporaryFile -from types import ModuleType -from upath import UPath +from pueblo.sfa.core import InvalidTarget, SingleFileApplication -from responder.util.common import is_valid_url +__all__ = [ + "InvalidTarget", + "SingleFileApplication", + "load_target", +] logger = logging.getLogger(__name__) -class InvalidTarget(Exception): - """ - Raised when the target specification format is invalid. - - This exception is raised when the target string does not conform to the expected - format of either a module path (e.g., 'acme.app:foo') or a file path - (e.g., '/path/to/acme/app.py'). - """ - - pass - - def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any: """ Load Python code from a file path or module name. @@ -54,102 +39,6 @@ def load_target(target: str, default_property: str = "api", method: str = "run") >>> api.run() """ # noqa: E501 - app_file = None - if is_valid_url(target): - upath = UPath(target) - frag = upath._url.fragment - suffix = upath.suffix - suffix = suffix.replace(f"#{frag}", "") - logger.info(f"Loading remote single-file application, source: {upath}") - name = "_".join([upath.parent.stem, upath.stem]) - app_file = NamedTemporaryFile(prefix=f"{name}_", suffix=suffix, delete=False) - target = app_file.name - if frag: - target = f"{app_file.name}:{frag}" - logger.info(f"Writing remote single-file application, target: {target}") - app_file.write(upath.read_bytes()) - app_file.flush() - - # Sanity checks, as suggested by @coderabbitai. Thanks. - if not target or (":" in target and len(target.split(":")) != 2): - raise InvalidTarget(f"Invalid target format: {target}") - - # Decode launch target location address. - # Module: acme.app:foo - # Path: /path/to/acme/app.py:foo - target_fragments = target.split(":") - if len(target_fragments) > 1: - target = target_fragments[0] - prop = target_fragments[1] - else: - prop = default_property - - # Validate property name follows Python identifier rules. - if not prop.isidentifier(): - raise ValueError(f"Invalid property name: {prop}") - - # Import launch target. Treat input location either as a filesystem path - # (/path/to/acme/app.py), or as a module address specification (acme.app). - path = Path(target) - if path.is_file(): - app = load_file_module(path) - else: - app = importlib.import_module(target) - - # Invoke launch target. - msg_prefix = f"Failed to import target '{target}'" - try: - api = getattr(app, prop, None) - if api is None: - raise AttributeError(f"Module has no API instance attribute '{prop}'") - if not hasattr(api, method): - raise AttributeError(f"API instance '{prop}' has no method '{method}'") - return api - except ImportError as ex: - raise ImportError(f"{msg_prefix}: {ex}") from ex - except AttributeError as ex: - raise AttributeError(f"{msg_prefix}: {ex}") from ex - except Exception as ex: - raise RuntimeError(f"{msg_prefix}: Unexpected error: {ex}") from ex - - -def load_file_module(path: Path) -> ModuleType: - """ - Load a Python file as a module using importlib. - - Args: - path: Path to the Python file to load - - Returns: - The loaded module object - - Raises: - ImportError: If the module cannot be loaded - """ - - # Validate file extension - if path.suffix != ".py": - raise ValueError(f"File must have .py extension: {path}") - - # Use unique surrogate module name. - unique_id = uuid.uuid4().hex - name = f"__{path.stem}_{unique_id}__" - - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Failed loading module from file: {path}") - app = importlib.util.module_from_spec(spec) - sys.modules[name] = app - try: - spec.loader.exec_module(app) - return app - except (ImportError, SyntaxError) as ex: - sys.modules.pop(name, None) - raise ImportError( - f"Failed to execute module '{app}': {ex.__class__.__name__}: {ex}" - ) from ex - except Exception as ex: - sys.modules.pop(name, None) - raise RuntimeError( - f"Unexpected error executing module '{app}': {ex.__class__.__name__}: {ex}" - ) from ex + app = SingleFileApplication.from_spec(spec=target, default_property=default_property) + app.load() + return app.entrypoint diff --git a/setup.py b/setup.py index 055801f..03e3449 100644 --- a/setup.py +++ b/setup.py @@ -121,8 +121,11 @@ def run(self): extras_require={ "cli": [ "docopt-ng", - "fsspec[abfs,gcs,github,http,libarchive,s3]", - "universal-pathlib", + "pueblo[sfa] @ git+https://github.com/pyveci/pueblo@sfa" + ], + "cli-full": [ + "responder[cli]", + "pueblo[sfa-full] @ git+https://github.com/pyveci/pueblo@sfa" ], "develop": [ "poethepoet", @@ -138,7 +141,7 @@ def run(self): "sphinx-design-elements", "sphinxext.opengraph", ], - "full": ["responder[cli,graphql,openapi]"], + "full": ["responder[cli-full,graphql,openapi]"], "graphql": ["graphene<3", "graphql-server-core>=1.2,<2"], "openapi": ["apispec>=1.0.0"], "release": ["build", "twine"],