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.
Files changed (36) hide show
  1. xdg_kit-0.1.0/.github/workflows/check.yml +24 -0
  2. xdg_kit-0.1.0/.github/workflows/publish.yml +61 -0
  3. xdg_kit-0.1.0/.github/workflows/reusable-check.yml +101 -0
  4. xdg_kit-0.1.0/.gitignore +33 -0
  5. xdg_kit-0.1.0/LICENSE +21 -0
  6. xdg_kit-0.1.0/PKG-INFO +301 -0
  7. xdg_kit-0.1.0/README.ko.md +263 -0
  8. xdg_kit-0.1.0/README.md +268 -0
  9. xdg_kit-0.1.0/pyproject.toml +85 -0
  10. xdg_kit-0.1.0/src/xdg_kit/__init__.py +60 -0
  11. xdg_kit-0.1.0/src/xdg_kit/_oslock.py +78 -0
  12. xdg_kit-0.1.0/src/xdg_kit/atomic.py +67 -0
  13. xdg_kit-0.1.0/src/xdg_kit/backends.py +414 -0
  14. xdg_kit-0.1.0/src/xdg_kit/cli.py +240 -0
  15. xdg_kit-0.1.0/src/xdg_kit/credentials.py +185 -0
  16. xdg_kit-0.1.0/src/xdg_kit/environment.py +46 -0
  17. xdg_kit-0.1.0/src/xdg_kit/errors.py +41 -0
  18. xdg_kit-0.1.0/src/xdg_kit/locking.py +112 -0
  19. xdg_kit-0.1.0/src/xdg_kit/paths.py +169 -0
  20. xdg_kit-0.1.0/src/xdg_kit/permissions.py +143 -0
  21. xdg_kit-0.1.0/src/xdg_kit/py.typed +0 -0
  22. xdg_kit-0.1.0/src/xdg_kit/runtime.py +67 -0
  23. xdg_kit-0.1.0/src/xdg_kit/scrub.py +93 -0
  24. xdg_kit-0.1.0/tests/__init__.py +0 -0
  25. xdg_kit-0.1.0/tests/conftest.py +63 -0
  26. xdg_kit-0.1.0/tests/test_atomic.py +122 -0
  27. xdg_kit-0.1.0/tests/test_backends.py +424 -0
  28. xdg_kit-0.1.0/tests/test_cli.py +162 -0
  29. xdg_kit-0.1.0/tests/test_credentials.py +97 -0
  30. xdg_kit-0.1.0/tests/test_locking.py +61 -0
  31. xdg_kit-0.1.0/tests/test_oslock.py +158 -0
  32. xdg_kit-0.1.0/tests/test_packaging.py +18 -0
  33. xdg_kit-0.1.0/tests/test_paths.py +92 -0
  34. xdg_kit-0.1.0/tests/test_permissions.py +121 -0
  35. xdg_kit-0.1.0/tests/test_runtime.py +60 -0
  36. 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
@@ -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
+ [![check](https://github.com/seokhoonj/xdg-kit/actions/workflows/check.yml/badge.svg)](https://github.com/seokhoonj/xdg-kit/actions/workflows/check.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/xdg-kit)](https://pypi.org/project/xdg-kit/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/xdg-kit)](https://pypi.org/project/xdg-kit/)
39
+ [![License](https://img.shields.io/pypi/l/xdg-kit)](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