diff --git a/pueblo/sfa/README.md b/pueblo/sfa/README.md index 4219558..003a545 100644 --- a/pueblo/sfa/README.md +++ b/pueblo/sfa/README.md @@ -25,6 +25,8 @@ not be used together. # Invoke Python entrypoint with given specification. PYTHONPATH=$(pwd) sfa run tests.testdata.entrypoint:main sfa run tests/testdata/entrypoint.py:main +sfa run https://github.com/pyveci/pueblo/raw/refs/heads/sfa/tests/testdata/entrypoint.py#main +sfa run github://pyveci:pueblo@/tests/testdata/entrypoint.py#main ``` diff --git a/pueblo/sfa/core.py b/pueblo/sfa/core.py index 4b2c628..3e7c61e 100644 --- a/pueblo/sfa/core.py +++ b/pueblo/sfa/core.py @@ -1,12 +1,16 @@ import importlib.util +import logging import sys import typing as t from pathlib import Path +from tempfile import NamedTemporaryFile from types import ModuleType from urllib.parse import urlparse from attrs import define +logger = logging.getLogger(__name__) + class InvalidTarget(Exception): pass @@ -29,6 +33,7 @@ def from_spec(cls, spec: str, default_property=None): :param default_property: Name of the property to load if not specified in target (default: "api") :return: """ + is_url = False if cls.is_valid_url(spec): # Decode launch target location address from URL. # URL: https://example.org/acme/app.py#foo @@ -51,7 +56,6 @@ def from_spec(cls, spec: str, default_property=None): if default_property is None: raise ValueError("Property can not be discovered, and no default property was supplied") prop = default_property - is_url = False return cls(target=target, property=prop, is_url=is_url) @@ -102,9 +106,10 @@ def run(self, *args, **kwargs): def load(self): target = self.address.target prop = self.address.property + is_url = self.address.is_url # Sanity checks, as suggested by @coderabbitai. Thanks. - if not target or (":" in target and len(target.split(":")) != 2): + if not is_url and (not target or (":" in target and len(target.split(":")) != 2)): raise InvalidTarget( f"Invalid target format: {target}. " "Use either a Python module entrypoint specification, " @@ -139,13 +144,29 @@ def load(self): def load_any(self): if self.address.is_url: - mod = None + import fsspec + + url = urlparse(self.address.target) + url_path = Path(url.path) + name = "_".join([url_path.parent.stem, url_path.stem]) + suffix = url_path.suffix + app_file = NamedTemporaryFile(prefix=f"{name}_", suffix=suffix, delete=False) + target = app_file.name + logger.info(f"Loading remote single-file application, source: {url}") + logger.info(f"Writing remote single-file application, target: {target}") + fs = fsspec.open(f"simplecache::{self.address.target}") + with fs as f: + app_file.write(f.read()) + app_file.flush() + path = Path(app_file.name) else: path = Path(self.address.target) - if path.is_file(): - mod = self.load_file(path) - else: - mod = importlib.import_module(self.address.target) + + if path.is_file(): + mod = self.load_file(path) + else: + mod = importlib.import_module(self.address.target) + self._module = mod @staticmethod diff --git a/pyproject.toml b/pyproject.toml index f1eb5ba..8324204 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,8 @@ optional-dependencies.release = [ ] optional-dependencies.sfa = [ "attrs", + "fsspec[github,http,libarchive,s3]<2024.11", + "tomli<3", ] optional-dependencies.test = [ "pueblo[testing]", diff --git a/tests/sfa/__init__.py b/tests/sfa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testdata/entrypoint.py b/tests/sfa/basic.py similarity index 100% rename from tests/testdata/entrypoint.py rename to tests/sfa/basic.py diff --git a/tests/test_sfa.py b/tests/test_sfa.py index 39bbb7a..08573a6 100644 --- a/tests/test_sfa.py +++ b/tests/test_sfa.py @@ -35,8 +35,9 @@ def test_address_url(): @pytest.mark.parametrize( "spec", [ - "tests.testdata.entrypoint:main", - "tests/testdata/entrypoint.py:main", + "tests.sfa.basic:main", + "tests/sfa/basic.py:main", + "https://github.com/pyveci/pueblo/raw/refs/heads/sfa/tests/sfa/basic.py#main", ], ) def test_application_api_success(capsys, spec): @@ -53,6 +54,7 @@ def test_application_api_success(capsys, spec): [ "pueblo.context:pueblo_cache_path", "pueblo/context.py:pueblo_cache_path", + "https://github.com/pyveci/pueblo/raw/refs/heads/main/pueblo/context.py#pueblo_cache_path", ], ) def test_application_api_not_callable(capsys, spec): @@ -65,8 +67,9 @@ def test_application_api_not_callable(capsys, spec): @pytest.mark.parametrize( "spec", [ - "tests.testdata.entrypoint:main", - "tests/testdata/entrypoint.py:main", + "tests.sfa.basic:main", + "tests/sfa/basic.py:main", + "https://github.com/pyveci/pueblo/raw/refs/heads/sfa/tests/sfa/basic.py#main", ], ) def test_application_cli(mocker, capfd, spec):