Skip to content

Commit f322954

Browse files
committed
Fully annotate the package and enforce it
Annotate every function, parameter and module-level variable in `keycmd`, and ship a `py.typed` marker so downstream consumers get the types too. Typing the config surface required describing its shape, so the [keys] and [aliases] tables are now `TypedDict`s. `load_conf` casts to that shape at the boundary where user authored TOML enters the program, which is where the promise is actually made. Enforcement, so this does not decay: - ruff's `ANN` rules require annotations (the test suite is exempt). - ty's off-by-default rules are enabled: `missing-type-argument`, `possibly-missing-attribute`, `possibly-missing-import`, `possibly-unresolved-reference` and `division-by-zero`. Suppressions must name a rule and must be needed (`blanket-ignore-comment`, `unused-ignore-comment`). - CI and pre-commit run `ty check --error-on-warning`, so warn-level diagnostics fail rather than scroll by. Two fixes fell out of making the types honest: - `shell.exec` passed `env=None` straight to `os.execvpe`, which requires a mapping and would have raised `TypeError`. It now falls back to `os.environ`, matching what the subprocess branch already did. - `creds.get_env` bound both the loop variable and the looked up key data to the name `key`, so the two had different types under one name. They are now `key`/`data` and `src`/`alias_src`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nTpsbs48Pcky9GVthXnWv
1 parent 1f5c509 commit f322954

10 files changed

Lines changed: 118 additions & 47 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
- name: Install only ty
4848
run: uv sync --only-group ty
4949
- name: Typecheck
50-
run: uv run --no-sync ty check
50+
run: uv run --no-sync ty check --output-format=github --error-on-warning
5151

5252
test:
5353
name: Test on ${{ matrix.name }}

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ repos:
1515
require_serial: true
1616
- id: ty
1717
name: Typecheck
18-
entry: uv run ty check
18+
entry: uv run ty check --error-on-warning
1919
language: system
2020
types: [python]
2121
pass_filenames: false

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,8 @@ See the [third party backends](https://github.com/jaraco/keyring/#third-party-ba
401401

402402
This project uses [uv](https://docs.astral.sh/uv/) for dependency management, [ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [ty](https://docs.astral.sh/ty/) for type checking.
403403

404+
The `keycmd` package is fully annotated and ships a `py.typed` marker, so the types are available to anything that imports it. Ruff's `ANN` rules keep it that way; the test suite is exempt.
405+
404406
```bash
405407
# create the virtual environment and install all dependencies
406408
uv sync

keycmd/cli.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import argparse
22
import tomllib
3+
from collections.abc import Sequence
34

45
from . import __version__
56
from .conf import load_conf
67
from .creds import get_env
78
from .logs import error, log, set_verbose
89
from .shell import run_cmd, run_shell
910

10-
cli = argparse.ArgumentParser(
11+
cli: argparse.ArgumentParser = argparse.ArgumentParser(
1112
prog="keycmd",
1213
)
1314
cli.add_argument(
@@ -29,14 +30,14 @@
2930
cli.add_argument("command", nargs=argparse.REMAINDER, help="command to run")
3031

3132

32-
def main(args=None):
33+
def main(args: Sequence[str] | None = None) -> None:
3334
"""CLI entrypoint"""
34-
args = cli.parse_args(args=args)
35+
parsed = cli.parse_args(args=args)
3536

36-
if args.verbose:
37+
if parsed.verbose:
3738
set_verbose()
3839

39-
if args.version:
40+
if parsed.version:
4041
log(f"v{__version__}")
4142
return
4243

@@ -46,9 +47,9 @@ def main(args=None):
4647
error(err)
4748
env = get_env(conf)
4849

49-
if args.shell:
50+
if parsed.shell:
5051
run_shell(env=env)
51-
elif args.command:
52-
run_cmd(args.command, env=env)
52+
elif parsed.command:
53+
run_cmd(parsed.command, env=env)
5354
else:
5455
error("missing command argument")

keycmd/conf.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,40 @@
11
import tomllib
22
from pathlib import Path
33
from pprint import pformat
4+
from typing import Any, Literal, NotRequired, TypedDict, cast, overload
45

56
from .logs import vlog
67

8+
9+
class KeyConf(TypedDict):
10+
"""A single entry of the [keys] table"""
11+
12+
credential: str
13+
username: str
14+
b64: NotRequired[bool]
15+
format: NotRequired[str]
16+
17+
18+
class AliasConf(TypedDict):
19+
"""A single entry of the [aliases] table"""
20+
21+
key: str
22+
b64: NotRequired[bool]
23+
format: NotRequired[str]
24+
25+
26+
class Conf(TypedDict):
27+
"""The merged keycmd configuration"""
28+
29+
keys: dict[str, KeyConf]
30+
aliases: NotRequired[dict[str, AliasConf]]
31+
32+
733
# exposed for testing
8-
USERPROFILE = "~"
34+
USERPROFILE: str | Path = "~"
935

1036

11-
def load_toml(path):
37+
def load_toml(path: Path) -> dict[str, Any]:
1238
"""Load a toml file"""
1339
with path.open("rb") as fh:
1440
try:
@@ -17,17 +43,25 @@ def load_toml(path):
1743
raise tomllib.TOMLDecodeError(f"invalid TOML in {path}:\n{err}") from err
1844

1945

20-
def load_pyproj(path):
46+
def load_pyproj(path: Path) -> dict[str, Any]:
2147
"""Load [tool.keycmd] from a pyproject.toml file"""
2248
data = load_toml(path)
2349
return data.get("tool", {}).get("keycmd", {})
2450

2551

26-
def find_file(fname, first_only=True):
52+
@overload
53+
def find_file(fname: str, first_only: Literal[True] = True) -> Path | None: ...
54+
55+
56+
@overload
57+
def find_file(fname: str, first_only: Literal[False]) -> list[Path]: ...
58+
59+
60+
def find_file(fname: str, first_only: bool = True) -> Path | list[Path] | None:
2761
"""Find a file by walking up the filesystem, starting at cwd"""
2862
cur = Path.cwd()
2963
home = Path.home()
30-
results = []
64+
results: list[Path] = []
3165
while True:
3266
candidate = cur / fname
3367
if candidate.is_file():
@@ -51,14 +85,15 @@ def find_file(fname, first_only=True):
5185
# be loaded and merged
5286
results.reverse()
5387
return results
88+
return None
5489

5590

56-
def defaults():
91+
def defaults() -> dict[str, Any]:
5792
"""Generate the default config"""
5893
return {"keys": {}}
5994

6095

61-
def merge_conf(a, b):
96+
def merge_conf(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
6297
"""
6398
Merges two deep dictionary structures.
6499
All other datatypes are simply overwritten
@@ -73,7 +108,7 @@ def merge_conf(a, b):
73108
return a
74109

75110

76-
def load_conf():
111+
def load_conf() -> Conf:
77112
"""
78113
Load merged configuration from the following files:
79114
- defaults()
@@ -106,4 +141,6 @@ def load_conf():
106141

107142
vlog(f"merged config:\n{pformat(conf)}")
108143

109-
return conf
144+
# the config is user authored, so this is a statement of the shape keycmd
145+
# expects rather than a guarantee; get_env reports violations as user errors
146+
return cast(Conf, conf)

keycmd/creds.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,27 @@
33

44
import keyring
55

6+
from .conf import Conf
67
from .logs import error, vlog
78

9+
# credential, username, password, apply_b64, format string
10+
KeyData = tuple[str, str, str, bool, str | None]
811

9-
def b64(value):
12+
13+
def b64(value: str) -> str:
1014
"""Convert a string to its base64 representation"""
1115
return base64.b64encode(value.encode("utf-8")).decode("utf-8")
1216

1317

14-
def expose(env, key, credential, username, password, apply_b64, format_string):
18+
def expose(
19+
env: dict[str, str],
20+
key: str,
21+
credential: str,
22+
username: str,
23+
password: str,
24+
apply_b64: bool,
25+
format_string: str | None,
26+
) -> None:
1527
if format_string:
1628
password = format_string.format(
1729
credential=credential,
@@ -23,11 +35,11 @@ def expose(env, key, credential, username, password, apply_b64, format_string):
2335
env[key] = password
2436

2537

26-
def get_env(conf):
38+
def get_env(conf: Conf) -> dict[str, str]:
2739
"""Load credentials from the OS keyring according to user configuration"""
2840
env = environ.copy()
2941

30-
key_data = {}
42+
key_data: dict[str, KeyData] = {}
3143
for key, src in conf["keys"].items():
3244
password = keyring.get_password(src["credential"], src["username"])
3345
if password is None:
@@ -53,17 +65,17 @@ def get_env(conf):
5365
f" (b64: {apply_b64}, format: {format_string})"
5466
)
5567

56-
for alias, src in conf.get("aliases", {}).items():
57-
key = key_data.get(src["key"])
58-
if key is None:
59-
error(f"MISSING alias key {src['key']}")
68+
for alias, alias_src in conf.get("aliases", {}).items():
69+
data = key_data.get(alias_src["key"])
70+
if data is None:
71+
error(f"MISSING alias key {alias_src['key']}")
6072
# re-use base data but replace apply_b64 and format_string
61-
credential, username, password, _, _ = key
62-
apply_b64 = src.get("b64", False)
63-
format_string = src.get("format")
73+
credential, username, password, _, _ = data
74+
apply_b64 = alias_src.get("b64", False)
75+
format_string = alias_src.get("format")
6476
expose(env, alias, credential, username, password, apply_b64, format_string)
6577
vlog(
66-
f"aliasing {src['key']}"
78+
f"aliasing {alias_src['key']}"
6779
f" as environment variable {alias}"
6880
f" (b64: {apply_b64}, format: {format_string})"
6981
)

keycmd/logs.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
import sys
22
from typing import NoReturn
33

4-
_verbose = False
4+
_verbose: bool = False
55

66

7-
def set_verbose(verbose=True):
7+
def set_verbose(verbose: bool = True) -> None:
88
global _verbose
99
_verbose = verbose
1010

1111

12-
def log(msg, err=False):
13-
msg = f"keycmd: {msg}"
12+
def log(msg: object, err: bool = False) -> None:
13+
line = f"keycmd: {msg}"
1414
if err:
15-
print(msg, file=sys.stderr)
15+
print(line, file=sys.stderr)
1616
else:
17-
print(msg)
17+
print(line)
1818

1919

20-
def vlog(msg):
20+
def vlog(msg: object) -> None:
2121
if _verbose:
2222
print(f"keycmd: {msg}")
2323

2424

25-
def error(msg) -> NoReturn:
25+
def error(msg: object) -> NoReturn:
2626
log(f"error: {msg}", err=True)
2727
sys.exit(1)
2828

2929

30-
def vwarn(msg):
30+
def vwarn(msg: object) -> None:
3131
vlog(f"warning: {msg}")

keycmd/py.typed

Whitespace-only changes.

keycmd/shell.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import os
2+
from collections.abc import Mapping, Sequence
23
from pathlib import Path
34
from pprint import pformat
45
from subprocess import run
56
from sys import exit
7+
from typing import NoReturn
68

79
from shellingham import ShellDetectionFailure, detect_shell
810

911
from .logs import vlog, vwarn
1012

11-
USE_SUBPROCESS = False # exposed for testing
12-
IS_WINDOWS = os.name == "nt"
13-
IS_POSIX = os.name == "posix"
13+
USE_SUBPROCESS: bool = False # exposed for testing
14+
IS_WINDOWS: bool = os.name == "nt"
15+
IS_POSIX: bool = os.name == "posix"
1416

1517

16-
def exec(args, env):
18+
def exec(args: list[str], env: Mapping[str, str] | None = None) -> NoReturn:
19+
if env is None:
20+
env = os.environ
1721
if USE_SUBPROCESS or IS_WINDOWS:
1822
# windows does not support process replacement
1923
# as well as posix systems do
@@ -25,7 +29,7 @@ def exec(args, env):
2529
os.execvpe(args[0], args, env)
2630

2731

28-
def get_shell():
32+
def get_shell() -> tuple[str, str]:
2933
"""Use shellingham to detect the shell that invoked
3034
this Python process"""
3135
try:
@@ -43,15 +47,15 @@ def get_shell():
4347
return shell_name, shell_path
4448

4549

46-
def run_shell(env=None):
50+
def run_shell(env: Mapping[str, str] | None = None) -> NoReturn:
4751
"""Open an interactive shell for the user to interact
4852
with."""
4953
shell_name, shell_path = get_shell()
5054
vlog(f"spawning subshell: {shell_name}")
5155
exec([shell_path], env)
5256

5357

54-
def run_cmd(cmd, env=None):
58+
def run_cmd(cmd: Sequence[str], env: Mapping[str, str] | None = None) -> NoReturn:
5559
"""Run a one-off command in a shell."""
5660
shell_name, shell_path = get_shell()
5761
if shell_name == "cmd":

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ readme = "README.md"
88
license = "MIT"
99
license-files = ["LICENSE"]
1010
keywords = ["keyring", "secrets", "credentials", "environment"]
11+
classifiers = ["Typing :: Typed"]
1112
dependencies = [
1213
"keyring>=25.6",
1314
"shellingham>=1.5.4",
@@ -50,6 +51,7 @@ select = [
5051
"I", # isort imports
5152
"N", # pep8-naming
5253
"B", # flake8-bugbear
54+
"ANN", # flake8-annotations
5355
"T10", # flake8-debugger
5456
"T20", # flake8-print
5557
"RUF", # ruff
@@ -58,10 +60,23 @@ select = [
5860
[tool.ruff.lint.per-file-ignores]
5961
# command line tool that reports through print
6062
"keycmd/logs.py" = ["T201"]
63+
# the package is fully annotated, the test suite is not
64+
"tests/*" = ["ANN"]
6165

6266
[tool.ty.src]
6367
include = ["keycmd"]
6468

69+
[tool.ty.rules]
70+
# rules that are off by default, enabled because the package is fully typed
71+
missing-type-argument = "error"
72+
possibly-missing-attribute = "error"
73+
possibly-missing-import = "error"
74+
possibly-unresolved-reference = "error"
75+
division-by-zero = "error"
76+
# suppressions have to name the rule they suppress, and have to be needed
77+
blanket-ignore-comment = "error"
78+
unused-ignore-comment = "error"
79+
6580
[tool.ty.environment]
6681
# Match the oldest supported Python version (requires-python), so
6782
# that the type checker catches use of newer typing features

0 commit comments

Comments
 (0)