fnox-py 0.1.0__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.
fnox_py-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,287 @@
1
+ Metadata-Version: 2.4
2
+ Name: fnox-py
3
+ Version: 0.1.0
4
+ Summary: Python bindings for fnox
5
+ Author: Zach Fuller
6
+ Author-email: Zach Fuller <zach.fuller1222@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # fnox-py
12
+
13
+ `fnox-py` is a thin Python wrapper around the [`fnox`](https://github.com/jdx/fnox) binary.
14
+
15
+ It does not reimplement `fnox` behavior in Python. Instead, it:
16
+
17
+ - locates a real `fnox` binary
18
+ - builds argv for common commands
19
+ - runs the binary
20
+ - returns parsed results or typed errors
21
+
22
+ Python requirement: `>=3.12`
23
+
24
+ ## Installation
25
+
26
+ ### pip
27
+
28
+ ```bash
29
+ pip install fnox-py
30
+ ```
31
+
32
+ ### uv
33
+
34
+ ```bash
35
+ uv add fnox-py
36
+ ```
37
+
38
+ If you want the CLI available outside a project environment, you can also use a tool install:
39
+
40
+ ```bash
41
+ uv tool install fnox-py
42
+ ```
43
+
44
+ ### Bundled binary vs source install
45
+
46
+ Platform wheels are intended to bundle the `fnox` binary.
47
+
48
+ If you install from source instead of a platform wheel, `fnox-py` requires a real `fnox` executable to be available via:
49
+
50
+ - `PATH`, or
51
+ - `FNOX_PY_BINARY=/absolute/path/to/fnox`
52
+
53
+ Examples:
54
+
55
+ ```bash
56
+ pip install --no-binary fnox-py fnox-py
57
+ ```
58
+
59
+ ```bash
60
+ FNOX_PY_BINARY=/usr/local/bin/fnox python -c "import fnox_py; print(fnox_py.version())"
61
+ ```
62
+
63
+ ## Binary Resolution
64
+
65
+ At runtime, `fnox-py` resolves the `fnox` binary in this order:
66
+
67
+ 1. `FNOX_PY_BINARY`
68
+ 2. bundled/installed locations in the current environment
69
+ 3. bundled/installed fallback locations associated with the base or target install
70
+ 4. user scheme script location
71
+ 5. `PATH`
72
+
73
+ If `FNOX_PY_BINARY` is set but points to a missing file, `fnox-py` raises `FnoxNotFoundError`.
74
+
75
+ ## Python API
76
+
77
+ ```python
78
+ from fnox_py import (
79
+ config_files,
80
+ export_json,
81
+ get,
82
+ lease_create,
83
+ profiles,
84
+ providers,
85
+ schema,
86
+ version,
87
+ )
88
+
89
+ value = get("MY_SECRET")
90
+ all_values = export_json()
91
+ schema_doc = schema()
92
+ profile_names = profiles()
93
+ provider_names = providers()
94
+ config_paths = config_files()
95
+ lease = lease_create("vault", duration="1h", label="local-dev")
96
+ fnox_version = version()
97
+ ```
98
+
99
+ ### Common examples
100
+
101
+ Get a single value:
102
+
103
+ ```python
104
+ from fnox_py import get
105
+
106
+ token = get("API_TOKEN")
107
+ ```
108
+
109
+ Get a value from a specific profile:
110
+
111
+ ```python
112
+ from fnox_py import get
113
+
114
+ token = get("API_TOKEN", profile="prod")
115
+ ```
116
+
117
+ Decode base64 output:
118
+
119
+ ```python
120
+ from fnox_py import get
121
+
122
+ decoded = get("TLS_CERT", base64_decode=True)
123
+ ```
124
+
125
+ Export all secrets as JSON:
126
+
127
+ ```python
128
+ from fnox_py import export_json
129
+
130
+ data = export_json(profile="dev")
131
+ ```
132
+
133
+ Inspect schema, profiles, providers, and config files:
134
+
135
+ ```python
136
+ from fnox_py import config_files, profiles, providers, schema
137
+
138
+ print(schema())
139
+ print(profiles())
140
+ print(providers())
141
+ print(config_files())
142
+ ```
143
+
144
+ Create a lease:
145
+
146
+ ```python
147
+ from fnox_py import lease_create
148
+
149
+ lease = lease_create("vault", duration="30m", label="ci-job")
150
+ ```
151
+
152
+ Get the underlying `fnox` version:
153
+
154
+ ```python
155
+ from fnox_py import version
156
+
157
+ print(version())
158
+ ```
159
+
160
+ ## CLI
161
+
162
+ The package installs the `fnox-py` console script.
163
+
164
+ ### Built-in subcommands
165
+
166
+ Locate the resolved binary:
167
+
168
+ ```bash
169
+ fnox-py which
170
+ ```
171
+
172
+ Show the wrapper version and attempt to print the underlying `fnox` version:
173
+
174
+ ```bash
175
+ fnox-py version
176
+ ```
177
+
178
+ Print basic environment diagnostics:
179
+
180
+ ```bash
181
+ fnox-py doctor
182
+ ```
183
+
184
+ ### Passthrough behavior
185
+
186
+ Any arguments other than `which`, `version`, and `doctor` are passed directly through to `fnox`.
187
+
188
+ For example:
189
+
190
+ ```bash
191
+ fnox-py get MY_SECRET
192
+ fnox-py profiles
193
+ fnox-py export --format json
194
+ ```
195
+
196
+ With no arguments, `fnox-py` runs `fnox` with no extra argv.
197
+
198
+ ## Public API
199
+
200
+ `fnox-py` currently exports:
201
+
202
+ - `config_files`
203
+ - `export_json`
204
+ - `get`
205
+ - `lease_create`
206
+ - `profiles`
207
+ - `providers`
208
+ - `schema`
209
+ - `version`
210
+ - `find_fnox_bin`
211
+ - `run`
212
+ - `FnoxResult`
213
+ - `FnoxCommandError`
214
+ - `FnoxError`
215
+ - `FnoxNotFoundError`
216
+ - `FnoxTimeoutError`
217
+
218
+ ## Errors
219
+
220
+ Library calls raise typed exceptions:
221
+
222
+ - `FnoxNotFoundError` when the binary cannot be found
223
+ - `FnoxCommandError` when `fnox` exits non-zero
224
+ - `FnoxTimeoutError` on subprocess timeout
225
+ - `FnoxError` as the base exception type
226
+
227
+ ## Development
228
+
229
+ This project uses `uv`, `pytest`, `ruff`, and `mypy`.
230
+
231
+ Install dependencies:
232
+
233
+ ```bash
234
+ uv sync
235
+ ```
236
+
237
+ Run tests:
238
+
239
+ ```bash
240
+ uv run pytest -v
241
+ ```
242
+
243
+ Run a single test:
244
+
245
+ ```bash
246
+ uv run pytest tests/test_api.py::test_get -q
247
+ ```
248
+
249
+ Lint:
250
+
251
+ ```bash
252
+ uv run ruff check src tests scripts
253
+ ```
254
+
255
+ Type-check:
256
+
257
+ ```bash
258
+ uv run mypy src
259
+ ```
260
+
261
+ Build distributions:
262
+
263
+ ```bash
264
+ uv build
265
+ ```
266
+
267
+ ## Release / Platform Wheel Build
268
+
269
+ `scripts/build_platform_wheel.py` builds platform-specific wheels by:
270
+
271
+ 1. building a pure Python wheel
272
+ 2. downloading upstream `fnox` release binaries
273
+ 3. injecting the binary into the wheel
274
+ 4. rewriting wheel metadata
275
+ 5. building an sdist
276
+
277
+ Example:
278
+
279
+ ```bash
280
+ uv run python scripts/build_platform_wheel.py --fnox-version 1.0.0 --output dist/
281
+ ```
282
+
283
+ ## Notes
284
+
285
+ - `fnox-py` is intentionally small and wrapper-focused.
286
+ - For behavior, flags, and command semantics, prefer the upstream `fnox` documentation.
287
+ - If you need lower-level control, use `run()` directly and inspect `FnoxResult`.
@@ -0,0 +1,277 @@
1
+ # fnox-py
2
+
3
+ `fnox-py` is a thin Python wrapper around the [`fnox`](https://github.com/jdx/fnox) binary.
4
+
5
+ It does not reimplement `fnox` behavior in Python. Instead, it:
6
+
7
+ - locates a real `fnox` binary
8
+ - builds argv for common commands
9
+ - runs the binary
10
+ - returns parsed results or typed errors
11
+
12
+ Python requirement: `>=3.12`
13
+
14
+ ## Installation
15
+
16
+ ### pip
17
+
18
+ ```bash
19
+ pip install fnox-py
20
+ ```
21
+
22
+ ### uv
23
+
24
+ ```bash
25
+ uv add fnox-py
26
+ ```
27
+
28
+ If you want the CLI available outside a project environment, you can also use a tool install:
29
+
30
+ ```bash
31
+ uv tool install fnox-py
32
+ ```
33
+
34
+ ### Bundled binary vs source install
35
+
36
+ Platform wheels are intended to bundle the `fnox` binary.
37
+
38
+ If you install from source instead of a platform wheel, `fnox-py` requires a real `fnox` executable to be available via:
39
+
40
+ - `PATH`, or
41
+ - `FNOX_PY_BINARY=/absolute/path/to/fnox`
42
+
43
+ Examples:
44
+
45
+ ```bash
46
+ pip install --no-binary fnox-py fnox-py
47
+ ```
48
+
49
+ ```bash
50
+ FNOX_PY_BINARY=/usr/local/bin/fnox python -c "import fnox_py; print(fnox_py.version())"
51
+ ```
52
+
53
+ ## Binary Resolution
54
+
55
+ At runtime, `fnox-py` resolves the `fnox` binary in this order:
56
+
57
+ 1. `FNOX_PY_BINARY`
58
+ 2. bundled/installed locations in the current environment
59
+ 3. bundled/installed fallback locations associated with the base or target install
60
+ 4. user scheme script location
61
+ 5. `PATH`
62
+
63
+ If `FNOX_PY_BINARY` is set but points to a missing file, `fnox-py` raises `FnoxNotFoundError`.
64
+
65
+ ## Python API
66
+
67
+ ```python
68
+ from fnox_py import (
69
+ config_files,
70
+ export_json,
71
+ get,
72
+ lease_create,
73
+ profiles,
74
+ providers,
75
+ schema,
76
+ version,
77
+ )
78
+
79
+ value = get("MY_SECRET")
80
+ all_values = export_json()
81
+ schema_doc = schema()
82
+ profile_names = profiles()
83
+ provider_names = providers()
84
+ config_paths = config_files()
85
+ lease = lease_create("vault", duration="1h", label="local-dev")
86
+ fnox_version = version()
87
+ ```
88
+
89
+ ### Common examples
90
+
91
+ Get a single value:
92
+
93
+ ```python
94
+ from fnox_py import get
95
+
96
+ token = get("API_TOKEN")
97
+ ```
98
+
99
+ Get a value from a specific profile:
100
+
101
+ ```python
102
+ from fnox_py import get
103
+
104
+ token = get("API_TOKEN", profile="prod")
105
+ ```
106
+
107
+ Decode base64 output:
108
+
109
+ ```python
110
+ from fnox_py import get
111
+
112
+ decoded = get("TLS_CERT", base64_decode=True)
113
+ ```
114
+
115
+ Export all secrets as JSON:
116
+
117
+ ```python
118
+ from fnox_py import export_json
119
+
120
+ data = export_json(profile="dev")
121
+ ```
122
+
123
+ Inspect schema, profiles, providers, and config files:
124
+
125
+ ```python
126
+ from fnox_py import config_files, profiles, providers, schema
127
+
128
+ print(schema())
129
+ print(profiles())
130
+ print(providers())
131
+ print(config_files())
132
+ ```
133
+
134
+ Create a lease:
135
+
136
+ ```python
137
+ from fnox_py import lease_create
138
+
139
+ lease = lease_create("vault", duration="30m", label="ci-job")
140
+ ```
141
+
142
+ Get the underlying `fnox` version:
143
+
144
+ ```python
145
+ from fnox_py import version
146
+
147
+ print(version())
148
+ ```
149
+
150
+ ## CLI
151
+
152
+ The package installs the `fnox-py` console script.
153
+
154
+ ### Built-in subcommands
155
+
156
+ Locate the resolved binary:
157
+
158
+ ```bash
159
+ fnox-py which
160
+ ```
161
+
162
+ Show the wrapper version and attempt to print the underlying `fnox` version:
163
+
164
+ ```bash
165
+ fnox-py version
166
+ ```
167
+
168
+ Print basic environment diagnostics:
169
+
170
+ ```bash
171
+ fnox-py doctor
172
+ ```
173
+
174
+ ### Passthrough behavior
175
+
176
+ Any arguments other than `which`, `version`, and `doctor` are passed directly through to `fnox`.
177
+
178
+ For example:
179
+
180
+ ```bash
181
+ fnox-py get MY_SECRET
182
+ fnox-py profiles
183
+ fnox-py export --format json
184
+ ```
185
+
186
+ With no arguments, `fnox-py` runs `fnox` with no extra argv.
187
+
188
+ ## Public API
189
+
190
+ `fnox-py` currently exports:
191
+
192
+ - `config_files`
193
+ - `export_json`
194
+ - `get`
195
+ - `lease_create`
196
+ - `profiles`
197
+ - `providers`
198
+ - `schema`
199
+ - `version`
200
+ - `find_fnox_bin`
201
+ - `run`
202
+ - `FnoxResult`
203
+ - `FnoxCommandError`
204
+ - `FnoxError`
205
+ - `FnoxNotFoundError`
206
+ - `FnoxTimeoutError`
207
+
208
+ ## Errors
209
+
210
+ Library calls raise typed exceptions:
211
+
212
+ - `FnoxNotFoundError` when the binary cannot be found
213
+ - `FnoxCommandError` when `fnox` exits non-zero
214
+ - `FnoxTimeoutError` on subprocess timeout
215
+ - `FnoxError` as the base exception type
216
+
217
+ ## Development
218
+
219
+ This project uses `uv`, `pytest`, `ruff`, and `mypy`.
220
+
221
+ Install dependencies:
222
+
223
+ ```bash
224
+ uv sync
225
+ ```
226
+
227
+ Run tests:
228
+
229
+ ```bash
230
+ uv run pytest -v
231
+ ```
232
+
233
+ Run a single test:
234
+
235
+ ```bash
236
+ uv run pytest tests/test_api.py::test_get -q
237
+ ```
238
+
239
+ Lint:
240
+
241
+ ```bash
242
+ uv run ruff check src tests scripts
243
+ ```
244
+
245
+ Type-check:
246
+
247
+ ```bash
248
+ uv run mypy src
249
+ ```
250
+
251
+ Build distributions:
252
+
253
+ ```bash
254
+ uv build
255
+ ```
256
+
257
+ ## Release / Platform Wheel Build
258
+
259
+ `scripts/build_platform_wheel.py` builds platform-specific wheels by:
260
+
261
+ 1. building a pure Python wheel
262
+ 2. downloading upstream `fnox` release binaries
263
+ 3. injecting the binary into the wheel
264
+ 4. rewriting wheel metadata
265
+ 5. building an sdist
266
+
267
+ Example:
268
+
269
+ ```bash
270
+ uv run python scripts/build_platform_wheel.py --fnox-version 1.0.0 --output dist/
271
+ ```
272
+
273
+ ## Notes
274
+
275
+ - `fnox-py` is intentionally small and wrapper-focused.
276
+ - For behavior, flags, and command semantics, prefer the upstream `fnox` documentation.
277
+ - If you need lower-level control, use `run()` directly and inspect `FnoxResult`.
@@ -0,0 +1,104 @@
1
+ [project]
2
+ name = "fnox-py"
3
+ version = "0.1.0"
4
+ description = "Python bindings for fnox"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ authors = [{ name = "Zach Fuller", email = "zach.fuller1222@gmail.com" }]
8
+ requires-python = ">=3.12"
9
+ dependencies = []
10
+
11
+ [build-system]
12
+ requires = ["uv_build>=0.10.11,<0.11.0"]
13
+ build-backend = "uv_build"
14
+
15
+ [project.scripts]
16
+ fnox-py = "fnox_py.cli:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "mypy>=1.19.1",
21
+ "prek>=0.3.6",
22
+ "ruff>=0.15.6",
23
+ "pytest>=8.0",
24
+ "urllib3>=2.6.3",
25
+ "rich>=14.3.3",
26
+ ]
27
+
28
+ [tool.pyright]
29
+ include = ["src/**", "tests/**"]
30
+ typeCheckingMode = "standard"
31
+
32
+ [tool.mypy]
33
+ warn_redundant_casts = true
34
+ warn_unused_ignores = true
35
+ disallow_any_generics = true
36
+ check_untyped_defs = true
37
+ no_implicit_reexport = true
38
+ disallow_untyped_defs = true
39
+ local_partial_types = true
40
+ warn_return_any = true
41
+ enable_error_code = [
42
+ "ignore-without-code",
43
+ "no-untyped-def",
44
+ "unreachable",
45
+ "unused-awaitable",
46
+ "explicit-override",
47
+ "mutable-override",
48
+ "exhaustive-match",
49
+ ]
50
+
51
+ [tool.ruff]
52
+ include = ["src/**", "tests/**", "scripts/**"]
53
+ exclude = ["tests/data/*.txt", ".venv"]
54
+ line-length = 120
55
+ indent-width = 4
56
+
57
+ target-version = "py313"
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "E",
62
+ "F",
63
+ "W",
64
+ "C90",
65
+ "I",
66
+ "N",
67
+ "UP",
68
+ "ASYNC",
69
+ "S",
70
+ "B",
71
+ "ERA",
72
+ "PLE",
73
+ "PLW",
74
+ "PLC",
75
+ "PLW",
76
+ "PERF",
77
+ "RUF",
78
+ "SIM",
79
+ "PT",
80
+ "T20",
81
+ "PTH",
82
+ "LOG",
83
+ "G",
84
+ ]
85
+ ignore = ["E501", "S101", "PLC0415"]
86
+
87
+ # Allow fix for all enabled rules (when `--fix`) is provided.
88
+ fixable = ["ALL"]
89
+ unfixable = []
90
+
91
+ # Allow unused variables when underscore-prefixed.
92
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
93
+
94
+ [tool.ruff.lint.per-file-ignores]
95
+ "scripts/*" = ["S603", "S607", "S202", "T201"]
96
+
97
+ [tool.ruff.format]
98
+ quote-style = "double"
99
+ indent-style = "space"
100
+ skip-magic-trailing-comma = false
101
+ line-ending = "auto"
102
+
103
+ [tool.ty.rules]
104
+ possibly-unresolved-reference = "warn"
@@ -0,0 +1,31 @@
1
+ from .api import (
2
+ config_files,
3
+ export_json,
4
+ get,
5
+ lease_create,
6
+ profiles,
7
+ providers,
8
+ schema,
9
+ version,
10
+ )
11
+ from .binary import find_fnox_bin
12
+ from .errors import FnoxCommandError, FnoxError, FnoxNotFoundError, FnoxTimeoutError
13
+ from .runner import FnoxResult, run
14
+
15
+ __all__ = [
16
+ "FnoxCommandError",
17
+ "FnoxError",
18
+ "FnoxNotFoundError",
19
+ "FnoxResult",
20
+ "FnoxTimeoutError",
21
+ "config_files",
22
+ "export_json",
23
+ "find_fnox_bin",
24
+ "get",
25
+ "lease_create",
26
+ "profiles",
27
+ "providers",
28
+ "run",
29
+ "schema",
30
+ "version",
31
+ ]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import runner
9
+
10
+
11
+ def get(
12
+ key: str,
13
+ *,
14
+ profile: str | None = None,
15
+ base64_decode: bool = False,
16
+ env: Mapping[str, str] | None = None,
17
+ cwd: str | Path | None = None,
18
+ timeout: float | None = None,
19
+ ) -> str:
20
+ """Get a single secret value by key."""
21
+ args: list[str] = ["get"]
22
+ if profile is not None:
23
+ args.extend(["--profile", profile])
24
+ if base64_decode:
25
+ args.append("--base64-decode")
26
+ args.append(key)
27
+ result = runner.run(args, env=env, cwd=cwd, timeout=timeout)
28
+ return result.stdout.rstrip("\n")
29
+
30
+
31
+ def export_json(
32
+ *,
33
+ profile: str | None = None,
34
+ env: Mapping[str, str] | None = None,
35
+ cwd: str | Path | None = None,
36
+ timeout: float | None = None,
37
+ ) -> dict[str, str]:
38
+ """Export all secrets as a JSON dictionary."""
39
+ args: list[str] = ["export"]
40
+ if profile is not None:
41
+ args.extend(["--profile", profile])
42
+ args.extend(["--format", "json"])
43
+ result = runner.run(args, env=env, cwd=cwd, timeout=timeout)
44
+ return json.loads(result.stdout) # type: ignore[no-any-return]
45
+
46
+
47
+ def schema(
48
+ *,
49
+ timeout: float | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Return the fnox JSON schema."""
52
+ result = runner.run(["schema"], timeout=timeout)
53
+ return json.loads(result.stdout) # type: ignore[no-any-return]
54
+
55
+
56
+ def profiles(
57
+ *,
58
+ env: Mapping[str, str] | None = None,
59
+ cwd: str | Path | None = None,
60
+ timeout: float | None = None,
61
+ ) -> list[str]:
62
+ """List available profiles."""
63
+ result = runner.run(["profiles"], env=env, cwd=cwd, timeout=timeout)
64
+ return result.stdout.strip().splitlines()
65
+
66
+
67
+ def providers(
68
+ *,
69
+ env: Mapping[str, str] | None = None,
70
+ cwd: str | Path | None = None,
71
+ timeout: float | None = None,
72
+ ) -> list[str]:
73
+ """List available providers."""
74
+ result = runner.run(["providers"], env=env, cwd=cwd, timeout=timeout)
75
+ return result.stdout.strip().splitlines()
76
+
77
+
78
+ def config_files(
79
+ *,
80
+ env: Mapping[str, str] | None = None,
81
+ cwd: str | Path | None = None,
82
+ timeout: float | None = None,
83
+ ) -> list[str]:
84
+ """List config file paths."""
85
+ result = runner.run(["config-files"], env=env, cwd=cwd, timeout=timeout)
86
+ return result.stdout.strip().splitlines()
87
+
88
+
89
+ def lease_create(
90
+ backend: str,
91
+ *,
92
+ duration: str | None = None,
93
+ label: str | None = None,
94
+ env: Mapping[str, str] | None = None,
95
+ cwd: str | Path | None = None,
96
+ timeout: float | None = None,
97
+ ) -> dict[str, Any]:
98
+ """Create a lease and return its metadata."""
99
+ args: list[str] = ["lease", "create"]
100
+ if duration is not None:
101
+ args.extend(["--duration", duration])
102
+ if label is not None:
103
+ args.extend(["--label", label])
104
+ args.extend(["--format", "json", backend])
105
+ result = runner.run(args, env=env, cwd=cwd, timeout=timeout)
106
+ return json.loads(result.stdout) # type: ignore[no-any-return]
107
+
108
+
109
+ def version(
110
+ *,
111
+ timeout: float | None = None,
112
+ ) -> str:
113
+ """Return the fnox version string."""
114
+ result = runner.run(["version"], timeout=timeout)
115
+ return result.stdout.strip()
@@ -0,0 +1,94 @@
1
+ # Binary discovery for fnox.
2
+ #
3
+ # Resolution order mirrors prek (MIT, Astral Software Inc.) with an
4
+ # additional env-var override and PATH fallback.
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import sysconfig
12
+ from fnmatch import fnmatch
13
+ from pathlib import Path
14
+
15
+ from .errors import FnoxNotFoundError
16
+
17
+ _MODULE_DIR = str(Path(__file__).parent)
18
+
19
+
20
+ def find_fnox_bin() -> str:
21
+ """Return the path to the fnox binary."""
22
+
23
+ # 1. Env-var override (hard error if set but missing)
24
+ env_path = os.environ.get("FNOX_PY_BINARY")
25
+ if env_path is not None:
26
+ if Path(env_path).is_file():
27
+ return env_path
28
+ raise FnoxNotFoundError(f"FNOX_PY_BINARY is set to {env_path!r} but the file does not exist")
29
+
30
+ fnox_exe = "fnox" + sysconfig.get_config_var("EXE")
31
+
32
+ targets: list[str | None] = [
33
+ # 2. Current venv scripts
34
+ sysconfig.get_path("scripts"),
35
+ # 3. Base prefix scripts
36
+ sysconfig.get_path("scripts", vars={"base": sys.base_prefix}),
37
+ # 4. Parent-of-package-root (platform-aware)
38
+ (
39
+ _join(_matching_parents(_MODULE_DIR, "Lib/site-packages/fnox_py"), "Scripts")
40
+ if sys.platform == "win32"
41
+ else _join(_matching_parents(_MODULE_DIR, "lib/python*/site-packages/fnox_py"), "bin")
42
+ ),
43
+ # 5. Adjacent-to-package-root (--target installs)
44
+ _join(_matching_parents(_MODULE_DIR, "fnox_py"), "bin"),
45
+ # 6. User scheme scripts
46
+ sysconfig.get_path("scripts", scheme=sysconfig.get_preferred_scheme("user")),
47
+ ]
48
+
49
+ seen: list[str] = []
50
+ for target in targets:
51
+ if not target:
52
+ continue
53
+ if target in seen:
54
+ continue
55
+ seen.append(target)
56
+ candidate = Path(target) / fnox_exe
57
+ if candidate.is_file():
58
+ return str(candidate)
59
+
60
+ # 7. PATH fallback (for sdist installs without bundled binary)
61
+ which = shutil.which("fnox")
62
+ if which is not None:
63
+ return which
64
+
65
+ locations = "\n".join(f" - {target}" for target in seen)
66
+ raise FnoxNotFoundError(
67
+ f"Could not find the fnox binary in any of the following locations:\n{locations}\n"
68
+ "Install fnox or set the FNOX_PY_BINARY environment variable."
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Helpers (ported from prek, MIT license, Astral Software Inc.)
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _matching_parents(path: str, match: str) -> str | None:
78
+ parts = Path(path).parts
79
+ match_parts = match.split("/")
80
+ if len(parts) < len(match_parts):
81
+ return None
82
+
83
+ if not all(
84
+ fnmatch(part, match_part) for part, match_part in zip(reversed(parts), reversed(match_parts), strict=False)
85
+ ):
86
+ return None
87
+
88
+ return str(Path(*parts[: -len(match_parts)]))
89
+
90
+
91
+ def _join(path: str | None, *parts: str) -> str | None:
92
+ if not path:
93
+ return None
94
+ return str(Path(path).joinpath(*parts))
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .binary import find_fnox_bin
6
+ from .errors import FnoxNotFoundError
7
+ from .runner import run, run_passthrough
8
+
9
+
10
+ def main() -> None:
11
+ if len(sys.argv) < 2:
12
+ run_passthrough([])
13
+ return
14
+
15
+ subcmd = sys.argv[1]
16
+
17
+ if subcmd == "which":
18
+ _cmd_which()
19
+ elif subcmd == "version":
20
+ _cmd_version()
21
+ elif subcmd == "doctor":
22
+ _cmd_doctor()
23
+ else:
24
+ run_passthrough(sys.argv[1:])
25
+
26
+
27
+ def _cmd_which() -> None:
28
+ try:
29
+ path = find_fnox_bin()
30
+ except FnoxNotFoundError as exc:
31
+ print(str(exc), file=sys.stderr) # noqa: T201
32
+ sys.exit(1)
33
+ print(path) # noqa: T201
34
+
35
+
36
+ def _cmd_version() -> None:
37
+ from importlib.metadata import version as pkg_version
38
+
39
+ wrapper_version = pkg_version("fnox-py")
40
+ print(f"fnox-py {wrapper_version}") # noqa: T201
41
+ try:
42
+ result = run(["version"], check=False)
43
+ print(result.stdout.strip()) # noqa: T201
44
+ except FnoxNotFoundError:
45
+ print("fnox binary not found", file=sys.stderr) # noqa: T201
46
+
47
+
48
+ def _cmd_doctor() -> None:
49
+ import shutil
50
+ from importlib.metadata import version as pkg_version
51
+
52
+ print(f"fnox-py {pkg_version('fnox-py')}") # noqa: T201
53
+ print(f"Python {sys.version}") # noqa: T201
54
+ try:
55
+ path = find_fnox_bin()
56
+ print(f"Binary {path}") # noqa: T201
57
+ is_bundled = shutil.which("fnox") != path
58
+ print(f"Bundled {is_bundled}") # noqa: T201
59
+ result = run(["version"], check=False)
60
+ print(f"fnox {result.stdout.strip()}") # noqa: T201
61
+ except FnoxNotFoundError as exc:
62
+ print(f"Binary NOT FOUND: {exc}", file=sys.stderr) # noqa: T201
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class FnoxError(Exception):
5
+ """Base exception for fnox-py."""
6
+
7
+
8
+ class FnoxNotFoundError(FnoxError, FileNotFoundError):
9
+ """The fnox binary could not be found."""
10
+
11
+
12
+ class FnoxCommandError(FnoxError):
13
+ """fnox exited with a non-zero return code."""
14
+
15
+ def __init__(self, returncode: int, stdout: str, stderr: str, cmd: list[str]) -> None:
16
+ self.returncode = returncode
17
+ self.stdout = stdout
18
+ self.stderr = stderr
19
+ self.cmd = cmd
20
+ super().__init__(f"fnox failed with exit code {returncode}: {stderr.strip() or stdout.strip()}")
21
+
22
+
23
+ class FnoxTimeoutError(FnoxError):
24
+ """fnox subprocess timed out."""
File without changes
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import NoReturn
10
+
11
+ from .binary import find_fnox_bin
12
+ from .errors import FnoxCommandError, FnoxNotFoundError, FnoxTimeoutError
13
+
14
+
15
+ @dataclasses.dataclass(frozen=True, slots=True)
16
+ class FnoxResult:
17
+ returncode: int
18
+ stdout: str
19
+ stderr: str
20
+ cmd: list[str]
21
+
22
+
23
+ def run(
24
+ args: Sequence[str],
25
+ *,
26
+ env: Mapping[str, str] | None = None,
27
+ cwd: str | Path | None = None,
28
+ check: bool = True,
29
+ timeout: float | None = None,
30
+ input: str | None = None,
31
+ ) -> FnoxResult:
32
+ """Run fnox with the given arguments and return the result."""
33
+ fnox = find_fnox_bin()
34
+ cmd = [fnox, *args]
35
+
36
+ run_env: dict[str, str] | None = None
37
+ if env is not None:
38
+ run_env = {**os.environ, **env}
39
+
40
+ try:
41
+ proc = subprocess.run( # noqa: S603
42
+ cmd,
43
+ capture_output=True,
44
+ text=True,
45
+ env=run_env,
46
+ cwd=cwd,
47
+ timeout=timeout,
48
+ input=input,
49
+ check=False,
50
+ )
51
+ except subprocess.TimeoutExpired as exc:
52
+ raise FnoxTimeoutError(f"fnox timed out after {exc.timeout}s") from exc
53
+ except FileNotFoundError as exc:
54
+ raise FnoxNotFoundError(str(exc)) from exc
55
+
56
+ result = FnoxResult(
57
+ returncode=proc.returncode,
58
+ stdout=proc.stdout,
59
+ stderr=proc.stderr,
60
+ cmd=cmd,
61
+ )
62
+
63
+ if check and proc.returncode != 0:
64
+ raise FnoxCommandError(proc.returncode, proc.stdout, proc.stderr, cmd)
65
+
66
+ return result
67
+
68
+
69
+ def run_passthrough(
70
+ args: Sequence[str],
71
+ *,
72
+ env: Mapping[str, str] | None = None,
73
+ cwd: str | Path | None = None,
74
+ ) -> NoReturn:
75
+ """Run fnox, forwarding stdio directly. On Unix, replaces the process."""
76
+ fnox = find_fnox_bin()
77
+ cmd = [fnox, *args]
78
+
79
+ if sys.platform == "win32":
80
+ try:
81
+ proc = subprocess.run(cmd, env=env, cwd=cwd, check=False) # noqa: S603
82
+ except KeyboardInterrupt:
83
+ sys.exit(2)
84
+ sys.exit(proc.returncode)
85
+ else:
86
+ if env is not None:
87
+ os.environ.update(env)
88
+ if cwd is not None:
89
+ os.chdir(cwd)
90
+ os.execvp(fnox, cmd) # noqa: S606