feat: new configurations for APK/Java/Python/Yum

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-09-02 06:58:20 +02:00
parent 48ec887808
commit 586f091eeb
22 changed files with 1804 additions and 74 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ repos:
rev: 1.8.6
hooks:
- id: bandit
args: [-s, 'B404,B405,B506,B703,B308,B603']
args: [-s, 'B101,B404,B405,B506,B703,B308,B603']
exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*)
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
+25
View File
@@ -107,6 +107,17 @@ class AptConfig(BaseModel):
keys: list[NamedContent] = Field(default_factory=list)
class ApkRepository(BaseModel):
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
url: str
key: str | None = None
class ApkConfig(BaseModel):
repositories: list[ApkRepository] = Field(default_factory=list)
class YumRepository(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
@@ -116,6 +127,7 @@ class YumRepository(BaseModel):
class YumConfig(BaseModel):
repositories: list[YumRepository] = Field(default_factory=list)
keys: list[NamedContent] = Field(default_factory=list)
class PythonRepository(BaseModel):
@@ -129,11 +141,24 @@ class PythonConfig(BaseModel):
repositories: list[PythonRepository] = Field(default_factory=list)
class JavaRepository(BaseModel):
"""A Maven-compatible repository, shared by Maven, Gradle, Ivy, SBT and Leiningen."""
name: str
url: str
class JavaConfig(BaseModel):
repositories: list[JavaRepository] = Field(default_factory=list)
class ConfigurationsBlock(BaseModel):
certificates: list[NamedContent] = Field(default_factory=list)
apt: AptConfig = Field(default_factory=AptConfig)
apk: ApkConfig = Field(default_factory=ApkConfig)
yum: YumConfig = Field(default_factory=YumConfig)
python: PythonConfig = Field(default_factory=PythonConfig)
java: JavaConfig = Field(default_factory=JavaConfig)
class Configuration(BaseModel):
+6
View File
@@ -54,3 +54,9 @@ class Context:
if path is None or not path.is_dir():
return []
return sorted(entry for entry in path.iterdir() if entry.is_file())
def glob_src(self, pattern: str) -> list[Path]:
"""Glob a pattern relative to the source filesystem, or [] if no source was given."""
if self.src is None:
return []
return sorted(self.src.glob(pattern))
+2 -2
View File
@@ -3,6 +3,6 @@
Importing this package registers every built-in module (see `base.register`).
"""
from id_configurator.modules import apt, certificates
from id_configurator.modules import apk, apt, certificates, java, python, yum
__all__ = ["apt", "certificates"]
__all__ = ["apk", "apt", "certificates", "java", "python", "yum"]
+81
View File
@@ -0,0 +1,81 @@
"""Detect existing APK (Alpine) repositories and configure new ones.
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
is copied into `<dst>` with every active line disabled (commented out), and
the repositories declared in the configuration are appended as new, active
lines. Public keys provided alongside a repository are written to
`<dst>/etc/apk/keys/`, named after their content so that repositories
sharing the same key (a common case for official Alpine mirrors) only
produce a single key file.
"""
from __future__ import annotations
import base64
import hashlib
import logging
from pathlib import Path
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_APK_DIR = "etc/apk"
_REPOSITORIES = "etc/apk/repositories"
_KEYS_DIR = "etc/apk/keys"
def _disable_repositories_content(content: str) -> str:
lines = [
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
for line in content.splitlines()
]
return "\n".join(lines) + "\n"
def _key_filename(content: bytes) -> str:
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
@register
class ApkModule(Module):
name = "apk"
def is_relevant(self, context: Context) -> bool:
return self._src_apk_dir(context) is not None
@staticmethod
def _src_apk_dir(context: Context) -> Path | None:
path = context.src_path(_APK_DIR)
if path is not None and path.is_dir():
return path
return None
def apply(self, context: Context) -> None:
disabled = self._disabled_existing_repositories(context)
configured = self._configured_repositories_lines(context)
content = disabled + ("\n".join(configured) + "\n" if configured else "")
if content:
context.write_file(_REPOSITORIES, content.encode("utf-8"))
@staticmethod
def _disabled_existing_repositories(context: Context) -> str:
existing = context.read_src_file(_REPOSITORIES)
if existing is None:
return ""
logger.info("disabled existing apk repositories %s", _REPOSITORIES)
return _disable_repositories_content(existing.decode("utf-8"))
@staticmethod
def _configured_repositories_lines(context: Context) -> list[str]:
lines = []
for repository in context.config.configurations.apk.repositories:
lines.append(repository.url)
logger.info("added apk repository %s", repository.url)
if repository.key:
content = base64.b64decode(repository.key)
filename = _key_filename(content)
context.write_file(f"{_KEYS_DIR}/{filename}", content)
logger.info("installed apk key %s", filename)
return lines
+300
View File
@@ -0,0 +1,300 @@
"""Add the configured root certificates to every Java trust store detected
under `<src>`, without shelling out to `keytool`, and configure the
Maven-compatible repositories declared in `configurations.java.repositories`
globally for the main JVM build tools.
Java ignores the system CA store and only trusts what is in each JRE/JDK's
own `cacerts` file: JKS format before Java 9, PKCS12 since Java 9. Every
`cacerts` file found under `usr/` or `opt/` in `<src>` is copied into `<dst>`
with the configured root certificates added, entirely in Python: `pyjks`
handles the legacy JKS format, `cryptography` handles PKCS12. The default
`changeit` store password is used, since virtually every JDK ships with it
unchanged; a keystore that doesn't open with it is left untouched and a
warning is logged instead of failing the whole run.
The repositories are written to the well-known global configuration file of
each build tool. Maven's is installation-wide, so every user on the image
picks it up; the others are written under root's home directory, since this
tool targets Docker images, which are built and run as root:
- Maven3: `/etc/maven/settings.xml` (the file `/usr/share/maven/conf/settings.xml`
points to on Debian/Ubuntu, Maven's global, all-users settings file), plus
`conf/settings.xml` in every other Maven installation detected under
`<src>` (found via its `bin/mvn` launcher). Both get an always-active
profile adding every repository, plus a `mirrorOf=*` mirror on the first
one so that even `pom.xml` files hardcoding central are redirected.
- Gradle: `~/.gradle/init.d/id-configurator-repositories.gradle`, an init
script adding the repositories to every project and to plugin management.
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
own bootstrapping and, when combined with the `sbt.override.build.repos`
system property, for every project's resolvers as well.
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
profile.
"""
from __future__ import annotations
import base64
import json
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
import jks
from cryptography import x509
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding,
pkcs12,
)
from id_configurator.config import JavaRepository, NamedContent
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_JAVA_SEARCH_ROOTS = ("usr", "opt")
_STORE_PASSWORD = b"changeit"
_MAVEN_GLOBAL_SETTINGS = "etc/maven/settings.xml"
_MAVEN_BIN_GLOBS = ("usr/**/bin/mvn", "opt/**/bin/mvn")
_GRADLE_INIT_SCRIPT = "root/.gradle/init.d/id-configurator-repositories.gradle"
_IVY_SETTINGS = "root/.ivy2/ivysettings.xml"
_SBT_REPOSITORIES = "root/.sbt/repositories"
_LEIN_PROFILES = "root/.lein/profiles.clj"
def _find_cacerts(context: Context) -> list[Path]:
found = {
path
for root in _JAVA_SEARCH_ROOTS
for path in context.glob_src(f"{root}/**/cacerts")
}
return sorted(path for path in found if path.is_file())
def _decode_certificates(certificates: list[NamedContent]) -> list[x509.Certificate]:
return [
x509.load_pem_x509_certificate(base64.b64decode(cert.content))
for cert in certificates
]
def _add_to_pkcs12(
content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate]
) -> bytes:
loaded = pkcs12.load_pkcs12(content, _STORE_PASSWORD)
entries = list(loaded.additional_certs)
for named, cert in zip(certificates, certs, strict=True):
entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8")))
return pkcs12.serialize_java_truststore(
entries, BestAvailableEncryption(_STORE_PASSWORD)
)
def _add_to_jks(
content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate]
) -> bytes:
store = jks.KeyStore.loads(content, _STORE_PASSWORD.decode("ascii"))
for named, cert in zip(certificates, certs, strict=True):
store.entries[named.name] = jks.TrustedCertEntry.new(
named.name, cert.public_bytes(Encoding.DER)
)
return store.saves(_STORE_PASSWORD.decode("ascii"))
def _keystore_kind(content: bytes) -> str:
if content[:4] in (jks.MAGIC_NUMBER_JKS, jks.MAGIC_NUMBER_JCEKS):
return "jks"
return "pkcs12"
def _string_literal(value: str) -> str:
return json.dumps(value)
def _render_xml(root: ET.Element) -> bytes:
ET.indent(root)
content = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
root, encoding="unicode"
)
return (content + "\n").encode("utf-8")
def _write_xml(context: Context, relative: str, root: ET.Element) -> None:
context.write_file(relative, _render_xml(root))
def _find_maven_homes(context: Context) -> list[Path]:
found = {
path.parent.parent
for pattern in _MAVEN_BIN_GLOBS
for path in context.glob_src(pattern)
if path.is_file()
}
return sorted(found)
def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element:
settings = ET.Element(
"settings",
{
"xmlns": "http://maven.apache.org/SETTINGS/1.1.0",
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xsi:schemaLocation": (
"http://maven.apache.org/SETTINGS/1.1.0 "
"http://maven.apache.org/xsd/settings-1.1.0.xsd"
),
},
)
profile = ET.SubElement(ET.SubElement(settings, "profiles"), "profile")
ET.SubElement(profile, "id").text = "id-configurator"
activation = ET.SubElement(profile, "activation")
ET.SubElement(activation, "activeByDefault").text = "true"
repositories_el = ET.SubElement(profile, "repositories")
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
for repository in repositories:
for parent, tag in (
(repositories_el, "repository"),
(plugin_repositories_el, "pluginRepository"),
):
entry = ET.SubElement(parent, tag)
ET.SubElement(entry, "id").text = repository.name
ET.SubElement(entry, "url").text = repository.url
active_profiles = ET.SubElement(settings, "activeProfiles")
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror")
ET.SubElement(mirror, "id").text = repositories[0].name
ET.SubElement(mirror, "url").text = repositories[0].url
ET.SubElement(mirror, "mirrorOf").text = "*"
return settings
def _write_maven_settings(context: Context, repositories: list[JavaRepository]) -> None:
content = _render_xml(_build_maven_settings(repositories))
relatives = {_MAVEN_GLOBAL_SETTINGS}
relatives.update(
f"{home.relative_to(context.src).as_posix()}/conf/settings.xml"
for home in _find_maven_homes(context)
)
for relative in sorted(relatives):
context.write_file(relative, content)
logger.info(
"configured maven mirrors for all users in %d location(s)", len(relatives)
)
def _gradle_repository_lines(repositories: list[JavaRepository], indent: str) -> str:
return "\n".join(
f"{indent}maven {{ url = uri({_string_literal(repository.url)}) }}"
for repository in repositories
)
def _write_gradle_init_script(
context: Context, repositories: list[JavaRepository]
) -> None:
content = (
"allprojects {\n"
" repositories {\n"
f"{_gradle_repository_lines(repositories, ' ')}\n"
" }\n"
"}\n"
"\n"
"settingsEvaluated { settings ->\n"
" settings.pluginManagement {\n"
" repositories {\n"
f"{_gradle_repository_lines(repositories, ' ')}\n"
" }\n"
" }\n"
"}\n"
)
context.write_file(_GRADLE_INIT_SCRIPT, content.encode("utf-8"))
logger.info("configured gradle to use %d repositories", len(repositories))
def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) -> None:
ivysettings = ET.Element("ivysettings")
ET.SubElement(ivysettings, "settings", {"defaultResolver": "id-configurator"})
chain = ET.SubElement(
ET.SubElement(ivysettings, "resolvers"), "chain", {"name": "id-configurator"}
)
for repository in repositories:
ET.SubElement(
chain,
"ibiblio",
{"name": repository.name, "m2compatible": "true", "root": repository.url},
)
_write_xml(context, _IVY_SETTINGS, ivysettings)
logger.info("configured ivy to use %d repositories", len(repositories))
def _write_sbt_repositories(
context: Context, repositories: list[JavaRepository]
) -> None:
lines = ["[repositories]", " local"]
lines.extend(
f" {repository.name}: {repository.url}" for repository in repositories
)
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured sbt to use %d repositories", len(repositories))
def _write_lein_profiles(context: Context, repositories: list[JavaRepository]) -> None:
entries = " ".join(
f"[{_string_literal(repository.name)} {{:url {_string_literal(repository.url)}}}]"
for repository in repositories
)
content = f"{{:user {{:repositories [{entries}]}}}}\n"
context.write_file(_LEIN_PROFILES, content.encode("utf-8"))
logger.info("configured leiningen to use %d repositories", len(repositories))
def _configure_repositories(
context: Context, repositories: list[JavaRepository]
) -> None:
_write_maven_settings(context, repositories)
_write_gradle_init_script(context, repositories)
_write_ivy_settings(context, repositories)
_write_sbt_repositories(context, repositories)
_write_lein_profiles(context, repositories)
@register
class JavaModule(Module):
name = "java"
def is_relevant(self, context: Context) -> bool:
has_certificates = bool(context.config.configurations.certificates) and bool(
_find_cacerts(context)
)
has_repositories = bool(context.config.configurations.java.repositories)
return has_certificates or has_repositories
def apply(self, context: Context) -> None:
certificates = context.config.configurations.certificates
if certificates:
certs = _decode_certificates(certificates)
for path in _find_cacerts(context):
relative = path.relative_to(context.src).as_posix()
content = path.read_bytes()
kind = _keystore_kind(content)
try:
if kind == "jks":
updated = _add_to_jks(content, certificates, certs)
else:
updated = _add_to_pkcs12(content, certificates, certs)
context.write_file(relative, updated)
logger.info(
"added %d certificate(s) to java keystore %s",
len(certificates),
relative,
)
except (jks.KeystoreException, ValueError) as exc:
logger.warning("skipping java keystore %s: %s", relative, exc)
repositories = context.config.configurations.java.repositories
if repositories:
_configure_repositories(context, repositories)
+112
View File
@@ -0,0 +1,112 @@
"""Configure the classic Python package managers to use the configured
PythonRepository entries instead of pypi.org, and to trust the root
certificates installed by the certificates module.
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
still has to reference these repositories as sources. This only makes the
repository and its certificate available system-wide. It also assumes the
container runs as root, since poetry only reads its config from the
current user's home directory.
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
to every Python installation detected under `<src>` (there is no location
independent of a specific Python prefix).
- pipx: not configured directly. It installs packages by invoking `pip`
inside the target venv, so it already picks up `/etc/pip.conf`.
"""
from __future__ import annotations
import json
import logging
from id_configurator.config import PythonRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
_PIP_CONF = "etc/pip.conf"
_UV_TOML = "etc/uv/uv.toml"
_POETRY_CONFIG = "root/.config/pypoetry/config.toml"
_DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*")
def _toml_string(value: str) -> str:
return json.dumps(value)
def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None:
lines = ["[global]", f"index-url = {repositories[0].index_url}"]
if len(repositories) > 1:
lines.append("extra-index-url =")
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
lines.append(f"cert = {_CA_BUNDLE}")
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured pip to use %s", repositories[0].index_url)
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
lines = ["native-tls = true", ""]
for position, repository in enumerate(repositories):
lines.append("[[index]]")
lines.append(f"name = {_toml_string(repository.name)}")
lines.append(f"url = {_toml_string(repository.index_url)}")
if position == 0:
lines.append("default = true")
lines.append("")
context.write_file(_UV_TOML, ("\n".join(lines).rstrip() + "\n").encode("utf-8"))
logger.info("configured uv to use %s", repositories[0].index_url)
def _write_poetry_config(
context: Context, repositories: list[PythonRepository]
) -> None:
lines = []
for repository in repositories:
lines.append(f"[repositories.{repository.name}]")
lines.append(f"url = {_toml_string(repository.index_url)}")
lines.append("")
lines.append(f"[certificates.{repository.name}]")
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
lines.append("")
context.write_file(
_POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8")
)
logger.info("registered %d poetry repositories", len(repositories))
def _write_distutils_cfg(
context: Context, repositories: list[PythonRepository]
) -> None:
prefixes = [
p
for pattern in _DISTUTILS_GLOBS
for p in context.glob_src(pattern)
if p.is_dir()
]
for prefix in prefixes:
relative = prefix.relative_to(context.src).as_posix()
content = f"[easy_install]\nindex_url = {repositories[0].index_url}\n"
context.write_file(
f"{relative}/distutils/distutils.cfg", content.encode("utf-8")
)
logger.info("configured easy_install for %s", relative)
@register
class PythonModule(Module):
name = "python"
def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.python.repositories)
def apply(self, context: Context) -> None:
repositories = context.config.configurations.python.repositories
_write_pip_conf(context, repositories)
_write_uv_toml(context, repositories)
_write_poetry_config(context, repositories)
_write_distutils_cfg(context, repositories)
+126
View File
@@ -0,0 +1,126 @@
"""Configure YUM/DNF repositories and trust their GPG keys.
DNF is a drop-in successor to YUM and reads the exact same `.repo` files
under `/etc/yum.repos.d/`, so a single module covers both.
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
copied into `<dst>` with all of its sections disabled (`enabled=0`), before
the repositories declared in the configuration are written as new, enabled
`.repo` files (so a configured repository reusing an existing file name
ends up enabled, taking precedence over the disabled copy). Each `.repo`
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
on the target system). Separately, each GPG key declared in
`configurations.yum.keys` is validated with `gpg` (catching a malformed key
early) and written to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`, the
conventional location such keys live at on RPM-based systems. `gpg` is only
used here to validate the key; the actual trust decision is left to
YUM/DNF, which import it from that file the first time the repository is
used (as `rpm --import` would).
"""
from __future__ import annotations
import base64
import configparser
import logging
import shutil
import subprocess
import tempfile
from io import StringIO
from id_configurator.config import NamedContent, YumRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_REPOS_DIR = "etc/yum.repos.d"
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
def _disable_repo_content(content: str) -> str:
parser = configparser.ConfigParser(interpolation=None)
parser.optionxform = str
parser.read_string(content)
for section in parser.sections():
parser[section]["enabled"] = "0"
buffer = StringIO()
parser.write(buffer, space_around_delimiters=False)
return buffer.getvalue()
def _disable_existing_repos(context: Context) -> None:
for entry in context.iter_src_dir(_REPOS_DIR):
if entry.suffix != ".repo":
continue
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"))
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
logger.info("disabled existing yum/dnf repository file %s", entry.name)
def _write_repo_file(context: Context, repository: YumRepository) -> None:
lines = [
f"[{repository.name}]",
f"name={repository.name}",
f"baseurl={repository.index_url}",
"enabled=1",
]
if repository.gpg_url:
lines.append("gpgcheck=1")
lines.append(f"gpgkey={repository.gpg_url}")
else:
lines.append("gpgcheck=0")
context.write_file(
f"{_REPOS_DIR}/{repository.name}.repo",
("\n".join(lines) + "\n").encode("utf-8"),
)
logger.info("added yum/dnf repository %s", repository.name)
def _validate_gpg_key(name: str, content: bytes) -> None:
with tempfile.TemporaryDirectory() as gnupg_home:
path = shutil.which("gpg")
if path is not None:
result = subprocess.run(
[path, "--homedir", gnupg_home, "--batch", "--import"],
input=content,
capture_output=True,
check=False,
)
else:
raise RuntimeError(
"gpg is not available; the gnupg package must be installed in the environment running id-configurator"
)
if result.returncode != 0:
raise RuntimeError(
f"invalid GPG key {name}: {result.stderr.decode('utf-8', 'replace')}"
)
def _import_key(context: Context, key: NamedContent) -> None:
content = base64.b64decode(key.content)
_validate_gpg_key(key.name, content)
context.write_file(f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", content)
logger.info("imported yum/dnf GPG key %s", key.name)
@register
class YumModule(Module):
name = "yum"
def is_relevant(self, context: Context) -> bool:
yum = context.config.configurations.yum
existing_repo_files = (
entry
for entry in context.iter_src_dir(_REPOS_DIR)
if entry.suffix == ".repo"
)
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
def apply(self, context: Context) -> None:
_disable_existing_repos(context)
for repository in context.config.configurations.yum.repositories:
_write_repo_file(context, repository)
for key in context.config.configurations.yum.keys:
_import_key(context, key)
+5
View File
@@ -28,7 +28,9 @@ classifiers = [
'Programming Language :: Python :: 3.14'
]
dependencies = [
"cryptography>=50.0.1",
"pydantic>=2.13.4",
"pyjks>=20.0.0",
"pyyaml>=6.0.3",
"typer>=0.27.1"
]
@@ -67,5 +69,8 @@ source = ["id_configurator"]
addopts = "--cov=id_configurator --cov-report=term-missing"
testpaths = ["tests"]
[tool.uv]
default-groups = ["dev", "test"]
[tool.uv.build-backend]
module-root = ""
+104
View File
@@ -0,0 +1,104 @@
import base64
import hashlib
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.apk import ApkModule
KEY_BYTES = b"fake-apk-key-bytes"
KEY_FILENAME = f"{hashlib.sha256(KEY_BYTES).hexdigest()[:16]}.rsa.pub"
CONFIG_DATA = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"url": "https://example.invalid/v3.19/main",
"key": base64.b64encode(KEY_BYTES).decode("ascii"),
},
{"url": "https://example.invalid/v3.19/community"},
]
}
},
}
def make_config() -> Configuration:
return Configuration.model_validate(CONFIG_DATA)
def test_is_relevant_false_without_src(tmp_path) -> None:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
assert ApkModule().is_relevant(context) is False
def test_is_relevant_false_without_apk_dir(tmp_path) -> None:
src = tmp_path / "src"
src.mkdir()
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert ApkModule().is_relevant(context) is False
def test_is_relevant_true_with_apk_dir(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert ApkModule().is_relevant(context) is True
def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None:
src = tmp_path / "src"
apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True)
(apk_dir / "repositories").write_text(
"# comment\nhttps://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=src)
ApkModule().apply(context)
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert content == (
"# comment\n"
"# https://old-mirror.invalid/v3.18/main\n"
"# https://old-mirror.invalid/v3.18/community\n"
"https://example.invalid/v3.19/main\n"
"https://example.invalid/v3.19/community\n"
)
key_content = (dst / "etc/apk/keys" / KEY_FILENAME).read_bytes()
assert key_content == KEY_BYTES
def test_apply_without_existing_repositories_file(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=make_config(), dst=dst, src=src)
ApkModule().apply(context)
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert (
content
== "https://example.invalid/v3.19/main\nhttps://example.invalid/v3.19/community\n"
)
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
empty_config = Configuration.model_validate(
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
)
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=empty_config, dst=dst, src=src)
ApkModule().apply(context)
assert not (dst / "etc/apk/repositories").exists()
+13 -13
View File
@@ -34,21 +34,21 @@ def make_config() -> Configuration:
def test_is_relevant_false_without_src(tmp_path) -> None:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
assert AptModule().is_relevant(context) is False # nosec B101
assert AptModule().is_relevant(context) is False
def test_is_relevant_false_without_apt_dir(tmp_path) -> None:
src = tmp_path / "src"
src.mkdir()
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert AptModule().is_relevant(context) is False # nosec B101
assert AptModule().is_relevant(context) is False
def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apt").mkdir(parents=True)
context = Context(config=make_config(), dst=tmp_path / "dst", src=src)
assert AptModule().is_relevant(context) is True # nosec B101
assert AptModule().is_relevant(context) is True
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None:
@@ -82,31 +82,31 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
assert (
disabled_top_level
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n"
) # nosec B101
)
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(
encoding="utf-8"
) # nosec B101
assert disabled_list == "# deb http://example.invalid/extra stable main\n" # nosec B101
)
assert disabled_list == "# deb http://example.invalid/extra stable main\n"
disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
encoding="utf-8"
) # nosec B101
assert disabled_sources == ( # nosec B101
)
assert disabled_sources == (
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no"
"\n\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n"
)
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() # nosec B101
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes()
assert keyring == b"fake-key-bytes" # nosec B101
assert keyring == b"fake-key-bytes"
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == ( # nosec B101
assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n"
)
@@ -120,5 +120,5 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
AptModule().apply(context)
assert not (dst / "etc/apt/sources.list").exists() # nosec B101
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() # nosec B101
assert not (dst / "etc/apt/sources.list").exists()
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
+13 -13
View File
@@ -25,12 +25,12 @@ def make_config(with_certificate: bool) -> Configuration:
def test_is_relevant_true_when_certificates_configured(tmp_path) -> None:
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
assert CertificatesModule().is_relevant(context) is True # nosec B101
assert CertificatesModule().is_relevant(context) is True
def test_is_relevant_false_when_no_certificates(tmp_path) -> None:
context = Context(config=make_config(False), dst=tmp_path / "dst", src=None)
assert CertificatesModule().is_relevant(context) is False # nosec B101
assert CertificatesModule().is_relevant(context) is False
def _mock_success(monkeypatch, stderr: str = "") -> MagicMock:
@@ -53,12 +53,12 @@ def test_apply_writes_certificate_and_calls_update_ca_certificates(
CertificatesModule().apply(context)
written = dst / "usr/local/share/ca-certificates/new-root-ca.crt"
assert written.read_bytes() == b"fake-cert-bytes" # nosec B101
assert written.read_bytes() == b"fake-cert-bytes"
mock_run.assert_called_once()
args = mock_run.call_args.args[0]
assert args[0] == "update-ca-certificates" # nosec B101
assert "--certsconf" not in args # nosec B101
assert "--certsdir" not in args # nosec B101
assert args[0] == "update-ca-certificates"
assert "--certsconf" not in args
assert "--certsdir" not in args
def test_apply_preserves_existing_local_certificates_from_src(
@@ -82,14 +82,14 @@ def test_apply_preserves_existing_local_certificates_from_src(
CertificatesModule().apply(context)
local_dst = dst / "usr/local/share/ca-certificates"
assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" # nosec B101
assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" # nosec B101
assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes"
assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes"
args = mock_run.call_args.args[0]
assert "--certsconf" in args # nosec B101
assert str(src / "etc/ca-certificates.conf") in args # nosec B101
assert "--certsdir" in args # nosec B101
assert str(src / "usr/share/ca-certificates") in args # nosec B101
assert "--certsconf" in args
assert str(src / "etc/ca-certificates.conf") in args
assert "--certsdir" in args
assert str(src / "usr/share/ca-certificates") in args
def test_update_ca_certificates_raises_when_binary_missing(
@@ -116,4 +116,4 @@ def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
with pytest.raises(RuntimeError, match="update-ca-certificates failed"):
CertificatesModule().apply(context) # nosec B101
CertificatesModule().apply(context)
+22 -11
View File
@@ -32,20 +32,27 @@ def write_config(tmp_path, data) -> str:
def test_validate_valid_config(tmp_path) -> None:
config_path = write_config(tmp_path, MINIMAL_CONFIG)
result = runner.invoke(cli.app, ["validate", "--config", config_path])
assert result.exit_code == 0 # nosec B101
assert "is valid" in result.stdout # nosec B101
assert result.exit_code == 0
assert "is valid" in result.stdout
def test_validate_without_config_fails() -> None:
result = runner.invoke(cli.app, ["validate"])
assert result.exit_code != 0 # nosec B101
assert "either --config or --config-env" in result.output # nosec B101
assert result.exit_code != 0
assert "either --config or --config-env" in result.output
def test_list_modules() -> None:
result = runner.invoke(cli.app, ["list-modules"])
assert result.exit_code == 0 # nosec B101
assert set(result.stdout.splitlines()) == {"apt", "certificates"} # nosec B101
assert result.exit_code == 0
assert set(result.stdout.splitlines()) == {
"apk",
"apt",
"certificates",
"java",
"python",
"yum",
}
def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None:
@@ -55,9 +62,13 @@ def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None:
result = runner.invoke(
cli.app, ["apply", "--config", config_path, "--dst", str(dst), "-v"]
)
assert result.exit_code == 0 # nosec B101
assert "skipping module apt" in caplog.text # nosec B101
assert "skipping module certificates" in caplog.text # nosec B101
assert result.exit_code == 0
assert "skipping module apk" in caplog.text
assert "skipping module java" in caplog.text
assert "skipping module apt" in caplog.text
assert "skipping module certificates" in caplog.text
assert "skipping module yum" in caplog.text
assert "skipping module python" in caplog.text
def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None:
@@ -74,8 +85,8 @@ def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None:
cli.app, ["apply", "--config", config_path, "--dst", str(dst)]
)
assert result.exit_code == 0 # nosec B101
assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" # nosec B101
assert result.exit_code == 0
assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake"
mock_run.assert_called_once()
+18 -16
View File
@@ -36,7 +36,8 @@ FULL_CONFIG = {
"index-url": "http://example.invalid/yum",
"gpg-url": "http://example.invalid/gpg",
}
]
],
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
},
"python": {
"repositories": [
@@ -49,26 +50,27 @@ FULL_CONFIG = {
def test_parse_configuration_from_json() -> None:
configuration = parse_configuration(json.dumps(FULL_CONFIG))
assert isinstance(configuration, Configuration) # nosec B101
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.configurations.certificates[0].name == "my-ca" # nosec B101
assert isinstance(configuration, Configuration)
assert configuration.organisation == "InterDiode"
assert configuration.configurations.certificates[0].name == "my-ca"
assert (
configuration.configurations.yum.repositories[0].index_url
== "http://example.invalid/yum"
) # nosec B101
)
assert (
configuration.configurations.yum.repositories[0].gpg_url
== "http://example.invalid/gpg"
) # nosec B101
)
assert (
configuration.configurations.python.repositories[0].index_url
== "http://example.invalid/simple"
) # nosec B101
)
assert (
configuration.configurations.apt.sources[0].signed_by
== "/etc/apt/keyrings/interdiode.asc"
) # nosec B101
assert configuration.configurations.apt.keys[0].name == "interdiode" # nosec B101
)
assert configuration.configurations.apt.keys[0].name == "interdiode"
assert configuration.configurations.yum.keys[0].name == "interdiode"
def test_parse_configuration_from_yaml() -> None:
@@ -77,22 +79,22 @@ def test_parse_configuration_from_yaml() -> None:
organisation: InterDiode
"""
configuration = parse_configuration(raw)
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.description == "" # nosec B101
assert configuration.configurations.certificates == [] # nosec B101
assert configuration.organisation == "InterDiode"
assert configuration.description == ""
assert configuration.configurations.certificates == []
def test_load_configuration_from_file(tmp_path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
configuration = load_configuration(config_file, None)
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.organisation == "InterDiode"
def test_load_configuration_from_env() -> None:
encoded = base64.b64encode(json.dumps(FULL_CONFIG).encode("utf-8")).decode("ascii")
configuration = load_configuration(None, encoded)
assert configuration.organisation == "InterDiode" # nosec B101
assert configuration.organisation == "InterDiode"
def test_load_configuration_without_source_raises() -> None:
@@ -107,7 +109,7 @@ def test_apt_source_to_deb822_minimal() -> None:
assert (
source.to_deb822()
== "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n"
) # nosec B101
)
def test_apt_source_to_deb822_full() -> None:
@@ -138,7 +140,7 @@ def test_apt_source_to_deb822_full() -> None:
description="InterDiode APT repository",
)
rendered = source.to_deb822()
assert rendered.splitlines() == [ # nosec B101
assert rendered.splitlines() == [
"Types: deb deb-src",
"URIs: http://example.invalid/debian",
"Suites: stable",
+25 -11
View File
@@ -21,7 +21,7 @@ def make_context(tmp_path, with_src: bool = True) -> Context:
def test_dst_path_within_destination(tmp_path) -> None:
context = make_context(tmp_path)
resolved = context.dst_path("etc/foo.conf")
assert resolved == context.dst / "etc" / "foo.conf" # nosec B101
assert resolved == context.dst / "etc" / "foo.conf"
def test_dst_path_rejects_escape(tmp_path) -> None:
@@ -32,46 +32,46 @@ def test_dst_path_rejects_escape(tmp_path) -> None:
def test_src_path_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.src_path("etc/foo.conf") is None # nosec B101
assert context.src_path("etc/foo.conf") is None
def test_src_path_with_src(tmp_path) -> None:
context = make_context(tmp_path)
resolved = context.src_path("etc/foo.conf")
assert resolved == context.src / "etc" / "foo.conf" # nosec B101
assert resolved == context.src / "etc" / "foo.conf"
def test_write_file_creates_parents_and_content(tmp_path) -> None:
context = make_context(tmp_path)
path = context.write_file("etc/nested/foo.conf", b"hello")
assert path.read_bytes() == b"hello" # nosec B101
assert path.stat().st_mode & 0o777 == 0o644 # nosec B101
assert path.read_bytes() == b"hello"
assert path.stat().st_mode & 0o777 == 0o644
def test_read_src_file_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.read_src_file("etc/foo.conf") is None # nosec B101
assert context.read_src_file("etc/foo.conf") is None
def test_read_src_file_missing(tmp_path) -> None:
context = make_context(tmp_path)
assert context.read_src_file("etc/missing.conf") is None # nosec B101
assert context.read_src_file("etc/missing.conf") is None
def test_read_src_file_present(tmp_path) -> None:
context = make_context(tmp_path)
(context.src / "foo.conf").write_bytes(b"existing")
assert context.read_src_file("foo.conf") == b"existing" # nosec B101
assert context.read_src_file("foo.conf") == b"existing"
def test_iter_src_dir_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.iter_src_dir("some/dir") == [] # nosec B101
assert context.iter_src_dir("some/dir") == []
def test_iter_src_dir_missing_dir(tmp_path) -> None:
context = make_context(tmp_path)
assert context.iter_src_dir("missing-dir") == [] # nosec B101
assert context.iter_src_dir("missing-dir") == []
def test_iter_src_dir_lists_only_files(tmp_path) -> None:
@@ -82,4 +82,18 @@ def test_iter_src_dir_lists_only_files(tmp_path) -> None:
(target / "b.crt").write_bytes(b"b")
(target / "subdir").mkdir()
result = context.iter_src_dir("certs")
assert [entry.name for entry in result] == ["a.crt", "b.crt"] # nosec B101
assert [entry.name for entry in result] == ["a.crt", "b.crt"]
def test_glob_src_without_src(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.glob_src("usr/lib/python3.*") == []
def test_glob_src_matches_pattern(tmp_path) -> None:
context = make_context(tmp_path)
(context.src / "usr/lib/python3.11").mkdir(parents=True)
(context.src / "usr/lib/python3.12").mkdir(parents=True)
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
result = context.glob_src("usr/lib/python3.*")
assert [entry.name for entry in result] == ["python3.11", "python3.12"]
+392
View File
@@ -0,0 +1,392 @@
import base64
import datetime
import jks
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding,
pkcs12,
)
from cryptography.x509.oid import NameOID
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.java import JavaModule, _keystore_kind
def _self_signed_cert(common_name: str) -> x509.Certificate:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
now = datetime.datetime.now(datetime.UTC)
return (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=1))
.sign(key, hashes.SHA256())
)
NEW_CERT = _self_signed_cert("interdiode-root-ca")
EXISTING_CERT = _self_signed_cert("already-trusted-ca")
CONFIG_WITH_CERT = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"certificates": [
{
"name": "interdiode-root-ca",
"content": base64.b64encode(NEW_CERT.public_bytes(Encoding.PEM)).decode(
"ascii"
),
}
]
},
}
EMPTY_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
}
def make_config(data: dict) -> Configuration:
return Configuration.model_validate(data)
def _pkcs12_cacerts(
entries: list[tuple[str, x509.Certificate]] = (), password: bytes = b"changeit"
) -> bytes:
# serialize_java_truststore refuses an empty cert list, so seed with a placeholder when none given.
seeded = list(entries) or [("placeholder-ca", EXISTING_CERT)]
certs = [
pkcs12.PKCS12Certificate(cert, name.encode("utf-8")) for name, cert in seeded
]
return pkcs12.serialize_java_truststore(certs, BestAvailableEncryption(password))
def _jks_cacerts(entries: list[tuple[str, x509.Certificate]] = ()) -> bytes:
store = jks.KeyStore.new("jks", [])
for name, cert in entries:
store.entries[name] = jks.TrustedCertEntry.new(
name, cert.public_bytes(Encoding.DER)
)
return store.saves("changeit")
def test_is_relevant_false_without_certificates(tmp_path) -> None:
src = tmp_path / "src"
(src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True)
(src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts())
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src)
assert JavaModule().is_relevant(context) is False
def test_is_relevant_false_without_java_installation(tmp_path) -> None:
context = Context(
config=make_config(CONFIG_WITH_CERT), dst=tmp_path / "dst", src=None
)
assert JavaModule().is_relevant(context) is False
def test_is_relevant_true_with_certificate_and_cacerts(tmp_path) -> None:
src = tmp_path / "src"
(src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True)
(src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts())
context = Context(
config=make_config(CONFIG_WITH_CERT), dst=tmp_path / "dst", src=src
)
assert JavaModule().is_relevant(context) is True
def test_apply_adds_certificate_to_pkcs12_and_jks_keystores(tmp_path) -> None:
src = tmp_path / "src"
pkcs12_dir = src / "usr/lib/jvm/java-21/lib/security"
jks_dir = src / "opt/java/openjdk8/jre/lib/security"
pkcs12_dir.mkdir(parents=True)
jks_dir.mkdir(parents=True)
(pkcs12_dir / "cacerts").write_bytes(
_pkcs12_cacerts([("already-trusted-ca", EXISTING_CERT)])
)
(jks_dir / "cacerts").write_bytes(
_jks_cacerts([("already-trusted-ca", EXISTING_CERT)])
)
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_CERT), dst=dst, src=src)
JavaModule().apply(context)
pkcs12_result = pkcs12.load_pkcs12(
(dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), b"changeit"
)
pkcs12_aliases = {entry.friendly_name for entry in pkcs12_result.additional_certs}
assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"}
jks_result = jks.KeyStore.loads(
(dst / "opt/java/openjdk8/jre/lib/security/cacerts").read_bytes(), "changeit"
)
assert set(jks_result.certs.keys()) == {"already-trusted-ca", "interdiode-root-ca"}
assert jks_result.certs["interdiode-root-ca"].cert == NEW_CERT.public_bytes(
Encoding.DER
)
def test_apply_skips_keystore_with_wrong_password(tmp_path, caplog) -> None:
src = tmp_path / "src"
good_dir = src / "usr/lib/jvm/java-21/lib/security"
bad_dir = src / "opt/java/other/lib/security"
good_dir.mkdir(parents=True)
bad_dir.mkdir(parents=True)
(good_dir / "cacerts").write_bytes(_pkcs12_cacerts())
(bad_dir / "cacerts").write_bytes(_pkcs12_cacerts(password=b"not-changeit"))
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_CERT), dst=dst, src=src)
with caplog.at_level("WARNING", logger="id_configurator"):
JavaModule().apply(context)
assert (dst / "usr/lib/jvm/java-21/lib/security/cacerts").exists()
assert not (dst / "opt/java/other/lib/security/cacerts").exists()
assert "skipping java keystore" in caplog.text
@pytest.mark.parametrize(
("content", "expected"),
[
(jks.MAGIC_NUMBER_JKS + b"rest", "jks"),
(jks.MAGIC_NUMBER_JCEKS + b"rest", "jks"),
(b"\x30\x82\x00\x00rest", "pkcs12"),
],
)
def test_keystore_kind_detection(content, expected) -> None:
assert _keystore_kind(content) == expected
ONE_REPO_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"java": {
"repositories": [
{"name": "interdiode", "url": "https://example.invalid/maven"}
]
}
},
}
TWO_REPOS_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"java": {
"repositories": [
{"name": "interdiode", "url": "https://example.invalid/maven"},
{
"name": "interdiode-extra",
"url": "https://example2.invalid/maven",
},
]
}
},
}
def test_is_relevant_true_with_repositories_and_no_src(tmp_path) -> None:
context = Context(
config=make_config(ONE_REPO_CONFIG), dst=tmp_path / "dst", src=None
)
assert JavaModule().is_relevant(context) is True
def test_apply_without_certificates_only_writes_repository_files(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(ONE_REPO_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
assert not (dst / "usr").exists()
assert (dst / "etc/maven/settings.xml").exists()
def test_apply_writes_maven_settings(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
settings = (dst / "etc/maven/settings.xml").read_text(encoding="utf-8")
assert settings == (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
'xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 '
'http://maven.apache.org/xsd/settings-1.1.0.xsd">\n'
" <profiles>\n"
" <profile>\n"
" <id>id-configurator</id>\n"
" <activation>\n"
" <activeByDefault>true</activeByDefault>\n"
" </activation>\n"
" <repositories>\n"
" <repository>\n"
" <id>interdiode</id>\n"
" <url>https://example.invalid/maven</url>\n"
" </repository>\n"
" <repository>\n"
" <id>interdiode-extra</id>\n"
" <url>https://example2.invalid/maven</url>\n"
" </repository>\n"
" </repositories>\n"
" <pluginRepositories>\n"
" <pluginRepository>\n"
" <id>interdiode</id>\n"
" <url>https://example.invalid/maven</url>\n"
" </pluginRepository>\n"
" <pluginRepository>\n"
" <id>interdiode-extra</id>\n"
" <url>https://example2.invalid/maven</url>\n"
" </pluginRepository>\n"
" </pluginRepositories>\n"
" </profile>\n"
" </profiles>\n"
" <activeProfiles>\n"
" <activeProfile>id-configurator</activeProfile>\n"
" </activeProfiles>\n"
" <mirrors>\n"
" <mirror>\n"
" <id>interdiode</id>\n"
" <url>https://example.invalid/maven</url>\n"
" <mirrorOf>*</mirrorOf>\n"
" </mirror>\n"
" </mirrors>\n"
"</settings>\n"
)
def test_apply_writes_gradle_init_script(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
script = (
dst / "root/.gradle/init.d/id-configurator-repositories.gradle"
).read_text(encoding="utf-8")
assert script == (
"allprojects {\n"
" repositories {\n"
' maven { url = uri("https://example.invalid/maven") }\n'
' maven { url = uri("https://example2.invalid/maven") }\n'
" }\n"
"}\n"
"\n"
"settingsEvaluated { settings ->\n"
" settings.pluginManagement {\n"
" repositories {\n"
' maven { url = uri("https://example.invalid/maven") }\n'
' maven { url = uri("https://example2.invalid/maven") }\n'
" }\n"
" }\n"
"}\n"
)
def test_apply_writes_ivy_settings(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
ivysettings = (dst / "root/.ivy2/ivysettings.xml").read_text(encoding="utf-8")
assert ivysettings == (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<ivysettings>\n"
' <settings defaultResolver="id-configurator" />\n'
" <resolvers>\n"
' <chain name="id-configurator">\n'
' <ibiblio name="interdiode" m2compatible="true" '
'root="https://example.invalid/maven" />\n'
' <ibiblio name="interdiode-extra" m2compatible="true" '
'root="https://example2.invalid/maven" />\n'
" </chain>\n"
" </resolvers>\n"
"</ivysettings>\n"
)
def test_apply_writes_sbt_repositories(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
repositories = (dst / "root/.sbt/repositories").read_text(encoding="utf-8")
assert repositories == (
"[repositories]\n"
" local\n"
" interdiode: https://example.invalid/maven\n"
" interdiode-extra: https://example2.invalid/maven\n"
)
def test_apply_writes_lein_profiles(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
JavaModule().apply(context)
profiles = (dst / "root/.lein/profiles.clj").read_text(encoding="utf-8")
assert profiles == (
"{:user {:repositories "
'[["interdiode" {:url "https://example.invalid/maven"}] '
'["interdiode-extra" {:url "https://example2.invalid/maven"}]]}}\n'
)
def test_apply_with_certificates_and_repositories_writes_both(tmp_path) -> None:
src = tmp_path / "src"
(src / "usr/lib/jvm/java-21/lib/security").mkdir(parents=True)
(src / "usr/lib/jvm/java-21/lib/security/cacerts").write_bytes(_pkcs12_cacerts())
combined_config = {
**CONFIG_WITH_CERT,
"configurations": {
**CONFIG_WITH_CERT["configurations"],
"java": ONE_REPO_CONFIG["configurations"]["java"],
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(combined_config), dst=dst, src=src)
JavaModule().apply(context)
assert (dst / "usr/lib/jvm/java-21/lib/security/cacerts").exists()
assert (dst / "etc/maven/settings.xml").exists()
def test_apply_writes_maven_settings_to_detected_installations(tmp_path) -> None:
src = tmp_path / "src"
(src / "usr/share/maven/bin").mkdir(parents=True)
(src / "usr/share/maven/bin/mvn").write_text("#!/bin/sh\n")
(src / "opt/apache-maven-3.9.6/bin").mkdir(parents=True)
(src / "opt/apache-maven-3.9.6/bin/mvn").write_text("#!/bin/sh\n")
dst = tmp_path / "dst"
context = Context(config=make_config(ONE_REPO_CONFIG), dst=dst, src=src)
JavaModule().apply(context)
global_settings = (dst / "etc/maven/settings.xml").read_bytes()
assert (dst / "usr/share/maven/conf/settings.xml").read_bytes() == global_settings
assert (
dst / "opt/apache-maven-3.9.6/conf/settings.xml"
).read_bytes() == global_settings
+1 -1
View File
@@ -16,4 +16,4 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
register(DummyModule)
assert all_modules() == [DummyModule] # nosec B101
assert all_modules() == [DummyModule]
+2 -2
View File
@@ -6,8 +6,8 @@ from unittest.mock import MagicMock
def test_package_exposes_app_and_main() -> None:
import id_configurator
assert id_configurator.main is id_configurator.cli.main # nosec B101
assert id_configurator.app is id_configurator.cli.app # nosec B101
assert id_configurator.main is id_configurator.cli.main
assert id_configurator.app is id_configurator.cli.app
def test_dunder_main_module_does_not_launch_app_when_imported(monkeypatch) -> None:
+139
View File
@@ -0,0 +1,139 @@
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.python import PythonModule
ONE_REPO_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{"name": "interdiode", "index-url": "https://example.invalid/simple"}
]
}
},
}
TWO_REPOS_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{"name": "interdiode", "index-url": "https://example.invalid/simple"},
{
"name": "interdiode-extra",
"index-url": "https://example2.invalid/simple",
},
]
}
},
}
def test_is_relevant_false_without_repositories(tmp_path) -> None:
config = Configuration.model_validate(
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
)
context = Context(config=config, dst=tmp_path / "dst", src=None)
assert PythonModule().is_relevant(context) is False
def test_is_relevant_true_with_repositories(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
context = Context(config=config, dst=tmp_path / "dst", src=None)
assert PythonModule().is_relevant(context) is True
def test_apply_single_repository(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
assert pip_conf == (
"[global]\nindex-url = https://example.invalid/simple\ncert = /etc/ssl/certs/ca-certificates.crt\n"
)
uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8")
assert uv_toml == (
'native-tls = true\n\n[[index]]\nname = "interdiode"\nurl = "https://example.invalid/simple"\ndefault = true\n'
)
poetry_config = (dst / "root/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
assert poetry_config == (
"[repositories.interdiode]\n"
'url = "https://example.invalid/simple"\n'
"\n"
"[certificates.interdiode]\n"
'cert = "/etc/ssl/certs/ca-certificates.crt"\n'
)
assert not (dst / "usr/lib").exists()
def test_apply_multiple_repositories(tmp_path) -> None:
config = Configuration.model_validate(TWO_REPOS_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
assert pip_conf == (
"[global]\n"
"index-url = https://example.invalid/simple\n"
"extra-index-url =\n"
" https://example2.invalid/simple\n"
"cert = /etc/ssl/certs/ca-certificates.crt\n"
)
uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8")
assert uv_toml == (
"native-tls = true\n\n"
"[[index]]\n"
'name = "interdiode"\n'
'url = "https://example.invalid/simple"\n'
"default = true\n\n"
"[[index]]\n"
'name = "interdiode-extra"\n'
'url = "https://example2.invalid/simple"\n'
)
def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
src = tmp_path / "src"
(src / "usr/lib/python3.11").mkdir(parents=True)
(src / "usr/local/lib/python3.12").mkdir(parents=True)
(src / "usr/lib/not-python").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=src)
PythonModule().apply(context)
content_311 = (dst / "usr/lib/python3.11/distutils/distutils.cfg").read_text(
encoding="utf-8"
)
assert content_311 == "[easy_install]\nindex_url = https://example.invalid/simple\n"
content_312 = (dst / "usr/local/lib/python3.12/distutils/distutils.cfg").read_text(
encoding="utf-8"
)
assert content_312 == "[easy_install]\nindex_url = https://example.invalid/simple\n"
assert not (dst / "usr/lib/not-python").exists()
def test_apply_without_src_skips_distutils_cfg(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
assert not (dst / "usr").exists()
+194
View File
@@ -0,0 +1,194 @@
import base64
import subprocess
from configparser import ConfigParser
from unittest.mock import MagicMock
import pytest
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.yum import YumModule
KEY_CONTENT = base64.b64encode(b"fake-rpm-gpg-key-bytes").decode("ascii")
CONFIG_WITH_REPO_AND_KEY = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "interdiode",
"index-url": "https://example.invalid/yum",
"gpg-url": "https://example.invalid/RPM-GPG-KEY-interdiode",
}
],
"keys": [{"name": "interdiode", "content": KEY_CONTENT}],
}
},
}
CONFIG_WITH_UNSIGNED_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{"name": "unsigned", "index-url": "https://example.invalid/yum"}
]
}
},
}
EMPTY_CONFIG = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
}
def make_config(data: dict) -> Configuration:
return Configuration.model_validate(data)
def test_is_relevant_false_when_empty(tmp_path) -> None:
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None)
assert YumModule().is_relevant(context) is False
def test_is_relevant_true_with_repository(tmp_path) -> None:
context = Context(
config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=tmp_path / "dst", src=None
)
assert YumModule().is_relevant(context) is True
def test_is_relevant_true_with_existing_repo_file(tmp_path) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "old.repo").write_text(
"[old]\nname=old\nbaseurl=https://old.invalid\nenabled=1\n", encoding="utf-8"
)
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src)
assert YumModule().is_relevant(context) is True
def test_is_relevant_false_with_only_unrelated_src_files(tmp_path) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "README").write_text("not a repo file\n", encoding="utf-8")
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=src)
assert YumModule().is_relevant(context) is False
def _mock_gpg_success(monkeypatch) -> MagicMock:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=b"", stderr=b""
)
)
monkeypatch.setattr("id_configurator.modules.yum.subprocess.run", mock_run)
return mock_run
def test_apply_writes_signed_repo_and_imports_key(tmp_path, monkeypatch) -> None:
mock_run = _mock_gpg_success(monkeypatch)
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=dst, src=None)
YumModule().apply(context)
repo_content = (dst / "etc/yum.repos.d/interdiode.repo").read_text(encoding="utf-8")
assert repo_content == (
"[interdiode]\n"
"name=interdiode\n"
"baseurl=https://example.invalid/yum\n"
"enabled=1\n"
"gpgcheck=1\n"
"gpgkey=https://example.invalid/RPM-GPG-KEY-interdiode\n"
)
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-interdiode").read_bytes()
assert key_content == b"fake-rpm-gpg-key-bytes"
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["input"] == b"fake-rpm-gpg-key-bytes"
def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=dst, src=None)
YumModule().apply(context)
repo_content = (dst / "etc/yum.repos.d/unsigned.repo").read_text(encoding="utf-8")
assert repo_content == (
"[unsigned]\nname=unsigned\nbaseurl=https://example.invalid/yum\nenabled=1\ngpgcheck=0\n"
)
def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "multi.repo").write_text(
"[old-one]\nname=old-one\nbaseurl=https://old-one.invalid\nenabled=1\ngpgcheck=1\n"
"\n"
"[old-two]\nname=old-two\nbaseurl=https://old-two.invalid\nenabled=1\n",
encoding="utf-8",
)
(repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8")
# A same-named existing file must end up enabled again once the configured repo overwrites it.
(repos_dir / "unsigned.repo").write_text(
"[unsigned]\nname=unsigned\nbaseurl=https://stale.invalid\nenabled=1\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_UNSIGNED_REPO), dst=dst, src=src)
YumModule().apply(context)
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
assert "[old-one]" in disabled_multi
assert "[old-two]" in disabled_multi
parser = ConfigParser(interpolation=None)
parser.read_string(disabled_multi)
assert parser["old-one"]["enabled"] == "0"
assert parser["old-two"]["enabled"] == "0"
assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
overridden = (dst / "etc/yum.repos.d/unsigned.repo").read_text(encoding="utf-8")
assert (
overridden
== "[unsigned]\nname=unsigned\nbaseurl=https://example.invalid/yum\nenabled=1\ngpgcheck=0\n"
)
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(
"id_configurator.modules.yum.subprocess.run",
MagicMock(side_effect=FileNotFoundError()),
)
context = Context(
config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=tmp_path / "dst", src=None
)
with pytest.raises(RuntimeError, match="gpg is not available"):
YumModule().apply(context)
def test_import_key_raises_on_invalid_key(tmp_path, monkeypatch) -> None:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=2, stdout=b"", stderr=b"gpg: no valid data found"
)
)
monkeypatch.setattr("id_configurator.modules.yum.subprocess.run", mock_run)
context = Context(
config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=tmp_path / "dst", src=None
)
with pytest.raises(RuntimeError, match="invalid GPG key interdiode"):
YumModule().apply(context)
+5 -4
View File
@@ -10,7 +10,7 @@ package = wheel
dependency_groups = test
change_dir = {env_tmp_dir}
set_env =
COVERAGE_FILE = {tox_root}/.coverage.{env_name}
COVERAGE_FILE = {toxworkdir}/coverage/coverage.db
commands =
pytest {tox_root}/tests --cov-config={tox_root}/pyproject.toml --cov-fail-under=0 {posargs}
@@ -20,7 +20,8 @@ depends = py313,py314
change_dir = {tox_root}
dependency_groups = test
set_env =
COVERAGE_FILE = {tox_root}/.coverage
COVERAGE_FILE = {toxworkdir}/coverage/coverage.db
commands =
coverage combine
coverage report
python3 -m coverage combine
python3 -m coverage xml -o .tox/coverage.xml --ignore
python3 -m coverage report -m --fail-under=94 --ignore --skip-covered
Generated
+218
View File
@@ -29,6 +29,79 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z"}
]
[[package]]
dependencies = [
{name = "pycparser", marker = "implementation_name != 'PyPy'"}
]
name = "cffi"
sdist = {url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z"}
source = {registry = "https://pypi.org/simple"}
version = "2.1.1"
wheels = [
{url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z"},
{url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z"},
{url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z"},
{url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z"},
{url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z"},
{url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z"},
{url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z"},
{url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z"},
{url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z"},
{url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z"},
{url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z"},
{url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z"},
{url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z"},
{url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z"},
{url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z"},
{url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z"},
{url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z"},
{url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z"},
{url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z"},
{url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z"},
{url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z"},
{url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z"},
{url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z"},
{url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z"},
{url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z"},
{url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z"},
{url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z"},
{url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z"},
{url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z"},
{url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z"},
{url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z"},
{url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z"},
{url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z"},
{url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z"},
{url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z"},
{url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z"},
{url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z"},
{url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z"},
{url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z"},
{url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z"},
{url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z"},
{url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z"},
{url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z"},
{url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z"},
{url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z"},
{url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z"},
{url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z"},
{url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z"},
{url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z"},
{url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z"},
{url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z"},
{url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z"},
{url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z"},
{url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z"},
{url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z"},
{url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z"},
{url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z"},
{url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z"},
{url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z"},
{url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z"},
{url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z"},
{url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z"}
]
[[package]]
name = "colorama"
sdist = {url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z"}
@@ -122,6 +195,56 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z"}
]
[[package]]
dependencies = [
{name = "cffi", marker = "platform_python_implementation != 'PyPy'"}
]
name = "cryptography"
sdist = {url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z"}
source = {registry = "https://pypi.org/simple"}
version = "50.0.1"
wheels = [
{url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z"},
{url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z"},
{url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z"},
{url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z"},
{url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z"},
{url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z"},
{url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z"},
{url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z"},
{url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z"},
{url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z"},
{url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z"},
{url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z"},
{url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z"},
{url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z"},
{url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z"},
{url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z"},
{url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z"},
{url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z"},
{url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z"},
{url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z"},
{url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z"},
{url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z"},
{url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z"},
{url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z"},
{url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z"},
{url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z"},
{url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z"},
{url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z"},
{url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z"},
{url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z"},
{url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z"},
{url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z"},
{url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z"},
{url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z"},
{url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z"},
{url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z"},
{url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z"},
{url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z"},
{url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z"}
]
[[package]]
name = "distlib"
sdist = {url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z"}
@@ -142,7 +265,9 @@ wheels = [
[[package]]
dependencies = [
{name = "cryptography"},
{name = "pydantic"},
{name = "pyjks"},
{name = "pyyaml"},
{name = "typer"}
]
@@ -162,7 +287,9 @@ test = [
[package.metadata]
requires-dist = [
{name = "cryptography", specifier = ">=50.0.1"},
{name = "pydantic", specifier = ">=2.13.4"},
{name = "pyjks", specifier = ">=20.0.0"},
{name = "pyyaml", specifier = ">=6.0.3"},
{name = "typer", specifier = ">=0.27.1"}
]
@@ -186,6 +313,15 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z"}
]
[[package]]
name = "javaobj-py3"
sdist = {url = "https://files.pythonhosted.org/packages/6e/a4/a0140d76c62effc710aa180c657792236d10e43d3f993b71bafc3036fea1/javaobj_py3-0.6.1.tar.gz", hash = "sha256:088ff086c6aa2912f342754c43b0ca3a504fb869bb5e6f13395131e87744ef3c", size = 113555, upload-time = "2026-08-12T16:51:05.39Z"}
source = {registry = "https://pypi.org/simple"}
version = "0.6.1"
wheels = [
{url = "https://files.pythonhosted.org/packages/20/97/62180aa88dc4719116128a3c2fe22a962cbf69256d517f376565a38467a7/javaobj_py3-0.6.1-py2.py3-none-any.whl", hash = "sha256:da97c61cb050bad37cca00523b1a617cc26ed2b698b6322cbc118820a4f52b30", size = 94957, upload-time = "2026-08-12T16:51:04.022Z"}
]
[[package]]
dependencies = [
{name = "mdurl"}
@@ -234,6 +370,66 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z"}
]
[[package]]
name = "pyasn1"
sdist = {url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z"}
source = {registry = "https://pypi.org/simple"}
version = "0.6.4"
wheels = [
{url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z"}
]
[[package]]
dependencies = [
{name = "pyasn1"}
]
name = "pyasn1-modules"
sdist = {url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z"}
source = {registry = "https://pypi.org/simple"}
version = "0.4.2"
wheels = [
{url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z"}
]
[[package]]
name = "pycparser"
sdist = {url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z"}
source = {registry = "https://pypi.org/simple"}
version = "3.0"
wheels = [
{url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z"}
]
[[package]]
name = "pycryptodomex"
sdist = {url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z"}
source = {registry = "https://pypi.org/simple"}
version = "3.23.0"
wheels = [
{url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z"},
{url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z"},
{url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z"},
{url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z"},
{url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z"},
{url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z"},
{url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z"},
{url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z"},
{url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z"},
{url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z"},
{url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z"},
{url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z"},
{url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z"},
{url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z"},
{url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z"},
{url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z"},
{url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z"},
{url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z"},
{url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z"},
{url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z"},
{url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z"},
{url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z"}
]
[[package]]
dependencies = [
{name = "annotated-types"},
@@ -314,6 +510,22 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z"}
]
[[package]]
dependencies = [
{name = "javaobj-py3"},
{name = "pyasn1"},
{name = "pyasn1-modules"},
{name = "pycryptodomex"},
{name = "twofish"}
]
name = "pyjks"
sdist = {url = "https://files.pythonhosted.org/packages/9a/3d/69e49f948f4daa09d27ac3c5378d44107e1bf98b3b5c0f847121c3773945/pyjks-20.0.0.tar.gz", hash = "sha256:0378cec15fb11b2ed27ba54dad9fd987d48e6f62f49fcff138f5f7a8b312b044", size = 25695, upload-time = "2020-04-19T01:37:26.52Z"}
source = {registry = "https://pypi.org/simple"}
version = "20.0.0"
wheels = [
{url = "https://files.pythonhosted.org/packages/28/46/e1f2c679d9c6c76a5fda4eb99922a51141803089bf58e77c74a1fe6f9033/pyjks-20.0.0-py2.py3-none-any.whl", hash = "sha256:394dee142ecff6b1adc36f64356e5584732f1859575aa03b9cf5d5541a9e3460", size = 45253, upload-time = "2020-04-19T01:37:28.789Z"}
]
[[package]]
dependencies = [
{name = "packaging"}
@@ -481,6 +693,12 @@ wheels = [
{url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z"}
]
[[package]]
name = "twofish"
sdist = {url = "https://files.pythonhosted.org/packages/82/b4/9eb026a8e62a04512435d3de25c93f7bda51c8b8c7991c1c0be70b5115a6/twofish-0.3.0.tar.gz", hash = "sha256:b09d8bb50d33b23ff34cafb1f9209f858f752935c6a5c901efb92a41acb830fa", size = 26039, upload-time = "2013-11-15T20:37:59.634Z"}
source = {registry = "https://pypi.org/simple"}
version = "0.3.0"
[[package]]
dependencies = [
{name = "annotated-doc"},