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.
- pipefy_infra-0.3.0a1/.gitignore +56 -0
- pipefy_infra-0.3.0a1/PKG-INFO +51 -0
- pipefy_infra-0.3.0a1/README.md +42 -0
- pipefy_infra-0.3.0a1/pyproject.toml +46 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/__init__.py +18 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/coerce.py +95 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/config.py +107 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/filesystem.py +111 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/security.py +251 -0
- pipefy_infra-0.3.0a1/src/pipefy_infra/telemetry.py +63 -0
- pipefy_infra-0.3.0a1/tests/test_coerce.py +108 -0
- pipefy_infra-0.3.0a1/tests/test_filesystem.py +99 -0
- pipefy_infra-0.3.0a1/tests/test_infra_config.py +181 -0
- pipefy_infra-0.3.0a1/tests/test_security.py +293 -0
|
@@ -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)
|