xdg-kit 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.
- xdg_kit-0.1.0/.github/workflows/check.yml +24 -0
- xdg_kit-0.1.0/.github/workflows/publish.yml +61 -0
- xdg_kit-0.1.0/.github/workflows/reusable-check.yml +101 -0
- xdg_kit-0.1.0/.gitignore +33 -0
- xdg_kit-0.1.0/LICENSE +21 -0
- xdg_kit-0.1.0/PKG-INFO +301 -0
- xdg_kit-0.1.0/README.ko.md +263 -0
- xdg_kit-0.1.0/README.md +268 -0
- xdg_kit-0.1.0/pyproject.toml +85 -0
- xdg_kit-0.1.0/src/xdg_kit/__init__.py +60 -0
- xdg_kit-0.1.0/src/xdg_kit/_oslock.py +78 -0
- xdg_kit-0.1.0/src/xdg_kit/atomic.py +67 -0
- xdg_kit-0.1.0/src/xdg_kit/backends.py +414 -0
- xdg_kit-0.1.0/src/xdg_kit/cli.py +240 -0
- xdg_kit-0.1.0/src/xdg_kit/credentials.py +185 -0
- xdg_kit-0.1.0/src/xdg_kit/environment.py +46 -0
- xdg_kit-0.1.0/src/xdg_kit/errors.py +41 -0
- xdg_kit-0.1.0/src/xdg_kit/locking.py +112 -0
- xdg_kit-0.1.0/src/xdg_kit/paths.py +169 -0
- xdg_kit-0.1.0/src/xdg_kit/permissions.py +143 -0
- xdg_kit-0.1.0/src/xdg_kit/py.typed +0 -0
- xdg_kit-0.1.0/src/xdg_kit/runtime.py +67 -0
- xdg_kit-0.1.0/src/xdg_kit/scrub.py +93 -0
- xdg_kit-0.1.0/tests/__init__.py +0 -0
- xdg_kit-0.1.0/tests/conftest.py +63 -0
- xdg_kit-0.1.0/tests/test_atomic.py +122 -0
- xdg_kit-0.1.0/tests/test_backends.py +424 -0
- xdg_kit-0.1.0/tests/test_cli.py +162 -0
- xdg_kit-0.1.0/tests/test_credentials.py +97 -0
- xdg_kit-0.1.0/tests/test_locking.py +61 -0
- xdg_kit-0.1.0/tests/test_oslock.py +158 -0
- xdg_kit-0.1.0/tests/test_packaging.py +18 -0
- xdg_kit-0.1.0/tests/test_paths.py +92 -0
- xdg_kit-0.1.0/tests/test_permissions.py +121 -0
- xdg_kit-0.1.0/tests/test_runtime.py +60 -0
- xdg_kit-0.1.0/tests/test_scrub.py +114 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
# A newer push to the same branch or PR makes the older run's result obsolete,
|
|
10
|
+
# so cancel it rather than let both run to completion.
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
# The check only reads the code to lint, type, and test it -- it never writes to the
|
|
16
|
+
# repo -- so it runs with the least privilege that still allows a checkout.
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
check:
|
|
22
|
+
# The full check lives in reusable-check.yml so the release gate in
|
|
23
|
+
# publish.yml runs the identical job on the release commit.
|
|
24
|
+
uses: ./.github/workflows/reusable-check.yml
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publish to PyPI on a GitHub Release, via PyPI Trusted Publishing (OIDC): no API
|
|
4
|
+
# token or password is stored anywhere -- GitHub mints a short-lived identity token
|
|
5
|
+
# that PyPI verifies against the publisher registered for this repo (owner seokhoonj
|
|
6
|
+
# / repo xdg-kit / this workflow / environment "pypi").
|
|
7
|
+
#
|
|
8
|
+
# Three jobs. `gate` runs the same reusable check that check.yml runs, on the
|
|
9
|
+
# release commit, so a red build (a lint/type error, a broken
|
|
10
|
+
# test, a lost py.typed or console script) cannot reach an upload. `build` builds
|
|
11
|
+
# the sdist and wheel and hands them to `publish` as an artifact. `publish` -- the
|
|
12
|
+
# only job holding the OIDC token -- downloads that artifact and uploads it, and
|
|
13
|
+
# checks out no source, so the privileged step runs the least code.
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
release:
|
|
17
|
+
types: [published]
|
|
18
|
+
|
|
19
|
+
# Workflow-level floor: any job without its own `permissions:` block still gets a
|
|
20
|
+
# read-only token, so a job added later cannot silently inherit a write-capable
|
|
21
|
+
# default. The build and publish jobs narrow it further below.
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
gate:
|
|
27
|
+
uses: ./.github/workflows/reusable-check.yml
|
|
28
|
+
|
|
29
|
+
build:
|
|
30
|
+
needs: gate
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
timeout-minutes: 15
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
37
|
+
with:
|
|
38
|
+
persist-credentials: false
|
|
39
|
+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
40
|
+
- name: Build sdist and wheel, then check the metadata
|
|
41
|
+
run: |
|
|
42
|
+
uv build
|
|
43
|
+
uvx twine check dist/*
|
|
44
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
|
|
49
|
+
publish:
|
|
50
|
+
needs: build
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
timeout-minutes: 15
|
|
53
|
+
environment: pypi
|
|
54
|
+
permissions:
|
|
55
|
+
id-token: write # OIDC; this is what replaces a stored token
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
58
|
+
with:
|
|
59
|
+
name: dist
|
|
60
|
+
path: dist/
|
|
61
|
+
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
name: reusable check
|
|
2
|
+
|
|
3
|
+
# The full check -- tests, lint, types, and the packaging assertions -- factored
|
|
4
|
+
# into one reusable job so both `check.yml` (on push / PR) and the release gate in
|
|
5
|
+
# `publish.yml` run the *identical* thing. One definition is what makes "publish
|
|
6
|
+
# gates on the full check" true; a second hand-maintained copy would drift, and a
|
|
7
|
+
# check added here silently would not gate releases.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
workflow_call:
|
|
11
|
+
|
|
12
|
+
# The floor lives on the shared body, not only on `check.yml`: `publish.yml`'s gate
|
|
13
|
+
# calls this same workflow, and if the floor were only on `check.yml` the gate would
|
|
14
|
+
# run this check with the repo-default (possibly write) token. Declared here, the
|
|
15
|
+
# check is read-only from every caller. A reusable workflow can only downscope from
|
|
16
|
+
# the caller, so this never widens anyone's token.
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
check:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 15
|
|
24
|
+
strategy:
|
|
25
|
+
fail-fast: false
|
|
26
|
+
matrix:
|
|
27
|
+
# The floor and the current release. requires-python says >=3.11, so 3.11 is
|
|
28
|
+
# the version that claim has to actually hold on; the upper end tracks whatever
|
|
29
|
+
# CPython currently ships, since >=3.11 promises every release above it.
|
|
30
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
34
|
+
with:
|
|
35
|
+
persist-credentials: false
|
|
36
|
+
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
|
37
|
+
|
|
38
|
+
- name: Install
|
|
39
|
+
# A venv per matrix version, not `uv pip --system`: the runner's system
|
|
40
|
+
# Python is externally managed and is one fixed version whatever the matrix
|
|
41
|
+
# says. This is also what the README tells a person to run.
|
|
42
|
+
run: |
|
|
43
|
+
uv venv --python ${{ matrix.python-version }}
|
|
44
|
+
uv pip install -e ".[dev]"
|
|
45
|
+
.venv/bin/python -c "import sys; print('testing on', sys.version)"
|
|
46
|
+
|
|
47
|
+
- name: Test
|
|
48
|
+
run: .venv/bin/pytest -q
|
|
49
|
+
|
|
50
|
+
- name: Lint
|
|
51
|
+
run: .venv/bin/ruff check src tests
|
|
52
|
+
|
|
53
|
+
- name: Types
|
|
54
|
+
run: .venv/bin/mypy
|
|
55
|
+
|
|
56
|
+
- name: Confirm the default install has no runtime dependencies
|
|
57
|
+
# The zero-dependency default is in the README and the pyproject: keyring is an
|
|
58
|
+
# opt-in extra, so a plain `pip install xdg-kit` must pull in nothing. A claim
|
|
59
|
+
# nothing checks quietly stops being true, so install into a bare environment
|
|
60
|
+
# and assert the runtime (non-extra) requirement set is empty.
|
|
61
|
+
run: |
|
|
62
|
+
uv venv /tmp/bare --python ${{ matrix.python-version }}
|
|
63
|
+
uv pip install --python /tmp/bare/bin/python .
|
|
64
|
+
/tmp/bare/bin/python -c "
|
|
65
|
+
import importlib.metadata as md
|
|
66
|
+
requires = md.requires('xdg-kit') or []
|
|
67
|
+
runtime = [r for r in requires if 'extra ==' not in r]
|
|
68
|
+
assert not runtime, f'runtime dependencies appeared: {runtime}'
|
|
69
|
+
import xdg_kit
|
|
70
|
+
print('xdg-kit', xdg_kit.__version__, 'imports with no third-party packages')
|
|
71
|
+
"
|
|
72
|
+
|
|
73
|
+
- name: Confirm the console script installed
|
|
74
|
+
# pyproject declares an `xdg-kit` entry point; a wheel that dropped it would
|
|
75
|
+
# leave the README and the docs pointing at a command that is not there. The
|
|
76
|
+
# top-level --version must print (not be shoved behind a required subcommand).
|
|
77
|
+
run: |
|
|
78
|
+
uv venv /tmp/cli --python ${{ matrix.python-version }}
|
|
79
|
+
uv pip install --python /tmp/cli/bin/python .
|
|
80
|
+
/tmp/cli/bin/xdg-kit --version
|
|
81
|
+
|
|
82
|
+
- name: Confirm a user's type checker can see the hints
|
|
83
|
+
# Every hint in this package is invisible to a user unless py.typed ships
|
|
84
|
+
# alongside it (PEP 561), and the source cannot answer whether it did:
|
|
85
|
+
# src/xdg_kit/py.typed can sit in git while the built wheel omits it. So ask
|
|
86
|
+
# it the way a user does -- install the built package into a clean
|
|
87
|
+
# environment and run their checker over their code.
|
|
88
|
+
run: |
|
|
89
|
+
uv venv /tmp/typed --python ${{ matrix.python-version }}
|
|
90
|
+
uv pip install --python /tmp/typed/bin/python . mypy
|
|
91
|
+
cat > /tmp/user_code.py <<'PY'
|
|
92
|
+
from xdg_kit import runtime_dir
|
|
93
|
+
|
|
94
|
+
# create is bool; a str is not, so a checker that can see the shipped hints
|
|
95
|
+
# must reject it and name the expected type.
|
|
96
|
+
runtime_dir("app", create="yes")
|
|
97
|
+
PY
|
|
98
|
+
/tmp/typed/bin/mypy /tmp/user_code.py > /tmp/mypy_out 2>&1 || true
|
|
99
|
+
cat /tmp/mypy_out
|
|
100
|
+
grep -q 'create' /tmp/mypy_out
|
|
101
|
+
grep -q 'bool' /tmp/mypy_out
|
xdg_kit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
|
|
12
|
+
# uv
|
|
13
|
+
uv.lock
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Local scratch (never tracked)
|
|
23
|
+
dev/
|
|
24
|
+
refs/
|
|
25
|
+
|
|
26
|
+
# AI coding agents
|
|
27
|
+
CLAUDE.md
|
|
28
|
+
.claude/
|
|
29
|
+
AGENTS.md
|
|
30
|
+
AGENT.md
|
|
31
|
+
.codex/
|
|
32
|
+
GEMINI.md
|
|
33
|
+
.gemini/
|
xdg_kit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seokhoon Joo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xdg_kit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xdg-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Secure XDG-style application storage for Python: paths, credentials, permissions, and runtime files.
|
|
5
|
+
Project-URL: Homepage, https://github.com/seokhoonj/xdg-kit
|
|
6
|
+
Project-URL: Repository, https://github.com/seokhoonj/xdg-kit
|
|
7
|
+
Project-URL: Issues, https://github.com/seokhoonj/xdg-kit/issues
|
|
8
|
+
Author-email: Seokhoon Joo <seokhoonj@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: appdirs,config,credentials,keyring,paths,runtime,xdg
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: keyring>=24; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Provides-Extra: keyring
|
|
31
|
+
Requires-Dist: keyring>=24; extra == 'keyring'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# xdg-kit
|
|
35
|
+
|
|
36
|
+
[](https://github.com/seokhoonj/xdg-kit/actions/workflows/check.yml)
|
|
37
|
+
[](https://pypi.org/project/xdg-kit/)
|
|
38
|
+
[](https://pypi.org/project/xdg-kit/)
|
|
39
|
+
[](https://github.com/seokhoonj/xdg-kit/blob/main/LICENSE)
|
|
40
|
+
|
|
41
|
+
**English** | [한국어](README.ko.md)
|
|
42
|
+
|
|
43
|
+
Secure XDG-style application storage for Python: paths, credentials, permissions, and
|
|
44
|
+
runtime files.
|
|
45
|
+
|
|
46
|
+
One small, dependency-free foundation for the two things every command-line app has to do
|
|
47
|
+
on disk — **find where its files live** and **resolve its secrets** — done once, the same
|
|
48
|
+
way, on every OS.
|
|
49
|
+
|
|
50
|
+
- **Directories** follow the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/)
|
|
51
|
+
(`config` / `data` / `state` / `cache` / `runtime`), using the `~/.config` layout on
|
|
52
|
+
every platform — the same convention git follows — so paths are identical across
|
|
53
|
+
machines and no platform library is needed.
|
|
54
|
+
- **Secrets** resolve in a fixed order — an explicit value, then the environment, then a
|
|
55
|
+
shared store, then the app's own store — so a key common to several apps can live in
|
|
56
|
+
**one** place instead of being copied into each.
|
|
57
|
+
- **Storage** is a plain `credentials.json` at mode 0600 in a 0700 directory by default
|
|
58
|
+
(reliable headless and across machines); the OS keyring is an opt-in backend with
|
|
59
|
+
automatic file fallback.
|
|
60
|
+
|
|
61
|
+
## 1. Install
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
pip install xdg-kit # file store, zero runtime dependencies
|
|
65
|
+
pip install "xdg-kit[keyring]" # add the optional OS keyring backend
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Check it worked:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
xdg-kit --version
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires Python 3.11+.
|
|
75
|
+
|
|
76
|
+
## 2. Quickstart
|
|
77
|
+
|
|
78
|
+
Store a secret once (prompted, without echo):
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
xdg-kit set myapp API_KEY
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then read it back in code — `require` resolves it ($API_KEY, else myapp's store) and raises
|
|
85
|
+
if it is set nowhere:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from xdg_kit import config_dir, Credentials
|
|
89
|
+
|
|
90
|
+
config_dir("myapp") # ~/.config/myapp (where files live)
|
|
91
|
+
Credentials("myapp").require("API_KEY") # read the secret; raises if unset
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 3. Directories
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from xdg_kit import config_dir, data_dir, state_dir, cache_dir, runtime_dir
|
|
98
|
+
|
|
99
|
+
config_dir("myapp") # ~/.config/myapp (or $XDG_CONFIG_HOME/...)
|
|
100
|
+
data_dir("myapp") # ~/.local/share/myapp (or $XDG_DATA_HOME/...)
|
|
101
|
+
state_dir("myapp") # ~/.local/state/myapp (or $XDG_STATE_HOME/...)
|
|
102
|
+
cache_dir("myapp") # ~/.cache/myapp (or $XDG_CACHE_HOME/...)
|
|
103
|
+
runtime_dir("myapp") # $XDG_RUNTIME_DIR/myapp, else a secured 0700 temp dir
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The app name is validated as a single path segment, so a crafted name can never escape its
|
|
107
|
+
base. `data_dir` and `state_dir` also honour a per-app `<APP>_DATA_DIR` / `<APP>_STATE_DIR`
|
|
108
|
+
environment override (an absolute path used as-is), so a large archive can be relocated to
|
|
109
|
+
another volume without editing anything. `runtime_dir` is the one XDG directory with no
|
|
110
|
+
specified default; when `XDG_RUNTIME_DIR` is unset (cron, containers, macOS, Windows) it
|
|
111
|
+
creates and secures a private directory under the system temp dir (uid-keyed on POSIX,
|
|
112
|
+
where a shared `/tmp` must not be hijacked), as the spec directs, and returns it (pass
|
|
113
|
+
`create=False` to compute the path without creating it).
|
|
114
|
+
|
|
115
|
+
## 4. Secrets
|
|
116
|
+
|
|
117
|
+
Secrets (passwords, tokens, API keys) live in **one `credentials.json` per app** —
|
|
118
|
+
`config_dir(app)/credentials.json`, e.g. `~/.config/myapp/credentials.json` for `myapp`.
|
|
119
|
+
That one file is the app's **store**. Which store is read is decided by the app name, so one
|
|
120
|
+
app can name another app's store and read it alongside its own (see **shared store** below).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from xdg_kit import Credentials, get_secret, require_secret, set_secret, unset_secret, secret_names
|
|
124
|
+
|
|
125
|
+
# Resolution order: override > environment > shared stores > this app's store
|
|
126
|
+
creds = Credentials("myapp", shared=["auth"])
|
|
127
|
+
key = creds.require("API_KEY") # env $API_KEY, then auth's store, then myapp's; raises if unset
|
|
128
|
+
maybe = creds.secret("API_KEY") # same, but returns None instead of raising
|
|
129
|
+
creds.set("API_KEY", value="sk-...") # writes myapp's own store (value is keyword-only)
|
|
130
|
+
creds.unset("API_KEY") # removes it from myapp's store (no-op if absent)
|
|
131
|
+
creds.names() # ["API_KEY", ...] -- names only, never values
|
|
132
|
+
|
|
133
|
+
# One-shot module-level convenience (each constructs a Credentials internally):
|
|
134
|
+
get_secret("myapp", "API_KEY") # -> str | None
|
|
135
|
+
require_secret("myapp", "API_KEY") # -> str, raises CredentialsError if unset
|
|
136
|
+
set_secret("myapp", "API_KEY", value="sk-...") # value is keyword-only
|
|
137
|
+
unset_secret("myapp", "API_KEY") # remove from this app's store (no-op if absent)
|
|
138
|
+
secret_names("myapp") # -> list[str]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The **shared store** is how a key common to several apps stops being duplicated: store it
|
|
142
|
+
once under a shared app (say `"auth"`), and every consumer resolves it with
|
|
143
|
+
`shared=["auth"]`. A key specific to one app stays in that app's own store.
|
|
144
|
+
|
|
145
|
+
## 5. The `xdg-kit` command
|
|
146
|
+
|
|
147
|
+
Manage any app's secrets from one place, in one format — no need to learn each package's
|
|
148
|
+
own way to store a key:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
xdg-kit set myapp API_KEY # prompts without echo; writes credentials.json (0600)
|
|
152
|
+
xdg-kit set myapp API_KEY --value sk-… # or pass it directly (exposes it in argv; prefer the prompt)
|
|
153
|
+
xdg-kit list myapp # names only, never values
|
|
154
|
+
xdg-kit get myapp API_KEY # masked (sk***ef); reads the stored value only
|
|
155
|
+
xdg-kit get myapp API_KEY --reveal # print in full
|
|
156
|
+
xdg-kit get myapp API_KEY --resolve # also consult the environment variable, not just the stored value
|
|
157
|
+
xdg-kit unset myapp API_KEY
|
|
158
|
+
xdg-kit path myapp # print the credentials.json path
|
|
159
|
+
xdg-kit dirs myapp # print all five directories
|
|
160
|
+
xdg-kit doctor # check every app's credentials file/dir permissions
|
|
161
|
+
xdg-kit doctor myapp other-app # check only the named apps
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`set`, `get`, `list`, and `unset` accept `--keyring` to operate on the OS keyring backend
|
|
165
|
+
(with automatic file fallback). Exit codes: `0` success, `1` a command failure (a missing
|
|
166
|
+
or empty secret, or a runtime error), `2` a usage error (an invalid app name, or no value
|
|
167
|
+
given to `set` with no interactive prompt available).
|
|
168
|
+
|
|
169
|
+
## 6. Keyring
|
|
170
|
+
|
|
171
|
+
Secrets — passwords, tokens, API keys — can live in one of two places:
|
|
172
|
+
|
|
173
|
+
- **File store** (the default) — a `credentials.json` in the app's folder. Works reliably
|
|
174
|
+
everywhere, but stores the value in plaintext.
|
|
175
|
+
- **OS keyring** (opt-in) — the OS-provided encrypted vault (macOS Keychain, GNOME Keyring,
|
|
176
|
+
etc.). More secure, but unavailable where no keyring exists or it is locked: headless
|
|
177
|
+
servers, cron jobs, containers.
|
|
178
|
+
|
|
179
|
+
The file store is the default because it works everywhere. To use the keyring, turn it on
|
|
180
|
+
explicitly:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from xdg_kit.backends import FileBackend, KeyringBackend, default_backend
|
|
184
|
+
from xdg_kit import Credentials
|
|
185
|
+
|
|
186
|
+
backend = KeyringBackend(fallback=FileBackend()) # keyring when available, else the file
|
|
187
|
+
creds = Credentials("myapp", backend=backend)
|
|
188
|
+
# or: default_backend(use_keyring=True) -- the same thing
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
With the keyring turned on, xdg-kit behaves like this:
|
|
192
|
+
|
|
193
|
+
- **Normally (keyring reachable)**: the value is stored in the keyring, and the keyring holds
|
|
194
|
+
authority over it — if the file also has the same key, the keyring value wins. A successful
|
|
195
|
+
`set` / `unset` also clears any stale plaintext copy from the file, so switching to the
|
|
196
|
+
keyring never leaves a file copy behind.
|
|
197
|
+
- **When the keyring can't be used**: an *absent* keyring (not installed, or no backend on a
|
|
198
|
+
server) makes every operation fall back to the file store, with a one-time warning so a user
|
|
199
|
+
who turned the keyring on learns the value went to the file. A *present but failing* keyring
|
|
200
|
+
(e.g. locked) still lets `get` and `set` fall back, but `unset` fails loudly (raises) rather
|
|
201
|
+
than risk reporting a secret deleted while it may still be in the keyring.
|
|
202
|
+
|
|
203
|
+
**One caveat** — this reconciliation runs only one way, keyring → file; the
|
|
204
|
+
reverse (file → keyring) is not automatic: a value written to the file while the keyring
|
|
205
|
+
was unavailable is *not* migrated back into the keyring once it
|
|
206
|
+
recovers. So if the keyring still holds an older value for that key, a read hits the keyring
|
|
207
|
+
first and that older value shadows the newer one in the file. The reliable fix is to
|
|
208
|
+
**re-set the key while the keyring is reachable** — the new value then goes straight into the
|
|
209
|
+
keyring and the stale file copy is cleared. Do *not* try to fix it by deleting the keyring
|
|
210
|
+
entry with `xdg-kit unset --keyring`: while the keyring is reachable that also deletes the
|
|
211
|
+
newer file copy, losing the value.
|
|
212
|
+
|
|
213
|
+
## 7. Redacting secrets from logs
|
|
214
|
+
|
|
215
|
+
An API often echoes your key back inside an error message or a request URL, so logging an
|
|
216
|
+
unscrubbed exception can leak the very secret it failed with into a log file or your terminal.
|
|
217
|
+
These helpers replace known secret values with `***` before anything is logged or surfaced.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from xdg_kit.scrub import scrub_secrets, scrub_exception
|
|
221
|
+
|
|
222
|
+
scrub_secrets("failed with sk-abc123", [key]) # "failed with ***"
|
|
223
|
+
raise scrub_exception(err, [key]) # scrubs the whole __cause__/__context__ chain
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`scrub_exception` never raises and rewrites each exception's `args` and a string `url`
|
|
227
|
+
attribute; for an exception with a custom `__str__`, also pass the rendered log line
|
|
228
|
+
through `scrub_secrets`.
|
|
229
|
+
|
|
230
|
+
## 8. Single-instance locking
|
|
231
|
+
|
|
232
|
+
Stop a job from overlapping with another copy of itself — two cron runs, or a cron run and a
|
|
233
|
+
manual one. Such runs redo the same work, produce duplicate output (double sends,
|
|
234
|
+
duplicate rows), and race on shared state (two writers corrupting one file); a `FileLock`
|
|
235
|
+
lets the later run detect that one is already in progress and skip rather than pile on.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from xdg_kit.locking import FileLock, single_instance
|
|
239
|
+
|
|
240
|
+
with single_instance("myapp", "poll") as acquired:
|
|
241
|
+
if not acquired:
|
|
242
|
+
return # another run holds the lock; skip rather than pile on
|
|
243
|
+
...
|
|
244
|
+
|
|
245
|
+
lock = FileLock("myapp", "poll") # or hold it explicitly
|
|
246
|
+
if lock.acquire():
|
|
247
|
+
try:
|
|
248
|
+
...
|
|
249
|
+
finally:
|
|
250
|
+
lock.release()
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The lock lives in `runtime_dir` and is released by the OS when the process exits, even on a
|
|
254
|
+
crash.
|
|
255
|
+
|
|
256
|
+
## 9. Public API reference
|
|
257
|
+
|
|
258
|
+
### Everyday API
|
|
259
|
+
|
|
260
|
+
| Import | What it is |
|
|
261
|
+
|--------|------------|
|
|
262
|
+
| `config_dir` / `data_dir` / `state_dir` / `cache_dir` (`xdg_kit`) | XDG directory for an app (a `Path`). |
|
|
263
|
+
| `runtime_dir(app, *, create=True)` (`xdg_kit`) | Secured session runtime directory. |
|
|
264
|
+
| `Credentials(app, *, shared=(), backend=None)` (`xdg_kit`) | The four-tier secret resolver: `.secret` / `.require` / `.set` / `.unset` / `.names`. |
|
|
265
|
+
| `get_secret` / `require_secret` / `set_secret` / `unset_secret` / `secret_names` (`xdg_kit`) | Module-level one-shot convenience over `Credentials`. |
|
|
266
|
+
| `FileBackend` / `KeyringBackend` / `default_backend` (`xdg_kit.backends`) | Storage backends: the file store (default) and the OS keyring, plus the chooser. |
|
|
267
|
+
| `scrub_secrets` / `scrub_exception` (`xdg_kit.scrub`) | Redact secret values from text and exception chains. |
|
|
268
|
+
| `FileLock` / `single_instance` (`xdg_kit.locking`) | Single-instance advisory locking in `runtime_dir`. |
|
|
269
|
+
| `XdgKitError` / `CredentialsError` / `InsecureStorageError` / `InvalidAppNameError` (`xdg_kit`) | The exception hierarchy. |
|
|
270
|
+
| `__version__` (`xdg_kit`) | The installed package version string. |
|
|
271
|
+
|
|
272
|
+
### Building blocks (for library authors — rarely called directly)
|
|
273
|
+
|
|
274
|
+
| Import | What it is |
|
|
275
|
+
|--------|------------|
|
|
276
|
+
| `SecretBackend` (`xdg_kit.backends`) | The backend interface (a `Protocol`) — implement it to write your own store. |
|
|
277
|
+
| `ensure_private_dir` / `restrict_dir_to_owner` / `warn_if_group_or_world_readable` (`xdg_kit.permissions`) | Directory/file permission guarantees and checks. |
|
|
278
|
+
| `PRIVATE_FILE_MODE` / `PRIVATE_DIR_MODE` (`xdg_kit.permissions`) | The `0600` / `0700` mode constants for private files and directories. |
|
|
279
|
+
| `write_bytes_atomic` / `write_text_atomic` (`xdg_kit.atomic`) | Atomic 0600 writes. |
|
|
280
|
+
| `env_value` / `absolute_override` (`xdg_kit.environment`) | Read an env value (blank = absent) / an absolute-path override. |
|
|
281
|
+
| `app_dir_segment` (`xdg_kit.paths`) | Validate an app name as a safe path segment. |
|
|
282
|
+
|
|
283
|
+
## 10. For library authors
|
|
284
|
+
|
|
285
|
+
`xdg-kit` provides only the base layer — directories, secret resolution, permissions,
|
|
286
|
+
atomic writes, locking, and scrubbing. Your package keeps its own domain configuration
|
|
287
|
+
(accounts, routes, topics) and reaches for xdg-kit underneath:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from xdg_kit import config_dir, Credentials
|
|
291
|
+
|
|
292
|
+
def credentials_path():
|
|
293
|
+
return config_dir("yourapp") / "credentials.json"
|
|
294
|
+
|
|
295
|
+
def api_key() -> str:
|
|
296
|
+
return Credentials("yourapp").require("YOURAPP_API_KEY")
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## 11. License
|
|
300
|
+
|
|
301
|
+
MIT
|