pyclilogin 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .coverage
12
+ coverage.xml
13
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Birch
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.
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyclilogin
3
+ Version: 0.1.1
4
+ Summary: Device-grant login for Python CLIs: one config file, a keychain token store with a visible fallback, and a refresh serialized across processes
5
+ Project-URL: Repository, https://github.com/datapointchris/pyclilogin
6
+ Project-URL: Issues, https://github.com/datapointchris/pyclilogin/issues
7
+ Project-URL: Changelog, https://github.com/datapointchris/pyclilogin/releases
8
+ Author-email: Chris Birch <datapointchris@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,device-authorization-grant,keyring,login,oauth2,oidc
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx2>=2.9.1
23
+ Requires-Dist: keyring>=25
24
+ Provides-Extra: typer
25
+ Requires-Dist: typer>=0.15.0; extra == 'typer'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pyclilogin
29
+
30
+ OIDC device-grant login for Python command-line tools. Tokens live in the OS
31
+ keychain, and the refresh is serialized across every process on the machine.
32
+ The same tool run by a service, with no person to approve a login, uses the
33
+ client-credentials grant instead.
34
+
35
+ It is the Python sibling of
36
+ [goclilogin](https://github.com/datapointchris/goclilogin), and keeps its
37
+ contract point for point: the same config file, the same keychain fallback, the
38
+ same refresh lock. A Go CLI and a Python CLI built on the two log in, store and
39
+ refresh the same way. Its server-side counterpart is
40
+ [pyapiauth](https://github.com/datapointchris/pyapiauth), which verifies the
41
+ tokens this package obtains.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uv add pyclilogin # the core
47
+ uv add "pyclilogin[typer]" # plus the ready-made `auth` commands
48
+ ```
49
+
50
+ ## Starting from scratch
51
+
52
+ A CLI called `acme` describes itself once, as a `Product`, and mounts the
53
+ `auth` commands on its typer app:
54
+
55
+ ```python
56
+ from contextvars import ContextVar
57
+
58
+ import typer
59
+ from pyclilogin import Product
60
+ from pyclilogin.typercmd import add_auth_commands
61
+
62
+ PRODUCT = Product(name='acme', keyring_service='acme-cli')
63
+ API_FLAG: ContextVar[str] = ContextVar('api_flag', default='')
64
+
65
+ app = typer.Typer()
66
+
67
+
68
+ @app.callback()
69
+ def root(api: str = typer.Option('', '--api')):
70
+ API_FLAG.set(api)
71
+
72
+
73
+ add_auth_commands(app, PRODUCT, api_flag=API_FLAG.get)
74
+ ```
75
+
76
+ That gives `acme auth login [URL]`, `acme auth logout`, `acme auth token` and
77
+ `acme auth status`. Every other command gets its token the same way:
78
+
79
+ ```python
80
+ import httpx2
81
+
82
+ resolved = PRODUCT.load(API_FLAG.get()) # the config file, with --api outranking it
83
+ source = PRODUCT.token_source(resolved) # NotLoggedIn where nothing is stored
84
+ response = httpx2.get(
85
+ f'{resolved.api.value}/items',
86
+ headers={'Authorization': f'Bearer {source.token().access_token}'}, # refreshed first if it lapsed
87
+ )
88
+ ```
89
+
90
+ The first run needs three things from outside the CLI:
91
+
92
+ - The provider advertises a `device_authorization_endpoint` in its OIDC
93
+ discovery document.
94
+ - The client id is registered at the provider as a public client with no
95
+ secret.
96
+ - The API serves RFC 9728 protected-resource metadata naming its login server.
97
+ A `config.toml` that already sets `issuer` skips this.
98
+
99
+ Every error is a `LoginError` whose message names its fix, so a CLI's entry
100
+ point catches that one class and prints the message.
101
+
102
+ ## Which call answers which question
103
+
104
+ | Ask | Call | Reads |
105
+ | --- | --- | --- |
106
+ | Where is this CLI pointed | `Product.load`, `write_deployment` | the config file, and an `--api`'s RFC 9728 metadata where it differs from the file's |
107
+ | What does a first login fill in | `Product.for_login` | the config file, then the API's RFC 9728 metadata for a missing `issuer` |
108
+ | How does a person log in | `Provider.login` | the issuer's discovery document, then the device grant |
109
+ | Where did the token go | `TokenStore.save`, `TokenStore.load` | the OS keychain, then the mode-600 file in the state directory |
110
+ | How do requests get a token | `Product.token_source` | the store, then on a refresh the refresh lock and the discovery document |
111
+ | How does a service with no person authenticate | `ServiceTokenSource` | the token endpoint, with the client's secret |
112
+ | How does a CLI use tokens a broker hands it | `CommandTokenSource` | the stdout of `token_command` from the config file |
113
+ | How do my tests avoid the real keychain | `pyclilogin.testing.MemoryKeyring` | a dict |
114
+
115
+ ## One config file says where the CLI is pointed, and only login writes it
116
+
117
+ `$XDG_CONFIG_HOME/<name>/config.toml` holds three keys:
118
+
119
+ ```toml
120
+ api = "https://acme.example.com"
121
+ issuer = "https://auth.example.com"
122
+ client_id = "acme-cli-laptop"
123
+ ```
124
+
125
+ Every command reads it through `Product.load`. An explicit `--api` outranks the
126
+ file's. Nothing else is read: no environment variable, and no address compiled
127
+ into the package, because each value names one installation.
128
+
129
+ `acme auth login https://acme.example.com` fills in only what the file lacks.
130
+ `api` comes from the URL, `issuer` from the API's
131
+ `/.well-known/oauth-protected-resource`, and `client_id` from `--client-id` or
132
+ `acme-cli-<short hostname>`. Each is printed with where it came from before the
133
+ grant starts, and written once the grant succeeds. A value the file already
134
+ holds is never rewritten, and a typed value differing from it is refused.
135
+
136
+ An `--api` differing from the file's must name the file's issuer in its own
137
+ metadata. Otherwise the command is refused before any request carries a token.
138
+
139
+ Login never writes through a symlink. A link means a config manager owns the
140
+ file, and its source may be every machine's. A client id written there would
141
+ become every machine's.
142
+
143
+ A key outside these three belongs to the product, as in goclilogin. A product
144
+ whose file holds nothing else passes `product_keys=frozenset()`, and a renamed
145
+ key such as `api_base` is then refused rather than read as a file with no
146
+ `api`.
147
+
148
+ ## The refresh is serialized across every process on the machine
149
+
150
+ A provider following RFC 9700 rotates the refresh token on every use, and
151
+ revokes the whole grant when a consumed one is presented again. Two processes
152
+ refreshing from the same stored token therefore cost an interactive login.
153
+ One rotates, and the other replays the token that rotation consumed.
154
+
155
+ `TokenSource.token` holds an exclusive `flock` in the state directory for the
156
+ refresh, and reads the store again once it has the lock. A process that waited
157
+ uses the winner's token instead of replaying its own. A lock that cannot be
158
+ taken within ten seconds gives an unlocked refresh. That risks one login, where
159
+ blocking on a wedged holder would hang every command.
160
+
161
+ A command holding a live token makes no call to the provider at all.
162
+
163
+ ## The token goes to the keychain, and the downgrade is visible
164
+
165
+ `TokenStore.save` returns the backend that took the token. On a Linux host with
166
+ no Secret Service, that is a mode-600 `token.json` in the state directory, and
167
+ `auth login` says so. A macOS keychain locked to an SSH session is refused
168
+ instead, with `security unlock-keychain` as the fix. A plaintext copy written
169
+ there would outlive the lock. `TokenStore.delete` clears both stores, so a file
170
+ token written before a host had a keyring cannot outlive the logout.
171
+
172
+ ## A service authenticates as itself, never as a person
173
+
174
+ With `ACME_CLIENT_SECRET` set, `Product.token_source` authenticates as the
175
+ config file's `client_id` with the client-credentials grant. The variable is
176
+ `<NAME>_CLIENT_SECRET` unless `Product.secret_variable` names another. The
177
+ secret stays in the environment, because `auth status` prints the file.
178
+
179
+ ```python
180
+ source = PRODUCT.token_source(resolved, service_scopes=('acme.items.read',))
181
+ ```
182
+
183
+ A service's token carries only the scopes its command names, so a command
184
+ naming none is refused before any request. Nothing is stored and no lock is
185
+ taken: the grant issues no refresh token. Name the service's client so the API
186
+ can tell it from a person's, such as `acme-svc-scheduler`.
187
+
188
+ ## A broker's token command replaces the login
189
+
190
+ A workload whose broker hands it short-lived tokens names the broker's command
191
+ in the file:
192
+
193
+ ```toml
194
+ api = "https://acme.example.com"
195
+ token_command = ["broker", "token", "--audience", "acme"]
196
+ ```
197
+
198
+ The command prints an RFC 6749 § 5.1 token response. Every request carries its
199
+ `access_token`, and the command runs again once `expires_in` lapses.
200
+
201
+ ## Linux and macOS
202
+
203
+ The refresh lock is `fcntl.flock`, so the package runs on Linux and macOS.
204
+ Releases are cut only from a commit whose suite passes on both, on Python 3.11
205
+ and 3.13.
206
+
207
+ ## What it deliberately does not do
208
+
209
+ It is not a general OIDC client. It has no ID token verification, no
210
+ back-channel logout, no DPoP and no mTLS-bound tokens. An API that verifies
211
+ tokens against the provider's JWKS, as pyapiauth does, needs none of them.
@@ -0,0 +1,184 @@
1
+ # pyclilogin
2
+
3
+ OIDC device-grant login for Python command-line tools. Tokens live in the OS
4
+ keychain, and the refresh is serialized across every process on the machine.
5
+ The same tool run by a service, with no person to approve a login, uses the
6
+ client-credentials grant instead.
7
+
8
+ It is the Python sibling of
9
+ [goclilogin](https://github.com/datapointchris/goclilogin), and keeps its
10
+ contract point for point: the same config file, the same keychain fallback, the
11
+ same refresh lock. A Go CLI and a Python CLI built on the two log in, store and
12
+ refresh the same way. Its server-side counterpart is
13
+ [pyapiauth](https://github.com/datapointchris/pyapiauth), which verifies the
14
+ tokens this package obtains.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ uv add pyclilogin # the core
20
+ uv add "pyclilogin[typer]" # plus the ready-made `auth` commands
21
+ ```
22
+
23
+ ## Starting from scratch
24
+
25
+ A CLI called `acme` describes itself once, as a `Product`, and mounts the
26
+ `auth` commands on its typer app:
27
+
28
+ ```python
29
+ from contextvars import ContextVar
30
+
31
+ import typer
32
+ from pyclilogin import Product
33
+ from pyclilogin.typercmd import add_auth_commands
34
+
35
+ PRODUCT = Product(name='acme', keyring_service='acme-cli')
36
+ API_FLAG: ContextVar[str] = ContextVar('api_flag', default='')
37
+
38
+ app = typer.Typer()
39
+
40
+
41
+ @app.callback()
42
+ def root(api: str = typer.Option('', '--api')):
43
+ API_FLAG.set(api)
44
+
45
+
46
+ add_auth_commands(app, PRODUCT, api_flag=API_FLAG.get)
47
+ ```
48
+
49
+ That gives `acme auth login [URL]`, `acme auth logout`, `acme auth token` and
50
+ `acme auth status`. Every other command gets its token the same way:
51
+
52
+ ```python
53
+ import httpx2
54
+
55
+ resolved = PRODUCT.load(API_FLAG.get()) # the config file, with --api outranking it
56
+ source = PRODUCT.token_source(resolved) # NotLoggedIn where nothing is stored
57
+ response = httpx2.get(
58
+ f'{resolved.api.value}/items',
59
+ headers={'Authorization': f'Bearer {source.token().access_token}'}, # refreshed first if it lapsed
60
+ )
61
+ ```
62
+
63
+ The first run needs three things from outside the CLI:
64
+
65
+ - The provider advertises a `device_authorization_endpoint` in its OIDC
66
+ discovery document.
67
+ - The client id is registered at the provider as a public client with no
68
+ secret.
69
+ - The API serves RFC 9728 protected-resource metadata naming its login server.
70
+ A `config.toml` that already sets `issuer` skips this.
71
+
72
+ Every error is a `LoginError` whose message names its fix, so a CLI's entry
73
+ point catches that one class and prints the message.
74
+
75
+ ## Which call answers which question
76
+
77
+ | Ask | Call | Reads |
78
+ | --- | --- | --- |
79
+ | Where is this CLI pointed | `Product.load`, `write_deployment` | the config file, and an `--api`'s RFC 9728 metadata where it differs from the file's |
80
+ | What does a first login fill in | `Product.for_login` | the config file, then the API's RFC 9728 metadata for a missing `issuer` |
81
+ | How does a person log in | `Provider.login` | the issuer's discovery document, then the device grant |
82
+ | Where did the token go | `TokenStore.save`, `TokenStore.load` | the OS keychain, then the mode-600 file in the state directory |
83
+ | How do requests get a token | `Product.token_source` | the store, then on a refresh the refresh lock and the discovery document |
84
+ | How does a service with no person authenticate | `ServiceTokenSource` | the token endpoint, with the client's secret |
85
+ | How does a CLI use tokens a broker hands it | `CommandTokenSource` | the stdout of `token_command` from the config file |
86
+ | How do my tests avoid the real keychain | `pyclilogin.testing.MemoryKeyring` | a dict |
87
+
88
+ ## One config file says where the CLI is pointed, and only login writes it
89
+
90
+ `$XDG_CONFIG_HOME/<name>/config.toml` holds three keys:
91
+
92
+ ```toml
93
+ api = "https://acme.example.com"
94
+ issuer = "https://auth.example.com"
95
+ client_id = "acme-cli-laptop"
96
+ ```
97
+
98
+ Every command reads it through `Product.load`. An explicit `--api` outranks the
99
+ file's. Nothing else is read: no environment variable, and no address compiled
100
+ into the package, because each value names one installation.
101
+
102
+ `acme auth login https://acme.example.com` fills in only what the file lacks.
103
+ `api` comes from the URL, `issuer` from the API's
104
+ `/.well-known/oauth-protected-resource`, and `client_id` from `--client-id` or
105
+ `acme-cli-<short hostname>`. Each is printed with where it came from before the
106
+ grant starts, and written once the grant succeeds. A value the file already
107
+ holds is never rewritten, and a typed value differing from it is refused.
108
+
109
+ An `--api` differing from the file's must name the file's issuer in its own
110
+ metadata. Otherwise the command is refused before any request carries a token.
111
+
112
+ Login never writes through a symlink. A link means a config manager owns the
113
+ file, and its source may be every machine's. A client id written there would
114
+ become every machine's.
115
+
116
+ A key outside these three belongs to the product, as in goclilogin. A product
117
+ whose file holds nothing else passes `product_keys=frozenset()`, and a renamed
118
+ key such as `api_base` is then refused rather than read as a file with no
119
+ `api`.
120
+
121
+ ## The refresh is serialized across every process on the machine
122
+
123
+ A provider following RFC 9700 rotates the refresh token on every use, and
124
+ revokes the whole grant when a consumed one is presented again. Two processes
125
+ refreshing from the same stored token therefore cost an interactive login.
126
+ One rotates, and the other replays the token that rotation consumed.
127
+
128
+ `TokenSource.token` holds an exclusive `flock` in the state directory for the
129
+ refresh, and reads the store again once it has the lock. A process that waited
130
+ uses the winner's token instead of replaying its own. A lock that cannot be
131
+ taken within ten seconds gives an unlocked refresh. That risks one login, where
132
+ blocking on a wedged holder would hang every command.
133
+
134
+ A command holding a live token makes no call to the provider at all.
135
+
136
+ ## The token goes to the keychain, and the downgrade is visible
137
+
138
+ `TokenStore.save` returns the backend that took the token. On a Linux host with
139
+ no Secret Service, that is a mode-600 `token.json` in the state directory, and
140
+ `auth login` says so. A macOS keychain locked to an SSH session is refused
141
+ instead, with `security unlock-keychain` as the fix. A plaintext copy written
142
+ there would outlive the lock. `TokenStore.delete` clears both stores, so a file
143
+ token written before a host had a keyring cannot outlive the logout.
144
+
145
+ ## A service authenticates as itself, never as a person
146
+
147
+ With `ACME_CLIENT_SECRET` set, `Product.token_source` authenticates as the
148
+ config file's `client_id` with the client-credentials grant. The variable is
149
+ `<NAME>_CLIENT_SECRET` unless `Product.secret_variable` names another. The
150
+ secret stays in the environment, because `auth status` prints the file.
151
+
152
+ ```python
153
+ source = PRODUCT.token_source(resolved, service_scopes=('acme.items.read',))
154
+ ```
155
+
156
+ A service's token carries only the scopes its command names, so a command
157
+ naming none is refused before any request. Nothing is stored and no lock is
158
+ taken: the grant issues no refresh token. Name the service's client so the API
159
+ can tell it from a person's, such as `acme-svc-scheduler`.
160
+
161
+ ## A broker's token command replaces the login
162
+
163
+ A workload whose broker hands it short-lived tokens names the broker's command
164
+ in the file:
165
+
166
+ ```toml
167
+ api = "https://acme.example.com"
168
+ token_command = ["broker", "token", "--audience", "acme"]
169
+ ```
170
+
171
+ The command prints an RFC 6749 § 5.1 token response. Every request carries its
172
+ `access_token`, and the command runs again once `expires_in` lapses.
173
+
174
+ ## Linux and macOS
175
+
176
+ The refresh lock is `fcntl.flock`, so the package runs on Linux and macOS.
177
+ Releases are cut only from a commit whose suite passes on both, on Python 3.11
178
+ and 3.13.
179
+
180
+ ## What it deliberately does not do
181
+
182
+ It is not a general OIDC client. It has no ID token verification, no
183
+ back-channel logout, no DPoP and no mTLS-bound tokens. An API that verifies
184
+ tokens against the provider's JWKS, as pyapiauth does, needs none of them.
@@ -0,0 +1,218 @@
1
+ [project]
2
+ name = "pyclilogin"
3
+ dynamic = ["version"]
4
+ description = "Device-grant login for Python CLIs: one config file, a keychain token store with a visible fallback, and a refresh serialized across processes"
5
+ authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ keywords = ["oauth2", "oidc", "device-authorization-grant", "cli", "keyring", "login"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: MacOS",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Topic :: Security",
18
+ "Topic :: Software Development :: Libraries",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ # 3.11 is what `tomllib` and `enum.StrEnum` require, and every dependency
23
+ # supports it. Raising the floor excludes callers.
24
+ requires-python = ">=3.11"
25
+
26
+ # Each one is on the credential path, so each earns its place: httpx2 for the
27
+ # five requests the login makes, and keyring for the OS keychain, the Python
28
+ # counterpart of the go-keyring goclilogin uses.
29
+ dependencies = [
30
+ "httpx2>=2.9.1",
31
+ "keyring>=25",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ typer = ["typer>=0.15.0"]
36
+
37
+ [project.urls]
38
+ Repository = "https://github.com/datapointchris/pyclilogin"
39
+ Issues = "https://github.com/datapointchris/pyclilogin/issues"
40
+ Changelog = "https://github.com/datapointchris/pyclilogin/releases"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "bandit>=1.7.8",
45
+ "mypy>=1.10.0",
46
+ "pre-commit>=4.3.0",
47
+ "pytest>=8.0.0",
48
+ "ruff==0.17.1",
49
+ "typer>=0.15.0",
50
+ ]
51
+
52
+
53
+ # ---------- Tool Configurations ---------- #
54
+
55
+
56
+ [tool.bandit]
57
+ exclude_dirs = [".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "tests"]
58
+ skips = ["B404", "B603"]
59
+
60
+
61
+ [tool.codespell]
62
+ skip = '*.lock'
63
+ check-filenames = true
64
+ check-hidden = true
65
+ builtin = "clear,rare"
66
+ uri-ignore-words-list = "*"
67
+
68
+
69
+ [tool.mypy]
70
+ pretty = true
71
+ ignore_missing_imports = true
72
+ check_untyped_defs = false
73
+ warn_return_any = false
74
+
75
+ [tool.pytest.ini_options]
76
+ minversion = "8.0"
77
+ testpaths = ["tests"]
78
+ verbosity_assertions = 2
79
+
80
+
81
+ [tool.refurb]
82
+ enable_all = true
83
+
84
+
85
+ [tool.ruff]
86
+ line-length = 140
87
+ exclude = [
88
+ ".git",
89
+ "__pycache__",
90
+ ".mypy_cache",
91
+ ".ruff_cache",
92
+ ".vscode",
93
+ ".venv",
94
+ "build",
95
+ "dist",
96
+ ]
97
+ extend-exclude = ["*.md"]
98
+
99
+ [tool.ruff.format]
100
+ quote-style = "single"
101
+ indent-style = "space"
102
+ skip-magic-trailing-comma = false
103
+ line-ending = "auto"
104
+ docstring-code-format = true
105
+
106
+ [tool.ruff.lint]
107
+ select = [
108
+ "E", # pycodestyle errors
109
+ "F", # pyflakes
110
+ "UP", # pyupgrade
111
+ "B", # flake8-bugbear
112
+ "SIM", # flake8-simplify
113
+ "I", # isort
114
+ "ICN", # flake8-import-conventions
115
+ # flake8-bandit by rule, never as the family. Most of what the family
116
+ # reports is a subprocess call made on purpose, and silencing that takes an
117
+ # ignore list that grows with every project.
118
+ "S102", # exec
119
+ "S113", # an HTTP request with no timeout, which can hang forever
120
+ "S301", # pickle, which runs code from the bytes it loads
121
+ "S307", # eval
122
+ "S324", # md5 or sha1 used for security
123
+ "S501", # TLS certificate verification turned off
124
+ ]
125
+ ignore = [
126
+ "SIM108", # ternary operator — often less readable
127
+ ]
128
+
129
+ [tool.ruff.lint.isort]
130
+ force-single-line = true
131
+
132
+ [tool.ruff.lint.per-file-ignores]
133
+ "__init__.py" = ["F401"]
134
+
135
+ [tool.ruff.lint.flake8-import-conventions]
136
+ banned-from = ["datetime", "pyspark.sql.functions", "pyspark.sql.types"]
137
+
138
+ [tool.ruff.lint.flake8-import-conventions.extend-aliases]
139
+ datetime = "dt"
140
+ sqlalchemy = "sa"
141
+ "pyspark.sql.functions" = "sf"
142
+ "pyspark.sql.types" = "st"
143
+ dataclasses = "dc"
144
+
145
+ [tool.semantic_release]
146
+ # A feature release on 0.x stays on 0.x. python-semantic-release v10 defaults
147
+ # this to false, which makes the next release of a 0.x project 1.0.0.
148
+ allow_zero_version = true
149
+ # 1.0 is a compatibility promise, so a breaking change on 0.x is a minor bump.
150
+ # Removing this key is how the project reaches 1.0.
151
+ major_on_zero = false
152
+
153
+
154
+ # ---------- Build System ---------- #
155
+
156
+
157
+ [tool.pyright]
158
+ typeCheckingMode = "standard"
159
+ analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
160
+
161
+ [tool.hatch.version]
162
+ source = "uv-dynamic-versioning"
163
+
164
+ [tool.uv-dynamic-versioning]
165
+ style = "semver"
166
+
167
+ # A new commit or tag changes the version, so it has to invalidate uv's cache.
168
+ [tool.uv]
169
+ cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
170
+
171
+ # The published sdist holds the package and its metadata, and none of the
172
+ # repo's tooling or tests. hatchling's default takes the whole tree.
173
+ [tool.hatch.build.targets.sdist]
174
+ only-include = ["src"]
175
+
176
+ [tool.forge]
177
+ # Keys the shared toolchain standard owns, written by its generator.
178
+ # Dropping one from the template removes it here on the next sync; a key absent
179
+ # from this list belongs to the project and is never touched. Do not hand-edit.
180
+ managed = [
181
+ ["ruff", "line-length"],
182
+ ["ruff", "extend-exclude"],
183
+ ["ruff", "format", "quote-style"],
184
+ ["ruff", "format", "indent-style"],
185
+ ["ruff", "format", "skip-magic-trailing-comma"],
186
+ ["ruff", "format", "line-ending"],
187
+ ["ruff", "format", "docstring-code-format"],
188
+ ["ruff", "lint", "select"],
189
+ ["ruff", "lint", "ignore"],
190
+ ["ruff", "lint", "isort", "force-single-line"],
191
+ ["ruff", "lint", "flake8-import-conventions", "banned-from"],
192
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "datetime"],
193
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "dataclasses"],
194
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "sqlalchemy"],
195
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.functions"],
196
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.types"],
197
+ ["ruff", "lint", "per-file-ignores", "__init__.py"],
198
+ ["mypy", "pretty"],
199
+ ["mypy", "ignore_missing_imports"],
200
+ ["mypy", "check_untyped_defs"],
201
+ ["mypy", "warn_return_any"],
202
+ ["pyright", "typeCheckingMode"],
203
+ ["pyright", "analyzeUnannotatedFunctions"],
204
+ ["codespell", "check-filenames"],
205
+ ["codespell", "check-hidden"],
206
+ ["codespell", "builtin"],
207
+ ["codespell", "uri-ignore-words-list"],
208
+ ["pytest", "ini_options", "verbosity_assertions"],
209
+ ["pytest", "ini_options", "minversion"],
210
+ ["pytest", "ini_options", "testpaths"],
211
+ ]
212
+ # Dependencies held at the version the pre-commit hook runs, in every group
213
+ # and extra that names them, so a local run and CI cannot disagree.
214
+ pinned = ["ruff"]
215
+
216
+ [build-system]
217
+ requires = ["hatchling", "uv-dynamic-versioning"]
218
+ build-backend = "hatchling.build"