mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: new configurations for APK/Java/Python/Yum
Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user