Skip to content

Paths

Path helpers for the SEPAL workspace. module_results_path derives a module's results directory without touching the network — see The results directory for how it relates to ensure_results_dir().

pysepal_api.paths

Path handling for SEPAL user-files routes.

SEPAL user-files write/create endpoints expect POSIX paths relative to the sandbox user's home (/home/sepal-user). pysepal callers historically pass either absolute paths under that home or already-relative paths; both must work transparently. The listing endpoint additionally accepts "/" as a shortcut for the workspace root, which the server understands as ".".

All routes — read, write, create, and list — enforce the same sandbox confinement: absolute paths must live under /home/sepal-user, and .. traversal is rejected everywhere via the shared _reject_traversal helper.

BASE_REMOTE_PATH module-attribute

BASE_REMOTE_PATH = '/home/sepal-user'

MODULE_RESULTS_DIR module-attribute

MODULE_RESULTS_DIR = 'module_results'

module_results_relative

module_results_relative(module_name: str) -> PurePosixPath

Home-relative results directory for a module.

Frozen data-compatibility contract: module_name is used verbatim and is never rewritten. se.plan and sdg_indicators/15.4.2 map to the exact directories downstream apps already hardcode; rewriting either would strand every recipe and export a user has already saved.

Names that would escape module_results/ are rejected rather than rewritten, which keeps that contract intact for every legitimate name. The rejected forms are silent otherwise: an absolute name replaces the prefix outright (PurePosixPath("module_results") / "/etc" is /etc), and .. climbs out of it.

Source code in src/pysepal_api/paths.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def module_results_relative(module_name: str) -> PurePosixPath:
    """Home-relative results directory for a module.

    Frozen data-compatibility contract: ``module_name`` is used verbatim and is
    never rewritten. ``se.plan`` and ``sdg_indicators/15.4.2`` map to the exact
    directories downstream apps already hardcode; rewriting either would strand
    every recipe and export a user has already saved.

    Names that would escape ``module_results/`` are rejected rather than
    rewritten, which keeps that contract intact for every legitimate name. The
    rejected forms are silent otherwise: an absolute name replaces the prefix
    outright (``PurePosixPath("module_results") / "/etc"`` is ``/etc``), and
    ``..`` climbs out of it.
    """
    if not module_name:
        raise InvalidPathError("module_results_relative: module name is empty")
    relative = PurePosixPath(module_name)
    if relative.is_absolute():
        raise InvalidPathError(
            f"module_results_relative: module name must be relative, got {module_name!r}"
        )
    _reject_traversal(relative, origin="module_results_relative")
    return PurePosixPath(MODULE_RESULTS_DIR) / relative

module_results_path

module_results_path(module_name: str) -> PurePosixPath

Absolute remote results directory for a module — pure, no I/O.

Source code in src/pysepal_api/paths.py
50
51
52
def module_results_path(module_name: str) -> PurePosixPath:
    """Absolute remote results directory for a module — pure, no I/O."""
    return PurePosixPath(BASE_REMOTE_PATH) / module_results_relative(module_name)

sanitize_write_path

sanitize_write_path(
    file_path: str | PurePosixPath,
) -> PurePosixPath

Sanitize a path for download/write/create endpoints.

Rules: - Absolute paths must live under /home/sepal-user; the prefix is stripped. - Relative paths pass through. - .. traversal in either form is rejected. - Anything else absolute is rejected.

Source code in src/pysepal_api/paths.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def sanitize_write_path(file_path: str | PurePosixPath) -> PurePosixPath:
    """Sanitize a path for download/write/create endpoints.

    Rules:
    - Absolute paths must live under `/home/sepal-user`; the prefix is stripped.
    - Relative paths pass through.
    - `..` traversal in either form is rejected.
    - Anything else absolute is rejected.
    """
    p = PurePosixPath(str(file_path))
    if p.is_absolute():
        rel = _strip_home_prefix(p, origin="sanitize_write_path")
        _reject_traversal(rel, origin="sanitize_write_path")
        return rel
    _reject_traversal(p, origin="sanitize_write_path")
    return p

normalize_list_folder

normalize_list_folder(folder: str | PurePosixPath) -> str

Normalize a folder argument for the listFiles endpoint.

/ is treated as a pysepal compatibility alias for the workspace root and becomes ".". Absolute paths under /home/sepal-user are stripped to relative form. Relative paths pass through. .. traversal is rejected, so listing enforces the same sandbox confinement as the write/read routes.

Source code in src/pysepal_api/paths.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def normalize_list_folder(folder: str | PurePosixPath) -> str:
    """Normalize a folder argument for the `listFiles` endpoint.

    `/` is treated as a pysepal compatibility alias for the workspace root and
    becomes `"."`. Absolute paths under `/home/sepal-user` are stripped to
    relative form. Relative paths pass through. `..` traversal is rejected, so
    listing enforces the same sandbox confinement as the write/read routes.
    """
    s = str(folder)
    if s in ("", "/"):
        return "."
    p = PurePosixPath(s)
    if p.is_absolute():
        rel = _strip_home_prefix(p, origin="normalize_list_folder")
        _reject_traversal(rel, origin="normalize_list_folder")
        return str(rel) or "."
    _reject_traversal(p, origin="normalize_list_folder")
    return s