Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand All @@ -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")

Expand All @@ -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")

Expand All @@ -53,13 +92,15 @@ 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)


@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;
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
213 changes: 10 additions & 203 deletions pysepal/scripts/sepal_client.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 7 additions & 0 deletions pysepal/solara/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
Loading