pipefy-infra 0.3.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Ruff stuff:
10
+ .ruff_cache/
11
+
12
+ # Unit test / coverage reports
13
+ htmlcov/
14
+ .tox/
15
+ .nox/
16
+ .coverage
17
+ .coverage.*
18
+ .cache
19
+ nosetests.xml
20
+ coverage.xml
21
+ *.cover
22
+ *.py,cover
23
+ .hypothesis/
24
+ .pytest_cache/
25
+ cover/
26
+
27
+ # Environments
28
+ .env
29
+ .venv
30
+ env/
31
+ venv/
32
+ ENV/
33
+ env.bak/
34
+ venv.bak/
35
+ .mise.toml
36
+
37
+ # Ralph Wiggum (local autonomous runs; state must not land in git)
38
+ .ralph/
39
+ RALPH_TASK.md
40
+
41
+ # IDEs and editors
42
+ .cursor/
43
+ .claude/
44
+ # Cursor MCP workspace config (machine-specific paths or local server entries).
45
+ # Carve-out: repo-root .mcp.json is the Claude Code plugin's MCP server config
46
+ # (auto-discovered at plugin root) and is intentionally tracked.
47
+ .mcp.json
48
+ !/.mcp.json
49
+
50
+ # Tracked release tooling only (ignore ad-hoc local scripts and smoke artifacts)
51
+ scripts/*
52
+ !scripts/bump_version.py
53
+
54
+ # macOS
55
+ .DS_Store
56
+ **/.DS_Store
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipefy-infra
3
+ Version: 0.3.0a1
4
+ Summary: Shared infra (TOML config loader, path discovery, SSRF defenses, string helpers) for Pipefy SDK and Auth.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pydantic-settings>=2.8.1
7
+ Requires-Dist: pydantic<3,>=2.13.4
8
+ Description-Content-Type: text/markdown
9
+
10
+ # pipefy-infra
11
+
12
+ Schema-agnostic infrastructure helpers shared by `pipefy` and `pipefy-auth`. Sits at the bottom of the workspace dependency graph; depends only on stdlib + `pydantic` / `pydantic-settings`.
13
+
14
+ Adapter layer between Pipefy's application code (`pipefy`, `pipefy-auth`) and external concerns (filesystem, OS, network boundary). Each submodule owns one bounded context; the package root exposes only `__version__`.
15
+
16
+ ## `pipefy_infra.config`
17
+
18
+ Pipefy on-disk configuration: where it lives and how it's read.
19
+
20
+ - `config_dir() -> Path`. Resolves the OS-appropriate config directory (`$XDG_CONFIG_HOME/pipefy` on POSIX, `%APPDATA%\pipefy` on Windows, falling back to `~/.config/pipefy` when `XDG_CONFIG_HOME` is unset).
21
+ - `config_file_path() -> Path`. Resolves the TOML file path, honouring the `PIPEFY_CONFIG_FILE` environment override.
22
+ - `PipefyTomlConfigSource`. A `pydantic-settings` source that loads top-level TOML keys from `config_file_path()`. The source knows nothing about specific field names; each consuming `BaseSettings` subclass filters via its own field definitions plus `extra="ignore"`.
23
+
24
+ ```python
25
+ from pipefy_infra.config import config_dir, config_file_path, PipefyTomlConfigSource
26
+ ```
27
+
28
+ ## `pipefy_infra.security`
29
+
30
+ SSRF defenses on URLs destined for outbound HTTP. Layered gates: shape regex at field declaration, synchronous internal-IP check at settings construction, asynchronous DNS-rebinding check at request time.
31
+
32
+ - `URL_SHAPE_PATTERN`. Regex for `Field(..., pattern=...)` on URL settings fields.
33
+ - `validate_https_url(url, field_label, *, allow_insecure=False)`. Synchronous scheme + literal-IP gate. Enforces HTTPS and rejects literal IPs in private/loopback/link-local/multicast/reserved/unspecified ranges. With `allow_insecure=True` (driven by `PIPEFY_ALLOW_INSECURE_URLS`) both http and the literal-IP gate are skipped for dev mode; production callers must follow up with the async DNS gate.
34
+ - `assert_hostname_is_not_internal(hostname, *, context)`. Rejects localhost and literal IPs in blocked ranges.
35
+ - `assert_hostname_resolves_to_public_ips(hostname)`. Asynchronous DNS gate used right before issuing a request; defends against DNS-rebinding.
36
+ - `validate_and_assert_public_url(url, *, field_label, allow_insecure=False) -> str`. Composite helper that runs the sync gate plus the DNS gate in one call and returns the validated hostname. Use from any new outbound-URL surface.
37
+ - `assert_url_is_host_root(url, *, field_label)`. Rejects non-root paths (including `//`, `///`), query strings, and fragments. For base-URL fields that derive endpoints via f-string concatenation.
38
+ - `assert_url_has_no_query_or_fragment(url, *, field_label)`. Path is allowed; rejects only query and fragment. For URLs that legitimately have a path (OIDC issuer URLs with a realm path) but where a stray query/fragment would corrupt downstream concatenation.
39
+
40
+ This is the **SSRF audit namespace**: import the module and call through it so every call site is greppable for audits, matching the stdlib idiom (`hmac.compare_digest`, `secrets.token_urlsafe`).
41
+
42
+ ```python
43
+ from pipefy_infra import security
44
+
45
+ security.validate_https_url(url, "graphql_url", allow_insecure=False)
46
+ await security.assert_hostname_resolves_to_public_ips(host)
47
+ ```
48
+
49
+ ## Field definitions
50
+
51
+ This package owns **no schema**. Field definitions live with the settings models that use them (`pipefy_auth.AuthSettings`, `pipefy_sdk.PipefySettings`).
@@ -0,0 +1,42 @@
1
+ # pipefy-infra
2
+
3
+ Schema-agnostic infrastructure helpers shared by `pipefy` and `pipefy-auth`. Sits at the bottom of the workspace dependency graph; depends only on stdlib + `pydantic` / `pydantic-settings`.
4
+
5
+ Adapter layer between Pipefy's application code (`pipefy`, `pipefy-auth`) and external concerns (filesystem, OS, network boundary). Each submodule owns one bounded context; the package root exposes only `__version__`.
6
+
7
+ ## `pipefy_infra.config`
8
+
9
+ Pipefy on-disk configuration: where it lives and how it's read.
10
+
11
+ - `config_dir() -> Path`. Resolves the OS-appropriate config directory (`$XDG_CONFIG_HOME/pipefy` on POSIX, `%APPDATA%\pipefy` on Windows, falling back to `~/.config/pipefy` when `XDG_CONFIG_HOME` is unset).
12
+ - `config_file_path() -> Path`. Resolves the TOML file path, honouring the `PIPEFY_CONFIG_FILE` environment override.
13
+ - `PipefyTomlConfigSource`. A `pydantic-settings` source that loads top-level TOML keys from `config_file_path()`. The source knows nothing about specific field names; each consuming `BaseSettings` subclass filters via its own field definitions plus `extra="ignore"`.
14
+
15
+ ```python
16
+ from pipefy_infra.config import config_dir, config_file_path, PipefyTomlConfigSource
17
+ ```
18
+
19
+ ## `pipefy_infra.security`
20
+
21
+ SSRF defenses on URLs destined for outbound HTTP. Layered gates: shape regex at field declaration, synchronous internal-IP check at settings construction, asynchronous DNS-rebinding check at request time.
22
+
23
+ - `URL_SHAPE_PATTERN`. Regex for `Field(..., pattern=...)` on URL settings fields.
24
+ - `validate_https_url(url, field_label, *, allow_insecure=False)`. Synchronous scheme + literal-IP gate. Enforces HTTPS and rejects literal IPs in private/loopback/link-local/multicast/reserved/unspecified ranges. With `allow_insecure=True` (driven by `PIPEFY_ALLOW_INSECURE_URLS`) both http and the literal-IP gate are skipped for dev mode; production callers must follow up with the async DNS gate.
25
+ - `assert_hostname_is_not_internal(hostname, *, context)`. Rejects localhost and literal IPs in blocked ranges.
26
+ - `assert_hostname_resolves_to_public_ips(hostname)`. Asynchronous DNS gate used right before issuing a request; defends against DNS-rebinding.
27
+ - `validate_and_assert_public_url(url, *, field_label, allow_insecure=False) -> str`. Composite helper that runs the sync gate plus the DNS gate in one call and returns the validated hostname. Use from any new outbound-URL surface.
28
+ - `assert_url_is_host_root(url, *, field_label)`. Rejects non-root paths (including `//`, `///`), query strings, and fragments. For base-URL fields that derive endpoints via f-string concatenation.
29
+ - `assert_url_has_no_query_or_fragment(url, *, field_label)`. Path is allowed; rejects only query and fragment. For URLs that legitimately have a path (OIDC issuer URLs with a realm path) but where a stray query/fragment would corrupt downstream concatenation.
30
+
31
+ This is the **SSRF audit namespace**: import the module and call through it so every call site is greppable for audits, matching the stdlib idiom (`hmac.compare_digest`, `secrets.token_urlsafe`).
32
+
33
+ ```python
34
+ from pipefy_infra import security
35
+
36
+ security.validate_https_url(url, "graphql_url", allow_insecure=False)
37
+ await security.assert_hostname_resolves_to_public_ips(host)
38
+ ```
39
+
40
+ ## Field definitions
41
+
42
+ This package owns **no schema**. Field definitions live with the settings models that use them (`pipefy_auth.AuthSettings`, `pipefy_sdk.PipefySettings`).
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "pipefy-infra"
3
+ dynamic = ["version"]
4
+ description = "Shared infra (TOML config loader, path discovery, SSRF defenses, string helpers) for Pipefy SDK and Auth."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pydantic>=2.13.4,<3",
9
+ "pydantic-settings>=2.8.1",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=8.3.5",
15
+ "pytest-asyncio>=0.26.0",
16
+ "ruff>=0.11.4",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/pipefy_infra"]
25
+
26
+ [tool.hatch.version]
27
+ path = "src/pipefy_infra/__init__.py"
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+ pythonpath = ["src", "tests"]
32
+ asyncio_mode = "auto"
33
+ asyncio_default_fixture_loop_scope = "module"
34
+ markers = [
35
+ "unit: marks a test as a unit test",
36
+ ]
37
+
38
+ [tool.ruff]
39
+ extend = "../../pyproject.toml"
40
+ lint.extend-select = ["TID251"]
41
+
42
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
43
+ "pipefy_auth".msg = "pipefy_infra is a leaf package; it must not import the Auth package."
44
+ "pipefy_sdk".msg = "pipefy_infra is a leaf package; it must not import the SDK package."
45
+ "pipefy_cli".msg = "pipefy_infra is a leaf package; it must not import the CLI package."
46
+ "pipefy_mcp".msg = "pipefy_infra is a leaf package; it must not import the MCP package."
@@ -0,0 +1,18 @@
1
+ """Schema-agnostic infrastructure helpers shared by ``pipefy_sdk`` and ``pipefy_auth``.
2
+
3
+ The package root exposes only ``__version__``. Consumers import from the
4
+ submodule that names the concern: :mod:`pipefy_infra.config` (on-disk
5
+ configuration: path discovery + TOML source), :mod:`pipefy_infra.security`
6
+ (SSRF defenses, imported as ``from pipefy_infra import security`` so every
7
+ call site is greppable for audits), and :mod:`pipefy_infra.coerce`
8
+ (permissive type-coercion helpers for JSON/GraphQL response values). See
9
+ each submodule docstring and ``README.md`` for the detailed surface. The
10
+ package sits at the bottom of the workspace dependency graph: stdlib +
11
+ ``pydantic`` / ``pydantic-settings`` only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.3.0-alpha.1"
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1,95 @@
1
+ """Permissive coercion helpers for values of uncertain shape.
2
+
3
+ When a value crosses a boundary (deserialization, user input, third-party
4
+ payload) it may arrive as a string, int, float, or wrong shape entirely.
5
+ These helpers normalize the common cases without raising, and explicitly
6
+ reject misshapen types (booleans, bytes) that Python's built-in conversions
7
+ would silently accept:
8
+
9
+ - :func:`optional_int` / :func:`optional_str` / :func:`optional_float` collapse
10
+ ``None`` and any unparseable or wrong-shape value to ``None``. Use when
11
+ absence and malformed-input are handled identically by the caller.
12
+ - :func:`try_int` returns the input unchanged if it can't be parsed as int.
13
+ Use when a non-numeric input is itself a meaningful value to preserve.
14
+
15
+ Booleans are rejected (or passed through, for ``try_int``) despite ``bool``
16
+ being a subclass of ``int``: a bool in a numeric- or string-shaped slot is
17
+ a shape signal, not the value ``1`` / ``0`` / ``"True"``. Bytes are likewise
18
+ rejected because ``str(b"x") == "b'x'"`` (the repr) is almost never what a
19
+ caller wants.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ from typing import TypeVar
26
+
27
+ T = TypeVar("T")
28
+
29
+
30
+ def optional_int(value: object) -> int | None:
31
+ """Return ``int(value)``, or ``None`` for None/bool/bytes/unparseable input.
32
+
33
+ Floats truncate (``42.7 -> 42``); non-finite floats and overflowing values
34
+ return ``None``. Numeric strings (including signed and PEP 515 underscored
35
+ forms) parse via Python's built-in ``int()``.
36
+ """
37
+ if value is None or isinstance(value, (bool, bytes, bytearray)):
38
+ return None
39
+ try:
40
+ return int(value) # type: ignore[arg-type]
41
+ except (TypeError, ValueError, OverflowError):
42
+ return None
43
+
44
+
45
+ def optional_str(value: object) -> str | None:
46
+ """Return ``str(value).strip()``, or ``None`` for None/bool/bytes/empty input.
47
+
48
+ Booleans return ``None`` (a bool in a string-shaped slot is a shape error,
49
+ not the literal ``'True'`` / ``'False'``). Bytes return ``None`` to avoid
50
+ producing the bytes-repr form. Numeric inputs are stringified
51
+ (``optional_str(42) == '42'``) for the case where a numeric id needs
52
+ comparing against a string token.
53
+ """
54
+ if value is None or isinstance(value, (bool, bytes, bytearray)):
55
+ return None
56
+ text = str(value).strip()
57
+ return text or None
58
+
59
+
60
+ def optional_float(value: object) -> float | None:
61
+ """Return ``float(value)``, or ``None`` for None/bool/bytes/unparseable input.
62
+
63
+ Non-finite floats (``inf``, ``-inf``, ``nan``) return ``None`` so callers
64
+ can treat them like missing data rather than threading edge-case checks
65
+ through every comparison.
66
+ """
67
+ if value is None or isinstance(value, (bool, bytes, bytearray)):
68
+ return None
69
+ try:
70
+ result = float(value) # type: ignore[arg-type]
71
+ except (TypeError, ValueError):
72
+ return None
73
+ if not math.isfinite(result):
74
+ return None
75
+ return result
76
+
77
+
78
+ def try_int(value: T) -> T | int:
79
+ """Return ``int(value)``, or ``value`` unchanged if conversion fails.
80
+
81
+ Distinct from :func:`optional_int` in that a non-numeric input is preserved
82
+ rather than collapsed to ``None``. Use when the caller wants to upgrade a
83
+ numeric form to an int but leave non-numeric values alone. Booleans and
84
+ bytes pass through unchanged (the misshapen-type rejection that
85
+ :func:`optional_int` uses ``None`` for).
86
+ """
87
+ if isinstance(value, (bool, bytes, bytearray)):
88
+ return value
89
+ try:
90
+ return int(value) # type: ignore[arg-type]
91
+ except (TypeError, ValueError, OverflowError):
92
+ return value
93
+
94
+
95
+ __all__ = ["optional_float", "optional_int", "optional_str", "try_int"]
@@ -0,0 +1,107 @@
1
+ """Pipefy on-disk configuration: where it lives and how it's read.
2
+
3
+ Bounded context: the operator-editable Pipefy config file. Owns path
4
+ discovery (where the OS expects the config directory and the
5
+ ``config.toml`` file to live) and the ``pydantic-settings`` source that
6
+ reads top-level TOML keys at settings construction.
7
+
8
+ Path discovery is intentionally hand-rolled. Adopting ``platformdirs``
9
+ would land config under ``~/Library/Application Support/pipefy`` on
10
+ macOS, breaking parity with the CLI tools operators expect to share that
11
+ location (``gh``, ``uv``, ``gcloud``).
12
+
13
+ The TOML source is schema-agnostic: it returns the full top-level mapping
14
+ filtered to the consuming ``BaseSettings`` subclass's field names
15
+ (env-only aliases like ``PIPEFY_TOKEN`` are intentionally not honoured as
16
+ TOML keys; operators get one canonical TOML schema).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import sys
23
+ import tomllib
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from pydantic.fields import FieldInfo
28
+ from pydantic_settings import PydanticBaseSettingsSource
29
+
30
+ _CONFIG_FILE_ENV = "PIPEFY_CONFIG_FILE"
31
+
32
+
33
+ def config_dir() -> Path:
34
+ """Resolve the shared Pipefy configuration directory.
35
+
36
+ On POSIX honours ``XDG_CONFIG_HOME`` (per the XDG Base Directory
37
+ Specification) and falls back to ``~/.config``. On Windows uses
38
+ ``%APPDATA%`` with a ``~/AppData/Roaming`` fallback. Returns the path
39
+ unconditionally; the directory may not yet exist.
40
+ """
41
+ if sys.platform == "win32":
42
+ appdata = os.environ.get("APPDATA")
43
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
44
+ return base / "pipefy"
45
+ xdg = os.environ.get("XDG_CONFIG_HOME")
46
+ base = Path(xdg) if xdg else Path.home() / ".config"
47
+ return base / "pipefy"
48
+
49
+
50
+ def config_file_path() -> Path:
51
+ """Resolve the operator-editable ``config.toml`` path.
52
+
53
+ Honours the ``PIPEFY_CONFIG_FILE`` env override (useful for tests, ops
54
+ automation, and multi-environment workflows). Returns the path
55
+ unconditionally; the file may not yet exist; consumers must tolerate that.
56
+ """
57
+ override = os.environ.get(_CONFIG_FILE_ENV)
58
+ if override:
59
+ return Path(override)
60
+ return config_dir() / "config.toml"
61
+
62
+
63
+ class PipefyTomlConfigSource(PydanticBaseSettingsSource):
64
+ """Load top-level TOML keys from :func:`config_file_path` as a settings source.
65
+
66
+ Missing file produces an empty mapping (no error). Malformed TOML
67
+ produces ``ValueError`` quoting the file path, chained from the
68
+ original ``tomllib.TOMLDecodeError`` so the parser context is preserved.
69
+ """
70
+
71
+ _cache: dict[str, Any] | None = None
72
+
73
+ def _data(self) -> dict[str, Any]:
74
+ # Pydantic-settings constructs a fresh source per ``BaseSettings.__init__``,
75
+ # so caching on the instance scopes to "one settings construction"; the
76
+ # lazy behaviour the tests exercise (env change between constructions)
77
+ # still works because each construction gets a new source instance.
78
+ if self._cache is not None:
79
+ return self._cache
80
+ path = config_file_path()
81
+ if not path.is_file():
82
+ self._cache = {}
83
+ return self._cache
84
+ try:
85
+ with path.open("rb") as handle:
86
+ raw = tomllib.load(handle)
87
+ except tomllib.TOMLDecodeError as exc:
88
+ msg = f"invalid TOML in {path}"
89
+ raise ValueError(msg) from exc
90
+ known = set(self.settings_cls.model_fields)
91
+ self._cache = {key: value for key, value in raw.items() if key in known}
92
+ return self._cache
93
+
94
+ def get_field_value(
95
+ self, field: FieldInfo, field_name: str
96
+ ) -> tuple[Any, str, bool]:
97
+ del field # signature dictated by ``PydanticBaseSettingsSource``
98
+ data = self._data()
99
+ if field_name in data:
100
+ return data[field_name], field_name, False
101
+ return None, field_name, False
102
+
103
+ def __call__(self) -> dict[str, Any]:
104
+ return self._data()
105
+
106
+
107
+ __all__ = ["PipefyTomlConfigSource", "config_dir", "config_file_path"]
@@ -0,0 +1,111 @@
1
+ """Local filesystem I/O with normalized error handling.
2
+
3
+ :class:`LocalFile` resolves a path on disk to its bytes, expanding ``~``,
4
+ checking the path is a regular file, and (optionally) enforcing a size cap
5
+ before reading. Every failure mode surfaces as :class:`LocalFileError` so
6
+ callers handle a single exception type.
7
+
8
+ Schema-agnostic: no Pipefy concepts here. Callers that need a domain-policy
9
+ cap (e.g. the attachment 100 MiB ceiling) pass ``max_size_bytes``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ __all__ = [
17
+ "LocalFile",
18
+ "LocalFileError",
19
+ ]
20
+
21
+
22
+ class LocalFileError(Exception):
23
+ """Raised when a :class:`LocalFile` cannot be read.
24
+
25
+ Covers unknown ``~user`` in the path, missing path, non-regular file,
26
+ permission denied, generic OS read failure, and oversize files when a
27
+ cap is configured. The message is user-facing and safe to surface in
28
+ error envelopes or CLI errors.
29
+ """
30
+
31
+
32
+ class LocalFile:
33
+ """A local file resolved to its bytes.
34
+
35
+ Construct with a path, optionally a size cap, then call :meth:`read` to
36
+ perform the I/O. All read-time failures land as :class:`LocalFileError`
37
+ so callers catch one exception type.
38
+
39
+ Path/bytes properties raise :class:`RuntimeError` if accessed before
40
+ :meth:`read` succeeds.
41
+
42
+ Args:
43
+ path: Local path. Supports ``~`` expansion against the user's home.
44
+ max_size_bytes: Optional ceiling enforced after ``stat()``. ``None``
45
+ (default) means no cap.
46
+ """
47
+
48
+ def __init__(self, path: Path, *, max_size_bytes: int | None = None):
49
+ self._path = path
50
+ self._max_size_bytes = max_size_bytes
51
+ self._expanded: Path | None = None
52
+ self._bytes: bytes | None = None
53
+
54
+ def read(self) -> None:
55
+ """Expand ``~``, validate, and read the file into memory.
56
+
57
+ Raises:
58
+ LocalFileError: For any failure mode (unknown ``~user``, missing
59
+ file, directory, permission denied, oversize when a cap is
60
+ configured).
61
+ """
62
+ try:
63
+ expanded = self._path.expanduser()
64
+ except RuntimeError as exc:
65
+ raise LocalFileError(f"Cannot expand ~ in {self._path}: {exc}") from exc
66
+
67
+ if not expanded.is_file():
68
+ raise LocalFileError(f"File not found or not a regular file: {expanded}")
69
+
70
+ if self._max_size_bytes is not None:
71
+ size = expanded.stat().st_size
72
+ if size > self._max_size_bytes:
73
+ cap_mib = self._max_size_bytes // (1024 * 1024)
74
+ raise LocalFileError(
75
+ f"File too large: {expanded} is {size} bytes, exceeding "
76
+ f"the {cap_mib} MiB cap."
77
+ )
78
+
79
+ try:
80
+ data = expanded.read_bytes()
81
+ except PermissionError as exc:
82
+ raise LocalFileError(
83
+ f"Permission denied reading {expanded}: {exc.strerror}"
84
+ ) from exc
85
+ except OSError as exc:
86
+ raise LocalFileError(f"Could not read {expanded}: {exc}") from exc
87
+
88
+ self._expanded = expanded
89
+ self._bytes = data
90
+
91
+ @property
92
+ def path(self) -> Path:
93
+ """The post-``expanduser()`` path. Available after :meth:`read`."""
94
+ if self._expanded is None:
95
+ raise RuntimeError("LocalFile.read() has not been called yet.")
96
+ return self._expanded
97
+
98
+ @property
99
+ def name(self) -> str:
100
+ """The expanded path's basename. May be empty for paths like ``/``."""
101
+ return self.path.name
102
+
103
+ @property
104
+ def bytes(self) -> bytes:
105
+ if self._bytes is None:
106
+ raise RuntimeError("LocalFile.read() has not been called yet.")
107
+ return self._bytes
108
+
109
+ @property
110
+ def size(self) -> int:
111
+ return len(self.bytes)