generated from coulomb/repo-seed
feat(local-identity): implement Stage 1 — core file store (NK-WP-0002-T01)
Deliverables: - src/local_identity/gecos.py: /etc/passwd GECOS parsing, current_username() - src/local_identity/user.py: UserRecord dataclass, ProductionIdentity, make_test_user() - Pure test-user derivation: <user>N / +testN email alias / source_user tracking - src/local_identity/store.py: file store CRUD backed by LOCAL_IDENTITY_HOME - ~/.local-identity/ mode 700, user files mode 600 - All path lookups dynamic (env-var override enables clean test isolation) - src/local_identity/cli.py: init/list/show commands; email from flag > config > prompt - pyproject.toml + uv.lock: pyyaml dep, local-identity script entry point Tests (41 passing): - test_gecos.py: 9 tests — simple/comma/empty/non-ASCII/whitespace GECOS, fallback - test_user.py: 14 tests — test-user derivation, YAML roundtrip, non-ASCII, idempotency - test_store.py: 18 tests — dir creation, permissions (700/600), CRUD, list, config, idempotency (reinit with --force produces identical users) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
26
local-identity/pyproject.toml
Normal file
26
local-identity/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "local-identity"
|
||||
version = "0.1.0"
|
||||
description = "Zero-dependency bootstrap user store for net-kingdom environments"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
local-identity = "local_identity.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/local_identity"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
2
local-identity/src/local_identity/__init__.py
Normal file
2
local-identity/src/local_identity/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# local-identity: zero-dependency bootstrap user store for net-kingdom environments.
|
||||
# See docs/LocalIdentity.md for design, scope, and security model.
|
||||
123
local-identity/src/local_identity/cli.py
Normal file
123
local-identity/src/local_identity/cli.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
local-identity CLI — entry point.
|
||||
|
||||
Commands:
|
||||
init [--force] [--email EMAIL] Derive primary user from Linux identity,
|
||||
generate test users, write store.
|
||||
list List all users in the store.
|
||||
show <username> Display a user's YAML record.
|
||||
|
||||
Environment:
|
||||
LOCAL_IDENTITY_HOME Override the store directory (default: ~/.local-identity).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .gecos import current_username, get_gecos_fullname
|
||||
from .user import UserRecord, make_test_user
|
||||
from . import store
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
if store.store_exists() and not args.force:
|
||||
print(
|
||||
f"Store already exists at {store._store_dir()}. "
|
||||
"Use --force to reinitialise.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
username = current_username()
|
||||
fullname = get_gecos_fullname(username)
|
||||
|
||||
# Email resolution: flag > existing config > interactive prompt
|
||||
config = store.read_config() if store.store_exists() else {}
|
||||
email: str = args.email or config.get("email", "")
|
||||
if not email:
|
||||
try:
|
||||
email = input(f"Email address for {username}: ").strip()
|
||||
except EOFError:
|
||||
email = ""
|
||||
if not email:
|
||||
print("Error: email address is required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
store.init_dirs()
|
||||
config["email"] = email
|
||||
store.write_config(config)
|
||||
|
||||
primary = UserRecord(username=username, fullname=fullname, email=email)
|
||||
store.write_user(primary)
|
||||
|
||||
test1 = make_test_user(primary, 1)
|
||||
test2 = make_test_user(primary, 2)
|
||||
store.write_user(test1)
|
||||
store.write_user(test2)
|
||||
|
||||
print(f"Initialised local-identity store at {store._store_dir()}")
|
||||
print(f" Primary : {primary.username} ({primary.fullname}) <{primary.email}>")
|
||||
print(f" Test 1 : {test1.username} <{test1.email}>")
|
||||
print(f" Test 2 : {test2.username} <{test2.email}>")
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> None:
|
||||
users = store.list_users()
|
||||
if not users:
|
||||
print("No users found. Run 'local-identity init' first.")
|
||||
return
|
||||
header = f"{'USERNAME':<20} {'FULLNAME':<30} {'EMAIL':<40} TYPE"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for u in users:
|
||||
utype = "test " if u.generated else "primary"
|
||||
print(f"{u.username:<20} {u.fullname:<30} {u.email:<40} {utype}")
|
||||
|
||||
|
||||
def cmd_show(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
user = store.read_user(args.username)
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(user.to_yaml(), end="")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="local-identity",
|
||||
description="Zero-dependency bootstrap user store for net-kingdom environments.",
|
||||
epilog=(
|
||||
"Store location: ~/.local-identity "
|
||||
"(override with LOCAL_IDENTITY_HOME)"
|
||||
),
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_init = sub.add_parser(
|
||||
"init",
|
||||
help="Initialise store from Linux identity",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--force", action="store_true",
|
||||
help="Reinitialise even if the store already exists",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--email",
|
||||
help="Email address (skips interactive prompt)",
|
||||
)
|
||||
p_init.set_defaults(func=cmd_init)
|
||||
|
||||
p_list = sub.add_parser("list", help="List all users in the store")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_show = sub.add_parser("show", help="Display a user record")
|
||||
p_show.add_argument("username", help="Username to display")
|
||||
p_show.set_defaults(func=cmd_show)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
local-identity/src/local_identity/gecos.py
Normal file
34
local-identity/src/local_identity/gecos.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Parse the operator's Linux identity from /etc/passwd and the environment.
|
||||
|
||||
GECOS format: "Full Name,Room Number,Work Phone,Home Phone,Other"
|
||||
We only need the first field (full name).
|
||||
"""
|
||||
|
||||
import os
|
||||
import pwd
|
||||
|
||||
|
||||
def current_username() -> str:
|
||||
"""Return the current Linux username."""
|
||||
return (
|
||||
os.environ.get("USER")
|
||||
or os.environ.get("LOGNAME")
|
||||
or pwd.getpwuid(os.getuid()).pw_name
|
||||
)
|
||||
|
||||
|
||||
def get_gecos_fullname(username: str) -> str:
|
||||
"""
|
||||
Extract the full name from /etc/passwd GECOS for the given username.
|
||||
Falls back to the username itself if GECOS is absent or unparseable.
|
||||
"""
|
||||
try:
|
||||
entry = pwd.getpwnam(username)
|
||||
# GECOS may be "Full Name,room,work,home,other" — take the first field
|
||||
fullname = entry.pw_gecos.split(",")[0].strip()
|
||||
if fullname:
|
||||
return fullname
|
||||
except KeyError:
|
||||
pass
|
||||
return username
|
||||
101
local-identity/src/local_identity/store.py
Normal file
101
local-identity/src/local_identity/store.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
File-store operations for local-identity.
|
||||
|
||||
The store lives at ~/.local-identity/ by default. Set LOCAL_IDENTITY_HOME
|
||||
to override (useful for tests and for running multiple independent stores).
|
||||
|
||||
Directory layout:
|
||||
$LOCAL_IDENTITY_HOME/
|
||||
├── config.yaml operator email and optional overrides (mode 600)
|
||||
└── users/
|
||||
├── <user>.yaml primary user (mode 600)
|
||||
├── <user>1.yaml test user 1 (mode 600)
|
||||
└── <user>2.yaml test user 2 (mode 600)
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import yaml
|
||||
|
||||
from .user import UserRecord
|
||||
|
||||
|
||||
def _store_dir() -> Path:
|
||||
"""Return the store root directory. Overridable via LOCAL_IDENTITY_HOME."""
|
||||
return Path(os.environ.get("LOCAL_IDENTITY_HOME", str(Path.home() / ".local-identity")))
|
||||
|
||||
|
||||
def _users_dir() -> Path:
|
||||
return _store_dir() / "users"
|
||||
|
||||
|
||||
def _config_file() -> Path:
|
||||
return _store_dir() / "config.yaml"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Directory management #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def store_exists() -> bool:
|
||||
return _store_dir().exists()
|
||||
|
||||
|
||||
def init_dirs() -> None:
|
||||
"""Create the store directory tree with secure permissions."""
|
||||
store = _store_dir()
|
||||
users = _users_dir()
|
||||
store.mkdir(mode=0o700, exist_ok=True)
|
||||
users.mkdir(mode=0o700, exist_ok=True)
|
||||
# Enforce permissions even when dirs already existed
|
||||
os.chmod(store, 0o700)
|
||||
os.chmod(users, 0o700)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# User CRUD #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def write_user(user: UserRecord) -> None:
|
||||
path = _users_dir() / f"{user.username}.yaml"
|
||||
path.write_text(user.to_yaml(), encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def read_user(username: str) -> UserRecord:
|
||||
path = _users_dir() / f"{username}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"User '{username}' not found in store")
|
||||
return UserRecord.from_yaml(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def list_users() -> List[UserRecord]:
|
||||
users_dir = _users_dir()
|
||||
if not users_dir.exists():
|
||||
return []
|
||||
return [
|
||||
UserRecord.from_yaml(p.read_text(encoding="utf-8"))
|
||||
for p in sorted(users_dir.glob("*.yaml"))
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Config #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def read_config() -> dict:
|
||||
config_file = _config_file()
|
||||
if not config_file.exists():
|
||||
return {}
|
||||
return yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def write_config(config: dict) -> None:
|
||||
config_file = _config_file()
|
||||
config_file.write_text(
|
||||
yaml.dump(config, default_flow_style=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(config_file, 0o600)
|
||||
127
local-identity/src/local_identity/user.py
Normal file
127
local-identity/src/local_identity/user.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
UserRecord dataclass — in-memory representation of a local-identity user.
|
||||
|
||||
Schema fields (all stored in YAML):
|
||||
schema_version str — format version, currently "1"
|
||||
username str — Linux username (primary) or derived username (test)
|
||||
fullname str — display name
|
||||
email str — contact email
|
||||
environment str — always "local"; production connectors reject this value
|
||||
generated bool — True for auto-generated test users
|
||||
source_user str? — for generated users: the source username
|
||||
production_identity — optional mapping to a production account
|
||||
.username str
|
||||
.realm str
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductionIdentity:
|
||||
username: str
|
||||
realm: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRecord:
|
||||
username: str
|
||||
fullname: str
|
||||
email: str
|
||||
schema_version: str = "1"
|
||||
environment: str = "local"
|
||||
generated: bool = False
|
||||
source_user: Optional[str] = None
|
||||
production_identity: Optional[ProductionIdentity] = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Serialisation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d: dict = {
|
||||
"schema_version": self.schema_version,
|
||||
"username": self.username,
|
||||
"fullname": self.fullname,
|
||||
"email": self.email,
|
||||
"environment": self.environment,
|
||||
"generated": self.generated,
|
||||
}
|
||||
if self.source_user is not None:
|
||||
d["source_user"] = self.source_user
|
||||
if self.production_identity is not None:
|
||||
d["production_identity"] = {
|
||||
"username": self.production_identity.username,
|
||||
"realm": self.production_identity.realm,
|
||||
}
|
||||
return d
|
||||
|
||||
def to_yaml(self) -> str:
|
||||
return yaml.dump(
|
||||
self.to_dict(),
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> UserRecord:
|
||||
pi: Optional[ProductionIdentity] = None
|
||||
if data.get("production_identity"):
|
||||
pi = ProductionIdentity(**data["production_identity"])
|
||||
return cls(
|
||||
schema_version=data.get("schema_version", "1"),
|
||||
username=data["username"],
|
||||
fullname=data["fullname"],
|
||||
email=data["email"],
|
||||
environment=data.get("environment", "local"),
|
||||
generated=data.get("generated", False),
|
||||
source_user=data.get("source_user"),
|
||||
production_identity=pi,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, text: str) -> UserRecord:
|
||||
return cls.from_dict(yaml.safe_load(text))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test-user derivation (pure function) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def make_test_user(primary: UserRecord, n: int) -> UserRecord:
|
||||
"""
|
||||
Derive test user N from the primary user.
|
||||
|
||||
Derivation rules:
|
||||
username → <username><n>
|
||||
fullname → <fullname>+test<n>
|
||||
email → <local>+test<n>@<domain> (or <email>+test<n> if no @)
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError(f"n must be >= 1, got {n}")
|
||||
|
||||
suffix = str(n)
|
||||
|
||||
test_username = primary.username + suffix
|
||||
test_fullname = f"{primary.fullname}+test{suffix}"
|
||||
|
||||
if "@" in primary.email:
|
||||
local, domain = primary.email.rsplit("@", 1)
|
||||
test_email = f"{local}+test{suffix}@{domain}"
|
||||
else:
|
||||
test_email = f"{primary.email}+test{suffix}"
|
||||
|
||||
return UserRecord(
|
||||
username=test_username,
|
||||
fullname=test_fullname,
|
||||
email=test_email,
|
||||
environment="local",
|
||||
generated=True,
|
||||
source_user=primary.username,
|
||||
)
|
||||
19
local-identity/tests/conftest.py
Normal file
19
local-identity/tests/conftest.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Shared pytest fixtures for local-identity tests.
|
||||
|
||||
The LOCAL_IDENTITY_HOME env var redirects the store to a temp directory,
|
||||
keeping tests isolated from the real ~/.local-identity store.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_store(tmp_path, monkeypatch):
|
||||
"""
|
||||
Redirect the local-identity store to a fresh temp directory.
|
||||
Returns the Path to the store root (not yet created — call init_dirs() as needed).
|
||||
"""
|
||||
store_path = tmp_path / ".local-identity"
|
||||
monkeypatch.setenv("LOCAL_IDENTITY_HOME", str(store_path))
|
||||
return store_path
|
||||
55
local-identity/tests/test_gecos.py
Normal file
55
local-identity/tests/test_gecos.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for GECOS / passwd parsing."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.gecos import get_gecos_fullname, current_username
|
||||
|
||||
|
||||
def _mock_entry(gecos: str) -> MagicMock:
|
||||
entry = MagicMock()
|
||||
entry.pw_gecos = gecos
|
||||
return entry
|
||||
|
||||
|
||||
class TestGetGecosFullname:
|
||||
def test_simple_name(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Bernd Worsch")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_name_with_comma_fields(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Bernd Worsch,Room 42,+49-555-1234")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_empty_gecos_falls_back_to_username(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("")):
|
||||
assert get_gecos_fullname("tegwick") == "tegwick"
|
||||
|
||||
def test_only_commas(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry(",,,,")):
|
||||
assert get_gecos_fullname("tegwick") == "tegwick"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry(" Bernd Worsch ,,")):
|
||||
assert get_gecos_fullname("tegwick") == "Bernd Worsch"
|
||||
|
||||
def test_non_ascii_name(self):
|
||||
with patch("pwd.getpwnam", return_value=_mock_entry("Ärger Müller,,")):
|
||||
assert get_gecos_fullname("amueller") == "Ärger Müller"
|
||||
|
||||
def test_user_not_in_passwd(self):
|
||||
with patch("pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert get_gecos_fullname("nobody") == "nobody"
|
||||
|
||||
|
||||
class TestCurrentUsername:
|
||||
def test_reads_user_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "tegwick")
|
||||
monkeypatch.delenv("LOGNAME", raising=False)
|
||||
assert current_username() == "tegwick"
|
||||
|
||||
def test_falls_back_to_logname(self, monkeypatch):
|
||||
monkeypatch.delenv("USER", raising=False)
|
||||
monkeypatch.setenv("LOGNAME", "tegwick")
|
||||
assert current_username() == "tegwick"
|
||||
145
local-identity/tests/test_store.py
Normal file
145
local-identity/tests/test_store.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for the file store (read/write users, permissions, config)."""
|
||||
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.store import (
|
||||
init_dirs,
|
||||
store_exists,
|
||||
write_user,
|
||||
read_user,
|
||||
list_users,
|
||||
read_config,
|
||||
write_config,
|
||||
_users_dir,
|
||||
_store_dir,
|
||||
)
|
||||
from local_identity.user import UserRecord, make_test_user
|
||||
|
||||
ALICE = UserRecord(username="alice", fullname="Alice Smith", email="alice@example.com")
|
||||
BOB = UserRecord(username="bob", fullname="Bob Jones", email="bob@example.com")
|
||||
|
||||
|
||||
class TestDirInit:
|
||||
def test_creates_store_dir(self, tmp_store):
|
||||
assert not tmp_store.exists()
|
||||
init_dirs()
|
||||
assert tmp_store.exists()
|
||||
|
||||
def test_store_dir_mode_700(self, tmp_store):
|
||||
init_dirs()
|
||||
mode = stat.S_IMODE(tmp_store.stat().st_mode)
|
||||
assert mode == 0o700, f"expected 0o700, got 0o{mode:03o}"
|
||||
|
||||
def test_users_dir_mode_700(self, tmp_store):
|
||||
init_dirs()
|
||||
users = tmp_store / "users"
|
||||
mode = stat.S_IMODE(users.stat().st_mode)
|
||||
assert mode == 0o700, f"expected 0o700, got 0o{mode:03o}"
|
||||
|
||||
def test_idempotent(self, tmp_store):
|
||||
init_dirs()
|
||||
init_dirs() # should not raise
|
||||
assert tmp_store.exists()
|
||||
|
||||
def test_store_exists_false_before_init(self, tmp_store):
|
||||
assert not store_exists()
|
||||
|
||||
def test_store_exists_true_after_init(self, tmp_store):
|
||||
init_dirs()
|
||||
assert store_exists()
|
||||
|
||||
|
||||
class TestWriteReadUser:
|
||||
def test_roundtrip(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
loaded = read_user("alice")
|
||||
assert loaded.username == "alice"
|
||||
assert loaded.fullname == "Alice Smith"
|
||||
assert loaded.email == "alice@example.com"
|
||||
|
||||
def test_file_mode_600(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
path = _users_dir() / "alice.yaml"
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:03o}"
|
||||
|
||||
def test_overwrite_same_user(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
updated = UserRecord(username="alice", fullname="Alice Updated", email="alice@example.com")
|
||||
write_user(updated)
|
||||
assert read_user("alice").fullname == "Alice Updated"
|
||||
|
||||
def test_read_nonexistent_raises(self, tmp_store):
|
||||
init_dirs()
|
||||
with pytest.raises(FileNotFoundError, match="nobody"):
|
||||
read_user("nobody")
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
def test_empty_store(self, tmp_store):
|
||||
init_dirs()
|
||||
assert list_users() == []
|
||||
|
||||
def test_lists_all_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(BOB)
|
||||
usernames = {u.username for u in list_users()}
|
||||
assert usernames == {"alice", "bob"}
|
||||
|
||||
def test_includes_test_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
usernames = {u.username for u in list_users()}
|
||||
assert usernames == {"alice", "alice1", "alice2"}
|
||||
|
||||
def test_sorted_by_filename(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(BOB)
|
||||
write_user(ALICE)
|
||||
names = [u.username for u in list_users()]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_read_config_empty(self, tmp_store):
|
||||
init_dirs()
|
||||
assert read_config() == {}
|
||||
|
||||
def test_write_read_config(self, tmp_store):
|
||||
init_dirs()
|
||||
write_config({"email": "tegwick@example.com"})
|
||||
assert read_config()["email"] == "tegwick@example.com"
|
||||
|
||||
def test_config_mode_600(self, tmp_store):
|
||||
init_dirs()
|
||||
write_config({"email": "x@y.com"})
|
||||
from local_identity.store import _config_file
|
||||
mode = stat.S_IMODE(_config_file().stat().st_mode)
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
class TestIdempotency:
|
||||
def test_reinit_produces_same_users(self, tmp_store):
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
|
||||
# Simulate --force re-init with same inputs
|
||||
init_dirs()
|
||||
write_user(ALICE)
|
||||
write_user(make_test_user(ALICE, 1))
|
||||
write_user(make_test_user(ALICE, 2))
|
||||
|
||||
users = {u.username: u for u in list_users()}
|
||||
assert set(users.keys()) == {"alice", "alice1", "alice2"}
|
||||
assert users["alice1"].source_user == "alice"
|
||||
assert users["alice1"].email == "alice+test1@example.com"
|
||||
91
local-identity/tests/test_user.py
Normal file
91
local-identity/tests/test_user.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for UserRecord and make_test_user."""
|
||||
|
||||
import pytest
|
||||
|
||||
from local_identity.user import UserRecord, ProductionIdentity, make_test_user
|
||||
|
||||
PRIMARY = UserRecord(
|
||||
username="tegwick",
|
||||
fullname="Bernd Worsch",
|
||||
email="bernd.worsch@gmail.com",
|
||||
)
|
||||
|
||||
|
||||
class TestMakeTestUser:
|
||||
def test_username_suffix(self):
|
||||
assert make_test_user(PRIMARY, 1).username == "tegwick1"
|
||||
assert make_test_user(PRIMARY, 2).username == "tegwick2"
|
||||
|
||||
def test_fullname_suffix(self):
|
||||
assert make_test_user(PRIMARY, 1).fullname == "Bernd Worsch+test1"
|
||||
assert make_test_user(PRIMARY, 2).fullname == "Bernd Worsch+test2"
|
||||
|
||||
def test_email_plus_alias(self):
|
||||
assert make_test_user(PRIMARY, 1).email == "bernd.worsch+test1@gmail.com"
|
||||
assert make_test_user(PRIMARY, 2).email == "bernd.worsch+test2@gmail.com"
|
||||
|
||||
def test_generated_flag(self):
|
||||
assert make_test_user(PRIMARY, 1).generated is True
|
||||
|
||||
def test_source_user(self):
|
||||
assert make_test_user(PRIMARY, 1).source_user == "tegwick"
|
||||
|
||||
def test_environment_is_local(self):
|
||||
assert make_test_user(PRIMARY, 1).environment == "local"
|
||||
|
||||
def test_email_without_at(self):
|
||||
p = UserRecord(username="foo", fullname="Foo Bar", email="noemail")
|
||||
assert make_test_user(p, 1).email == "noemail+test1"
|
||||
|
||||
def test_invalid_n_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
make_test_user(PRIMARY, 0)
|
||||
|
||||
def test_deterministic(self):
|
||||
a = make_test_user(PRIMARY, 1)
|
||||
b = make_test_user(PRIMARY, 1)
|
||||
assert a.to_dict() == b.to_dict()
|
||||
|
||||
|
||||
class TestUserRecordYamlRoundtrip:
|
||||
def test_basic_roundtrip(self):
|
||||
text = PRIMARY.to_yaml()
|
||||
loaded = UserRecord.from_yaml(text)
|
||||
assert loaded.username == PRIMARY.username
|
||||
assert loaded.fullname == PRIMARY.fullname
|
||||
assert loaded.email == PRIMARY.email
|
||||
assert loaded.environment == PRIMARY.environment
|
||||
assert loaded.generated == PRIMARY.generated
|
||||
assert loaded.source_user is None
|
||||
assert loaded.production_identity is None
|
||||
|
||||
def test_roundtrip_with_production_identity(self):
|
||||
u = UserRecord(
|
||||
username="tegwick",
|
||||
fullname="Bernd Worsch",
|
||||
email="bernd.worsch@gmail.com",
|
||||
production_identity=ProductionIdentity(
|
||||
username="tegwick", realm="net-kingdom"
|
||||
),
|
||||
)
|
||||
loaded = UserRecord.from_yaml(u.to_yaml())
|
||||
assert loaded.production_identity is not None
|
||||
assert loaded.production_identity.username == "tegwick"
|
||||
assert loaded.production_identity.realm == "net-kingdom"
|
||||
|
||||
def test_roundtrip_test_user(self):
|
||||
t = make_test_user(PRIMARY, 1)
|
||||
loaded = UserRecord.from_yaml(t.to_yaml())
|
||||
assert loaded.generated is True
|
||||
assert loaded.source_user == "tegwick"
|
||||
|
||||
def test_yaml_contains_expected_fields(self):
|
||||
text = PRIMARY.to_yaml()
|
||||
assert "username: tegwick" in text
|
||||
assert "environment: local" in text
|
||||
assert "generated: false" in text
|
||||
|
||||
def test_non_ascii_roundtrip(self):
|
||||
u = UserRecord(username="amueller", fullname="Ärger Müller", email="a@b.de")
|
||||
loaded = UserRecord.from_yaml(u.to_yaml())
|
||||
assert loaded.fullname == "Ärger Müller"
|
||||
137
local-identity/uv.lock
generated
Normal file
137
local-identity/uv.lock
generated
Normal file
@@ -0,0 +1,137 @@
|
||||
version = 1
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "local-identity"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
|
||||
]
|
||||
Reference in New Issue
Block a user