diff --git a/noxfile.py b/noxfile.py index da4fdaf3..8e326f34 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,8 +3,45 @@ The nox run are build in isolated environment that will be stored in .nox. to force the venv update, remove the .nox/xxx folder. """ +import tempfile +from pathlib import Path + import nox +# pysepal-api is not yet on PyPI; build a wheel from the local sibling repo when present +# so that pip's resolver can satisfy the pysepal-api>=0.1,<1 constraint via --find-links. +_PYSEPAL_API_LOCAL = Path(__file__).parent.parent / "pysepal-api" + + +def _preinstall_pysepal_api(session: nox.Session) -> None: + """Pre-install pysepal-api from the local sibling repo into the session venv. + + pysepal-api ships as a pre-release (0.1.0.dev0) until it hits PyPI. We + build a wheel on the fly and install it directly so that pip's subsequent + ``.[test]`` install sees the dependency already satisfied and does not try + to resolve it from PyPI. When pysepal-api is eventually published on PyPI + this helper becomes a no-op (the local dir will be absent in CI / fresh + checkouts). + """ + if not _PYSEPAL_API_LOCAL.is_dir(): + return + wheel_dir = Path(tempfile.mkdtemp(prefix="pysepal-api-wheel-")) + session.run( + "python", + "-m", + "pip", + "wheel", + "--no-deps", + "--wheel-dir", + str(wheel_dir), + str(_PYSEPAL_API_LOCAL), + silent=True, + ) + wheels = list(wheel_dir.glob("pysepal_api-*.whl")) + if wheels: + session.install(str(wheels[0])) + + nox.options.sessions = ["lint", "test", "docs"] @@ -18,6 +55,7 @@ def lint(session): @nox.session(reuse_venv=False) def test(session): """Run all the test using the environment variable of the running machine.""" + _preinstall_pysepal_api(session) session.install(".[test]") session.run("pip", "list") @@ -36,6 +74,7 @@ def test(session): @nox.session(reuse_venv=False) def test_gee(session): """Run GEE smoke tests. Requires EARTHENGINE_* credentials.""" + _preinstall_pysepal_api(session) session.install(".[test]") session.run("pip", "list") @@ -53,6 +92,7 @@ def test_gee(session): @nox.session(name="clean_gee_assets", reuse_venv=True) def clean_gee_assets(session): """Delete stale pysepal test assets from GEE. Dry-run by default; pass -- --yes to delete.""" + _preinstall_pysepal_api(session) session.install(".[test]") session.run("python", "-m", "tests._janitor", *session.posargs) @@ -60,6 +100,7 @@ def clean_gee_assets(session): @nox.session(name="dead-fixtures", reuse_venv=True) def dead_fixtures(session): """Check for dead fixtures items.""" + _preinstall_pysepal_api(session) session.install(".[test]") test_files = session.posargs or ["tests"] # Clear addopts ("-m 'not gee'") so the scan covers both test lanes; @@ -70,6 +111,7 @@ def dead_fixtures(session): @nox.session(reuse_venv=True) def bin(session): """Run all the bin methods to validate the conda recipe.""" + _preinstall_pysepal_api(session) session.install(".") session.run("module_deploy", "--help") session.run("module_factory", "--help") @@ -83,6 +125,7 @@ def bin(session): @nox.session(reuse_venv=True) def docs(session): """Build the documentation.""" + _preinstall_pysepal_api(session) session.install(".[doc]") # patch version in nox instead of pyproject to avoid blocking conda releases session.run("rm", "-rf", "docs/source/modules", external=True) @@ -105,6 +148,7 @@ def docs(session): @nox.session(reuse_venv=True) def mypy(session): """Run a mypy check of the lib.""" + _preinstall_pysepal_api(session) session.install(".[dev]") test_files = session.posargs or ["pysepal"] session.run( diff --git a/pyproject.toml b/pyproject.toml index 819a3c5e..fb2e9336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pygadm>=0.5.0", # use the class implementation # miscellaneous "python-box", + "pysepal-api>=0.1.0.dev0,<1", "tqdm", "Deprecated>=1.2.14", "anyascii", # to decode international names with non latin characters diff --git a/pysepal/scripts/sepal_client.py b/pysepal/scripts/sepal_client.py index 77aa5b9b..ec53f576 100644 --- a/pysepal/scripts/sepal_client.py +++ b/pysepal/scripts/sepal_client.py @@ -1,207 +1,14 @@ -"""Client for interacting with sepal userFiles API.""" +"""Compatibility shim for `pysepal.scripts.sepal_client`. -import os -from pathlib import Path, PurePosixPath -from typing import Any, Dict, List, Literal, Optional, Union +Re-exports `pysepal_api.compat.SepalClient`, which is a drop-in replacement +for the legacy v3 class (same constructor, same method names, same returned +shapes, same public attributes). A deprecation warning fires when callers +construct the class so import-time noise stays out of pysepal's +module-loading path. -import httpx +This shim is scheduled for removal in pysepal v4. +""" -from pysepal.logger import log +from pysepal_api.compat import SepalClient - -class SepalClient: - def __init__( - self, - session_id: str, - module_name: str, - sepal_host: Optional[str] = None, - create_base_dir: bool = True, - ): - """Initialize the Sepal HTTP client. - - Args: - session_id: The SEPAL session ID for authentication - module_name: The name of the module using the client, it creates the module results - directory if create_base_dir is True. - sepal_host: Optional SEPAL host, if None uses SEPAL_HOST environment variable - create_base_dir: If True, creates the base results directory for the module - """ - self.module_name = module_name - self.BASE_REMOTE_PATH = "/home/sepal-user" - - # Get SEPAL_HOST environment variable - self.sepal_host = sepal_host or os.getenv("SEPAL_HOST") - if not self.sepal_host: - raise ValueError("SEPAL_HOST environment variable not set") - - # Determine SSL verification based on host - self.verify_ssl = not ( - self.sepal_host == "host.docker.internal" or self.sepal_host == "danielg.sepal.io" - ) - - self.base_url = f"https://{self.sepal_host}/api/user-files" - self.cookies = {"SEPAL-SESSIONID": session_id} - self.headers = {"Accept": "application/json"} - - if create_base_dir: - self.results_path = self.create_base_dir() - # Maybe do a test? and check that the session is valid - # if not I will get this error: - # httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://danielg.sepal.io/api/user-files/listFiles/?path=%2F&extensions=' - - log.debug(f"SEPAL_CLIENT: SepalClient initialized, with results path: {self.results_path}") - - def rest_call( - self, - method: Literal["GET", "POST", "PUT"], - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[str] = None, - json: Optional[Dict[str, Any]] = None, - files: Optional[Dict[str, Any]] = None, - parse_json: bool = True, - ) -> Union[Dict[str, Any], bytes]: - """Make HTTP requests and handle JSON/binary responses.""" - url = f"{self.base_url}/{endpoint.lstrip('/')}" - - with httpx.Client(verify=self.verify_ssl) as client: - response = client.request( - method=method, - url=url, - params=params, - json=json, - cookies=self.cookies, - headers=self.headers, - files=files, - data=data, - ) - - # Handle 409 Conflict for createFolder and setFile endpoints - # This means the resource already exists and cannot be overwritten - if response.status_code == 409 and endpoint.rstrip("/") in ["createFolder", "setFile"]: - log.debug( - f"Resource already exists for {endpoint} (409 Conflict) - continuing normally" - ) - # Return empty dict for JSON responses or empty bytes for binary - return {} if parse_json else b"" - - response.raise_for_status() - - if parse_json: - return response.json() - else: - return response.content - - def create_base_dir(self) -> PurePosixPath: - """Create the base results directory and return the PurePosixPath object.""" - results_path = f"{self.BASE_REMOTE_PATH}/module_results/{self.module_name}" - try: - self.rest_call( - "POST", - "createFolder/", - params={"path": self.sanitize_path(results_path), "recursive": True}, - ) - except httpx.HTTPStatusError as e: - if e.response.status_code == 403: - log.debug( - f"Folder already exists: {results_path} (403 Forbidden) - continuing normally" - ) - else: - raise - - return PurePosixPath(results_path) - - def list_files( - self, folder: str = "/", extensions: Optional[List[str]] = None - ) -> Dict[str, Any]: - """List files in a specified folder with optional extension filtering. - - Args: - folder: The folder path to list files from - extensions: Optional list of file extensions to filter by - - Returns: - Dict containing the API response - """ - params = {"path": folder, "extensions": ",".join(extensions or [])} - return self.rest_call("GET", "listFiles/", params=params) - - def get_file(self, file_path: str, parse_json=False) -> bytes: - """Download a file from the specified folder. - - Args: - file_path: The file path to download - parse_json: If True, parse the response as JSON; otherwise return raw bytes - - Returns: - The file content as bytes - """ - return self.rest_call( - "GET", - "download/", - params={"path": self.sanitize_path(file_path)}, - parse_json=parse_json, - ) - - def set_file( - self, file_path: str, content: Union[str, bytes], overwrite: bool = False - ) -> Dict[str, Any]: - """Upload any content (text or binary) via multipart/form-data. - - Args: - file_path: The path where the file will be saved on the server - content: The content to upload, can be a string or bytes - overwrite: If True, allows overwriting existing files on the server - - """ - # ensure we have bytes - if isinstance(content, str): - payload = content.encode("utf-8") - else: - payload = content - - params = {"path": self.sanitize_path(file_path), "overwrite": str(overwrite).lower()} - - # pick MIME by extension - ext = Path(file_path).suffix.lower() - mime_map = { - ".json": "application/json", - ".csv": "text/csv", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xls": "application/vnd.ms-excel", - ".tif": "image/tiff", - ".tiff": "image/tiff", - } - mime = mime_map.get(ext, "application/octet-stream") - - files = {"file": (Path(file_path).name, payload, mime)} - - return self.rest_call("POST", "setFile/", params=params, files=files) - - def sanitize_path(self, file_path: Union[str, Path]) -> PurePosixPath: - """Sanitize a file path to be relative to the base remote path.""" - p = PurePosixPath(str(file_path)) - base = PurePosixPath(self.BASE_REMOTE_PATH) - - if p.is_absolute(): - try: - rel = p.relative_to(base) - except ValueError: - raise ValueError(f"sanitize_path: expected absolute under {base!r}, got {p!r}") - if ".." in rel.parts: - raise ValueError(f"sanitize_path: path traversal detected: {p!r}") - return rel - - if ".." in p.parts: - raise ValueError(f"sanitize_path: path traversal detected: {p!r}") - return p - - def get_remote_dir(self, folder: Union[str, Path], parents: bool = False) -> PurePosixPath: - """Create a remote directory and return its sanitized path.""" - sanitized_folder = self.sanitize_path(folder) - self.rest_call( - "POST", - "createFolder/", - params={"path": sanitized_folder, "recursive": parents}, - ) - return sanitized_folder +__all__ = ["SepalClient"] diff --git a/pysepal/solara/session_manager.py b/pysepal/solara/session_manager.py index a9e32cc7..ef98939b 100644 --- a/pysepal/solara/session_manager.py +++ b/pysepal/solara/session_manager.py @@ -130,6 +130,13 @@ def cleanup_session(self, kernel_id: str) -> None: except Exception as e: logger.error(f"Error closing GEE interface for kernel {kernel_id}: {e}") + try: + sepal_client = session.get("sepal_client") + if sepal_client is not None: + sepal_client.close() + except Exception as e: + logger.error(f"Error closing SepalClient for kernel {kernel_id}: {e}") + del self._sessions[kernel_id] logger.debug(f"Session cleaned up for kernel {kernel_id}")