diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 279746d..4eba0ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,11 +71,11 @@ jobs: - name: Sync Python tools env: UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} - run: uv sync --locked + run: uv sync --locked --only-group test-python - name: Test with pytest id: test - run: uv run --locked pytest + run: uv run --locked --only-group test-python pytest - name: Upload test coverage # any except canceled or skipped diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml new file mode 100644 index 0000000..7f50fd5 --- /dev/null +++ b/.github/workflows/localize.yml @@ -0,0 +1,140 @@ +--- +name: localize +permissions: {} + +on: + workflow_call: + secrets: + github_token: + required: true + +env: + PYTHON_VERSION: '3.14' + +jobs: + localize: + name: Update Localization + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Resolve shared workflow checkout + id: shared-workflow + env: + JOB_CONTEXT: ${{ toJSON(job) }} + run: | + repository="$(jq -r '.workflow_repository // empty' <<< "${JOB_CONTEXT}")" + sha="$(jq -r '.workflow_sha // empty' <<< "${JOB_CONTEXT}")" + + if [ -z "${repository}" ] || [ -z "${sha}" ]; then + echo "Unable to resolve shared workflow repository and sha from job context." >&2 + exit 1 + fi + + echo "repository=${repository}" >> "${GITHUB_OUTPUT}" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + + - name: Checkout lizardbyte-common + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: ${{ steps.shared-workflow.outputs.repository }} + ref: ${{ steps.shared-workflow.outputs.sha }} + path: .lizardbyte-common + + - name: Install Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Sync Python tools + run: | + uv sync --project .lizardbyte-common --frozen --only-group locale \ + --python "${PYTHON_VERSION}" \ + --no-python-downloads \ + --no-install-project + + - name: Set up xgettext + run: | + sudo apt-get update -y && + sudo apt-get --reinstall install -y \ + gettext + + - name: Resolve gettext template + id: template + run: | + repository_name="${GITHUB_REPOSITORY##*/}" + file="locale/${repository_name,,}.po" + echo "file=${file}" >> "${GITHUB_OUTPUT}" + + - name: Update Strings + env: + FILE: ${{ steps.template.outputs.file }} + run: | + new_file=true + + if [ -f "${FILE}" ]; then + rm "${FILE}" + new_file=false + fi + echo "NEW_FILE=${new_file}" >> "${GITHUB_ENV}" + + uv run --project .lizardbyte-common --frozen --only-group locale --no-sync \ + python .lizardbyte-common/scripts/localize.py --root-dir "${GITHUB_WORKSPACE}" --extract + + - name: git diff + if: env.NEW_FILE == 'false' + env: + FILE: ${{ steps.template.outputs.file }} + run: | + git config --global pager.diff false + git diff -- "${FILE}" + + output="$(git diff --numstat -- "${FILE}" | sed -e $'s#\t# #g')" + echo "GIT_DIFF=${output}" >> "${GITHUB_ENV}" + + - name: git restore + if: env.NEW_FILE == 'false' + env: + FILE: ${{ steps.template.outputs.file }} + run: | + expected="1 1 ${FILE}" + if [ "${GIT_DIFF}" = "${expected}" ]; then + git restore -- "${FILE}" + fi + + - name: Clean shared tools checkout + if: always() + run: rm -rf .lizardbyte-common + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}" + + - name: Create/Update Pull Request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + add-paths: | + locale/*.po + token: ${{ secrets.github_token }} + commit-message: "chore(l10n): new babel updates" + branch: localize/update + delete-branch: true + base: master + title: "chore(l10n): new babel updates" + body: | + Update report + - Updated ${{ steps.date.outputs.date }} + - Auto-generated by [create-pull-request][1] + + [1]: https://github.com/peter-evans/create-pull-request + labels: | + babel + l10n diff --git a/README.md b/README.md index 1cbb89d..d0d25b8 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,60 @@ This repository contains shared helper scripts and repository-level tooling used across LizardByte projects. -The current tooling is focused on Python-managed C/C++ formatting helpers: +The current tooling includes Python-managed helpers and reusable GitHub workflows: - `scripts/update_clang_format.py` runs `clang-format` across supported source directories. +- `scripts/localize.py` updates gettext and Babel locale files. +- `.github/workflows/localize.yml` runs the locale helper from GitHub Actions and opens localization update pull requests. ## Python Tooling Install [uv](https://docs.astral.sh/uv/) and sync the locked tool environment: ```bash -uv sync +uv sync --locked ``` Run the clang-format helper: ```bash -uv run python scripts/update_clang_format.py +uv run --locked python scripts/update_clang_format.py +``` + +Run gettext extraction: + +```bash +uv run --locked --only-group locale python scripts/localize.py --extract +``` + +## Workflows + +Reusable GitHub workflows live under `.github/workflows/`. + +- `localize.yml` extracts gettext strings with the shared locale helper and can open a localization update pull request. + +```yaml +name: localize +permissions: {} + +on: + push: + branches: + - master + paths: + - '.github/workflows/localize.yml' + - 'src/**' + - 'locale/sunshine.po' + workflow_dispatch: + +jobs: + localize: + name: Update Localization + permissions: + contents: read + uses: LizardByte/lizardbyte-common/.github/workflows/localize.yml@master + secrets: + github_token: ${{ secrets.GH_BOT_TOKEN }} ``` ## Tests @@ -35,5 +73,5 @@ uv run python scripts/update_clang_format.py Run the pytest suite: ```bash -uv run pytest +uv run --locked --only-group test-python pytest ``` diff --git a/pyproject.toml b/pyproject.toml index 2908b96..c1e084b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,13 +15,29 @@ dependencies = [] [dependency-groups] dev = [ - {include-group = "lint"}, - {include-group = "test"}, + {include-group = "c"}, + {include-group = "lint-python"}, + {include-group = "test-python"}, ] -lint = [ - "clang-format==21.*", +c = [ + {include-group = "lint-c"}, + {include-group = "locale"}, + {include-group = "test-c"}, ] -test = [ +lint-c = [ + "clang-format==21.1.8", + "cmakelang==0.6.13", +] +lint-python = [ + "flake8==7.3.0", +] +locale = [ + "babel==2.18.0", +] +test-c = [ + "gcovr==8.6", +] +test-python = [ "pytest==9.0.3", "pytest-cov==7.1.0", ] diff --git a/scripts/localize.py b/scripts/localize.py new file mode 100644 index 0000000..b36df45 --- /dev/null +++ b/scripts/localize.py @@ -0,0 +1,627 @@ +"""Locale maintenance helpers for gettext and Babel workflows.""" + +# standard imports +import argparse +import datetime +import json +import os +import subprocess +import urllib.request +from dataclasses import dataclass + +LANGUAGES_URL = ( + 'https://raw.githubusercontent.com/LizardByte/i18n/refs/heads/dist/' + '458f881791aebba1d4dde491bw4/languages.json' +) +DEFAULT_EXTENSIONS = [ + 'c', + 'cc', + 'cpp', + 'cxx', + 'h', + 'hh', + 'hpp', + 'hxx', + 'm', + 'mm', +] +DEFAULT_KEYWORDS = [ + 'translate:1,1t', + 'translate:1c,2,2t', + 'translate:1,2,3t', + 'translate:1c,2,3,4t', + 'gettext:1', + 'pgettext:1c,2', + 'ngettext:1,2', + 'npgettext:1c,2,3', +] +DEFAULT_SOURCE_DIRECTORIES = [ + 'src', +] + + +@dataclass +class LocaleContext: + """Resolved locale maintenance settings. + + Attributes + ---------- + root_dir : str + Repository root directory. + locale_dir : str + Directory containing gettext and Babel locale files. + source_directories : list[str] + Source directories to scan for translatable strings. + extensions : list[str] + File extensions to include during source scanning. + keywords : list[str] + xgettext keyword expressions to extract. + project_name : str + Project or package name used in generated metadata. + project_owner : str + Project owner used in generated metadata. + domain : str + gettext domain name. + bugs_address : str + Address recorded in generated files for translation bugs. + language_source_url : str + URL to the shared languages metadata file. + target_locales : list[str] + Locale codes to initialize. + """ + + root_dir: str + locale_dir: str + source_directories: list[str] + extensions: list[str] + keywords: list[str] + project_name: str + project_owner: str + domain: str + bugs_address: str + language_source_url: str + target_locales: list[str] + + +def split_values(values: list[str] | None) -> list[str]: + """Split comma-separated CLI values into a flat list. + + Parameters + ---------- + values : list[str] | None + Values collected by ``argparse`` from repeatable options. + + Returns + ------- + list[str] + Non-empty, stripped values in input order. + """ + + if values is None: + return [] + + split_items = [] + for value in values: + for item in value.split(','): + item = item.strip() + if item: + split_items.append(item) + + return split_items + + +def resolve_path(root_dir: str, path: str) -> str: + """Resolve a path relative to a repository root. + + Parameters + ---------- + root_dir : str + Repository root directory. + path : str + Absolute path or path relative to ``root_dir``. + + Returns + ------- + str + Absolute path. + """ + + if os.path.isabs(path): + return os.path.abspath(path) + + return os.path.abspath(os.path.join(root_dir, path)) + + +def build_arg_parser() -> argparse.ArgumentParser: + """Build the locale helper argument parser. + + Returns + ------- + argparse.ArgumentParser + Parser configured with locale maintenance actions and options. + """ + + parser = argparse.ArgumentParser( + description='Update gettext and Babel locale files for a repository.', + ) + + parser.add_argument('--extract', action='store_true', help='Extract messages from source files.') + parser.add_argument('--init', action='store_true', help='Initialize missing locale directories.') + parser.add_argument('--update', action='store_true', help='Update existing locales.') + parser.add_argument('--compile', action='store_true', help='Compile translated locales.') + parser.add_argument( + '--root-dir', + help='Repository root. Defaults to GITHUB_WORKSPACE or the parent directory of this script.', + ) + parser.add_argument('--locale-dir', default='locale', help='Locale directory, relative to root unless absolute.') + parser.add_argument( + '--source-dir', + action='append', + help='Source directory to scan. May be repeated or comma-separated. Defaults to src.', + ) + parser.add_argument( + '--locale', + action='append', + help='Locale to initialize. May be repeated or comma-separated.', + ) + parser.add_argument( + '--extension', + action='append', + help='Source file extension to scan. May be repeated or comma-separated.', + ) + parser.add_argument( + '--keyword', + action='append', + help='xgettext keyword expression. May be repeated or comma-separated.', + ) + parser.add_argument('--project-name', help='Package/project name. Defaults to the GITHUB_REPOSITORY repo name.') + parser.add_argument('--project-owner', help='Project owner. Defaults to GITHUB_REPOSITORY_OWNER.') + parser.add_argument('--domain', help='Gettext domain. Defaults to the lower-case project name.') + parser.add_argument('--bugs-address', help='msgid bugs address. Defaults to GITHUB_SERVER_URL/GITHUB_REPOSITORY.') + parser.add_argument( + '--language-source-url', + default=LANGUAGES_URL, + help='Shared languages metadata URL used by --init when --locale is not provided.', + ) + + return parser + + +def build_context(args: argparse.Namespace) -> LocaleContext: + """Build a locale context from CLI arguments and GitHub runner variables. + + Parameters + ---------- + args : argparse.Namespace + Parsed command-line arguments. + + Returns + ------- + LocaleContext + Resolved locale settings. + """ + + root_dir = os.path.abspath( + args.root_dir + or os.environ.get('GITHUB_WORKSPACE') + or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + repository = os.environ.get('GITHUB_REPOSITORY', '') + repository_owner = repository.split('/', 1)[0] if '/' in repository else '' + repository_name = repository.rsplit('/', 1)[-1] if repository else os.path.basename(root_dir) + + project_name = args.project_name or repository_name + project_owner = args.project_owner or os.environ.get('GITHUB_REPOSITORY_OWNER') or repository_owner or project_name + domain = args.domain or project_name.lower() + server_url = os.environ.get('GITHUB_SERVER_URL', 'https://github.com').rstrip('/') + bugs_repository = repository or f'{project_owner}/{project_name}' + bugs_address = args.bugs_address or f'{server_url}/{bugs_repository}' + + return LocaleContext( + root_dir=root_dir, + locale_dir=resolve_path(root_dir=root_dir, path=args.locale_dir), + source_directories=split_values(args.source_dir) or DEFAULT_SOURCE_DIRECTORIES, + extensions=split_values(args.extension) or DEFAULT_EXTENSIONS, + keywords=split_values(args.keyword) or DEFAULT_KEYWORDS, + project_name=project_name, + project_owner=project_owner, + domain=domain, + bugs_address=bugs_address, + language_source_url=args.language_source_url, + target_locales=split_values(args.locale), + ) + + +def get_two_letter_code(language_code: str, language_info: dict[str, str]) -> str: + """Get the two-letter code for a language metadata entry. + + Parameters + ---------- + language_code : str + Language key from the shared languages metadata. + language_info : dict[str, str] + Language metadata entry. + + Returns + ------- + str + Two-letter language code. + """ + + return language_info.get('two_letters_code') or language_code.split('-', 1)[0] + + +def get_locale_code(language_code: str, language_info: dict[str, str]) -> str: + """Get the locale code for a language metadata entry. + + Parameters + ---------- + language_code : str + Language key from the shared languages metadata. + language_info : dict[str, str] + Language metadata entry. + + Returns + ------- + str + Locale code with underscores. + """ + + return language_info.get('locale_with_underscore') or language_code.replace('-', '_') + + +def select_default_language_index(language_entries: list[tuple[str, dict[str, str]]]) -> int | None: + """Select the default variant for a two-letter language group. + + Parameters + ---------- + language_entries : list[tuple[str, dict[str, str]]] + Language metadata entries that share a two-letter code. + + Returns + ------- + int | None + Index of the default entry. ``None`` means the metadata has no obvious + default variant and every entry should keep its regional code. + """ + + if len(language_entries) == 1: + return 0 + + base_indexes = [ + index + for index, (language_code, language_info) in enumerate(language_entries) + if get_locale_code(language_code=language_code, language_info=language_info) == ( + get_two_letter_code(language_code=language_code, language_info=language_info) + ) + ] + if base_indexes: + return base_indexes[0] + + generic_indexes = [ + index + for index, (language_code, language_info) in enumerate(language_entries) + if ',' not in language_info.get('name', '') + ] + if len(generic_indexes) == 1: + return generic_indexes[0] + if not generic_indexes: + return None + + return generic_indexes[0] + + +def parse_target_locales(language_data: dict[str, dict[str, str]]) -> list[str]: + """Parse target locale codes from shared language metadata. + + Parameters + ---------- + language_data : dict[str, dict[str, str]] + Shared language metadata keyed by Crowdin language code. + + Returns + ------- + list[str] + Sorted locale codes parsed from shared language metadata. + """ + + language_groups = {} + for language_code, language_info in language_data.items(): + two_letter_code = get_two_letter_code(language_code=language_code, language_info=language_info) + language_groups.setdefault(two_letter_code, []).append((language_code, language_info)) + + discovered_locales = set() + for two_letter_code, language_entries in language_groups.items(): + default_index = select_default_language_index(language_entries=language_entries) + discovered_locales.add(two_letter_code) + for index, (language_code, language_info) in enumerate(language_entries): + if index != default_index and len(language_entries) > 1: + discovered_locales.add(get_locale_code(language_code=language_code, language_info=language_info)) + + return sorted(discovered_locales) + + +def load_target_locales(language_source_url: str) -> list[str]: + """Load target locales from shared language metadata. + + Parameters + ---------- + language_source_url : str + URL to the shared languages metadata file. + + Returns + ------- + list[str] + Target locale codes parsed from shared language metadata. + """ + + with urllib.request.urlopen(language_source_url, timeout=30) as response: + language_data = json.load(response) + + return parse_target_locales(language_data=language_data) + + +def discover_locale_codes(locale_dir: str) -> list[str]: + """Discover existing locale directory names. + + Parameters + ---------- + locale_dir : str + Directory containing locale subdirectories. + + Returns + ------- + list[str] + Sorted locale directory names. + """ + + if not os.path.isdir(locale_dir): + return [] + + return sorted( + name + for name in os.listdir(locale_dir) + if os.path.isdir(os.path.join(locale_dir, name)) + ) + + +def collect_source_files(root_dir: str, source_directories: list[str], extensions: list[str]) -> list[str]: + """Collect source files that should be scanned by xgettext. + + Parameters + ---------- + root_dir : str + Repository root directory. + source_directories : list[str] + Directories to scan, relative to ``root_dir`` unless absolute. + extensions : list[str] + File extensions to include. Leading dots are optional. + + Returns + ------- + list[str] + Sorted source file paths relative to ``root_dir``. + """ + + extension_set = {extension.lstrip('.').lower() for extension in extensions} + source_files = [] + + for source_directory in source_directories: + scan_dir = resolve_path(root_dir=root_dir, path=source_directory) + if not os.path.isdir(scan_dir): + continue + + for current_root, dirs, files in os.walk(scan_dir): + dirs.sort() + for filename in sorted(files): + extension = filename.rsplit('.', 1)[-1].lower() + if extension in extension_set: + file_path = os.path.join(current_root, filename) + source_files.append(os.path.relpath(file_path, root_dir)) + + return sorted(source_files) + + +def run_command(command: list[str], root_dir: str): + """Run a locale maintenance command from the repository root. + + Parameters + ---------- + command : list[str] + Command and arguments to execute. + root_dir : str + Working directory for the command. + """ + + print(command) + subprocess.check_output(args=command, cwd=root_dir) + + +def rewrite_pot_header(context: LocaleContext, pot_filepath: str): + """Rewrite the generated gettext template header. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + pot_filepath : str + Path to the generated gettext template. + """ + + year = str(datetime.datetime.now().year) + body = '' + + with open(file=pot_filepath, mode='r', encoding='utf-8') as file: + for line in file.readlines(): + if line == '"Language: \\n"\n': + continue + + if line == '# SOME DESCRIPTIVE TITLE.\n': + body += f'# Translations template for {context.project_name}.\n' + elif line.startswith('#') and ('YEAR' in line or 'PACKAGE' in line): + body += line.replace('YEAR', year).replace('PACKAGE', context.project_name) + else: + body += line + + with open(file=pot_filepath, mode='w', encoding='utf-8') as file: + file.write(body) + + +def x_extract(context: LocaleContext): + """Extract gettext messages from configured source files. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + + Raises + ------ + RuntimeError + Raised when no source files match the configured source directories + and extensions. + """ + + pot_filepath = os.path.join(context.locale_dir, f'{context.domain}.po') + source_files = collect_source_files( + root_dir=context.root_dir, + source_directories=context.source_directories, + extensions=context.extensions, + ) + if not source_files: + raise RuntimeError('No source files found for locale extraction.') + + os.makedirs(context.locale_dir, exist_ok=True) + command = [ + 'xgettext', + *[f'--keyword={keyword}' for keyword in context.keywords], + f'--default-domain={context.domain}', + f'--output={pot_filepath}', + '--language=C++', + '--boost', + '--from-code=utf-8', + '-F', + f'--msgid-bugs-address={context.bugs_address}', + f'--copyright-holder={context.project_owner}', + f'--package-name={context.project_name}', + '--package-version=v0', + *source_files, + ] + + run_command(command=command, root_dir=context.root_dir) + rewrite_pot_header(context=context, pot_filepath=pot_filepath) + + +def babel_init(context: LocaleContext, locale_code: str): + """Initialize a locale with pybabel. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + locale_code : str + Locale code to initialize. + """ + + command = [ + 'pybabel', + 'init', + '-i', + os.path.join(context.locale_dir, f'{context.domain}.po'), + '-d', + context.locale_dir, + '-D', + context.domain, + '-l', + locale_code, + ] + run_command(command=command, root_dir=context.root_dir) + + +def babel_update(context: LocaleContext): + """Update existing locales with pybabel. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + """ + + command = [ + 'pybabel', + 'update', + '-i', + os.path.join(context.locale_dir, f'{context.domain}.po'), + '-d', + context.locale_dir, + '-D', + context.domain, + '--update-header-comment', + ] + run_command(command=command, root_dir=context.root_dir) + + +def babel_compile(context: LocaleContext): + """Compile translated locales with pybabel. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + """ + + command = [ + 'pybabel', + 'compile', + '-d', + context.locale_dir, + '-D', + context.domain, + ] + run_command(command=command, root_dir=context.root_dir) + + +def init_missing_locales(context: LocaleContext): + """Initialize configured locales that do not already exist. + + Parameters + ---------- + context : LocaleContext + Resolved locale settings. + """ + + locales = context.target_locales or load_target_locales(language_source_url=context.language_source_url) + for locale_code in locales: + if not os.path.isdir(os.path.join(context.locale_dir, locale_code)): + babel_init(context=context, locale_code=locale_code) + + +def main(argv: list[str] | None = None): # pragma: no cover + """Run locale maintenance actions from the command line. + + Parameters + ---------- + argv : list[str] | None, optional + Command-line arguments. When omitted, ``argparse`` reads from + ``sys.argv``. + """ + + parser = build_arg_parser() + args = parser.parse_args(argv) + + if not any([args.extract, args.init, args.update, args.compile]): + parser.error('Specify at least one action: --extract, --init, --update, or --compile.') + + context = build_context(args=args) + if args.extract: + x_extract(context=context) + if args.init: + init_missing_locales(context=context) + if args.update: + babel_update(context=context) + if args.compile: + babel_compile(context=context) + + +if __name__ == '__main__': + main() diff --git a/scripts/update_clang_format.py b/scripts/update_clang_format.py index ed0d504..676369b 100644 --- a/scripts/update_clang_format.py +++ b/scripts/update_clang_format.py @@ -1,3 +1,5 @@ +"""Run clang-format across shared C and C++ source directories.""" + # standard imports import os import subprocess @@ -20,14 +22,26 @@ def clang_format(file: str): + """Run clang-format on a source file. + + Parameters + ---------- + file : str + Source file path to format. + """ + print(f'Formatting {file} ...') subprocess.run(['clang-format', '-i', file], check=True) def main(): + """Format supported source files in configured directories. + + Notes + ----- + Missing configured directories are ignored by ``os.walk``. """ - Main entry point. - """ + # walk the directories for directory in directories: for root, dirs, files in os.walk(directory): diff --git a/tests/unit/test_localize.py b/tests/unit/test_localize.py new file mode 100644 index 0000000..514f2a9 --- /dev/null +++ b/tests/unit/test_localize.py @@ -0,0 +1,548 @@ +"""Tests for the shared locale maintenance helper.""" + +# standard imports +import datetime +import os + +# local imports +import scripts.localize as localize + + +def parse_args(*args): + """Parse locale helper arguments for tests. + + Parameters + ---------- + *args : str + Command-line arguments to parse. + + Returns + ------- + argparse.Namespace + Parsed arguments. + """ + + return localize.build_arg_parser().parse_args(args) + + +def test_build_context_uses_github_defaults(monkeypatch, tmp_path): + """Verify context defaults come from GitHub runner environment variables.""" + + monkeypatch.setenv('GITHUB_WORKSPACE', str(tmp_path)) + monkeypatch.setenv('GITHUB_REPOSITORY', 'LizardByte/Example-Repo') + monkeypatch.setenv('GITHUB_REPOSITORY_OWNER', 'LizardByte') + monkeypatch.setenv('GITHUB_SERVER_URL', 'https://github.example') + monkeypatch.setenv('GITHUB_REF_NAME', 'master') + + context = localize.build_context(args=parse_args()) + + assert context.root_dir == str(tmp_path) + assert context.locale_dir == os.path.join(str(tmp_path), 'locale') + assert context.source_directories == ['src'] + assert context.extensions == localize.DEFAULT_EXTENSIONS + assert context.keywords == localize.DEFAULT_KEYWORDS + assert context.project_name == 'Example-Repo' + assert context.project_owner == 'LizardByte' + assert context.domain == 'example-repo' + assert context.bugs_address == 'https://github.example/LizardByte/Example-Repo' + assert context.language_source_url == localize.LANGUAGES_URL + assert context.target_locales == [] + + +def test_build_context_allows_cli_overrides(monkeypatch, tmp_path): + """Verify CLI options override GitHub and fallback defaults.""" + + monkeypatch.delenv('GITHUB_WORKSPACE', raising=False) + monkeypatch.delenv('GITHUB_REPOSITORY', raising=False) + monkeypatch.delenv('GITHUB_REPOSITORY_OWNER', raising=False) + monkeypatch.delenv('GITHUB_REF_NAME', raising=False) + args = parse_args( + '--root-dir', + str(tmp_path), + '--locale-dir', + 'i18n', + '--source-dir', + 'src,lib', + '--source-dir', + 'tests', + '--locale', + 'en,de', + '--locale', + 'fr', + '--extension', + '.cpp,hpp', + '--keyword', + 'gettext:1', + '--project-name', + 'Shared', + '--project-owner', + 'Example', + '--domain', + 'shared-domain', + '--bugs-address', + 'https://bugs.example/shared', + '--language-source-url', + 'https://example.invalid/languages.json', + ) + + context = localize.build_context(args=args) + + assert context.root_dir == str(tmp_path) + assert context.locale_dir == os.path.join(str(tmp_path), 'i18n') + assert context.source_directories == ['src', 'lib', 'tests'] + assert context.target_locales == ['en', 'de', 'fr'] + assert context.extensions == ['.cpp', 'hpp'] + assert context.keywords == ['gettext:1'] + assert context.project_name == 'Shared' + assert context.project_owner == 'Example' + assert context.domain == 'shared-domain' + assert context.bugs_address == 'https://bugs.example/shared' + assert context.language_source_url == 'https://example.invalid/languages.json' + + +def test_parse_target_locales_normalizes_i18n_metadata(): + """Verify shared language metadata maps to Sunshine-style locale codes.""" + + language_data = { + 'bg': { + 'locale_with_underscore': 'bg_BG', + 'two_letters_code': 'bg', + }, + 'cs': { + 'locale_with_underscore': 'cs_CZ', + 'two_letters_code': 'cs', + }, + 'de': { + 'locale_with_underscore': 'de_DE', + 'two_letters_code': 'de', + }, + 'en-GB': { + 'name': 'English, United Kingdom', + 'locale_with_underscore': 'en_GB', + 'two_letters_code': 'en', + }, + 'en-US': { + 'name': 'English, United States', + 'locale_with_underscore': 'en_US', + 'two_letters_code': 'en', + }, + 'es-ES': { + 'locale_with_underscore': 'es_ES', + 'two_letters_code': 'es', + }, + 'fr': { + 'locale_with_underscore': 'fr_FR', + 'two_letters_code': 'fr', + }, + 'hu': { + 'locale_with_underscore': 'hu_HU', + 'two_letters_code': 'hu', + }, + 'it': { + 'locale_with_underscore': 'it_IT', + 'two_letters_code': 'it', + }, + 'ja': { + 'locale_with_underscore': 'ja_JP', + 'two_letters_code': 'ja', + }, + 'ko': { + 'locale_with_underscore': 'ko_KR', + 'two_letters_code': 'ko', + }, + 'pl': { + 'locale_with_underscore': 'pl_PL', + 'two_letters_code': 'pl', + }, + 'pt-BR': { + 'name': 'Portuguese, Brazilian', + 'locale_with_underscore': 'pt_BR', + 'two_letters_code': 'pt', + }, + 'pt-PT': { + 'name': 'Portuguese', + 'locale_with_underscore': 'pt_PT', + 'two_letters_code': 'pt', + }, + 'ru': { + 'locale_with_underscore': 'ru_RU', + 'two_letters_code': 'ru', + }, + 'sv-SE': { + 'locale_with_underscore': 'sv_SE', + 'two_letters_code': 'sv', + }, + 'tr': { + 'locale_with_underscore': 'tr_TR', + 'two_letters_code': 'tr', + }, + 'uk': { + 'locale_with_underscore': 'uk_UA', + 'two_letters_code': 'uk', + }, + 'vi': { + 'locale_with_underscore': 'vi_VN', + 'two_letters_code': 'vi', + }, + 'zh-CN': { + 'name': 'Chinese Simplified', + 'locale_with_underscore': 'zh_CN', + 'two_letters_code': 'zh', + }, + 'zh-TW': { + 'name': 'Chinese Traditional', + 'locale_with_underscore': 'zh_TW', + 'two_letters_code': 'zh', + }, + } + + assert localize.parse_target_locales(language_data=language_data) == [ + 'bg', + 'cs', + 'de', + 'en', + 'en_GB', + 'en_US', + 'es', + 'fr', + 'hu', + 'it', + 'ja', + 'ko', + 'pl', + 'pt', + 'pt_BR', + 'ru', + 'sv', + 'tr', + 'uk', + 'vi', + 'zh', + 'zh_TW', + ] + + +def test_parse_target_locales_handles_missing_optional_metadata(): + """Verify language codes fall back to source keys when metadata is sparse.""" + + language_data = { + 'aa-AA': { + 'name': 'Afar, Regional', + }, + 'aa-BB': { + 'name': 'Afar, Alternate', + }, + 'bb': { + 'locale_with_underscore': 'bb', + 'two_letters_code': 'bb', + }, + 'bb-BB': { + 'locale_with_underscore': 'bb_BB', + 'two_letters_code': 'bb', + }, + } + + assert localize.parse_target_locales(language_data=language_data) == [ + 'aa', + 'aa_AA', + 'aa_BB', + 'bb', + 'bb_BB', + ] + + +def test_load_target_locales_uses_shared_language_metadata(monkeypatch): + """Verify target locales load from the shared languages URL.""" + + class FakeResponse: + """Fake urlopen response for language metadata.""" + + def __enter__(self): + """Enter the context manager. + + Returns + ------- + FakeResponse + Active fake response. + """ + + return self + + def __exit__(self, exc_type, exc_value, traceback): + """Exit the context manager.""" + + def read(self): + """Read fake JSON response bytes. + + Returns + ------- + bytes + Encoded language metadata. + """ + + return ( + b'{"pt-PT": {"name": "Portuguese", "locale_with_underscore": "pt_PT", ' + b'"two_letters_code": "pt"}, "pt-BR": {"name": "Portuguese, Brazilian", ' + b'"locale_with_underscore": "pt_BR", "two_letters_code": "pt"}}' + ) + + def fake_urlopen(url, timeout): + """Return fake shared language metadata.""" + + assert url == 'https://example.invalid/languages.json' + assert timeout == 30 + return FakeResponse() + + monkeypatch.setattr(localize.urllib.request, 'urlopen', fake_urlopen) + + assert localize.load_target_locales( + language_source_url='https://example.invalid/languages.json', + ) == ['pt', 'pt_BR'] + + +def test_load_target_locales_propagates_source_errors(monkeypatch): + """Verify failed language metadata loads raise the source error.""" + + def fake_urlopen(url, timeout): + """Raise an error while loading language metadata.""" + + raise OSError('offline') + + monkeypatch.setattr(localize.urllib.request, 'urlopen', fake_urlopen) + + try: + localize.load_target_locales(language_source_url='https://example.invalid/languages.json') + except OSError as err: + assert str(err) == 'offline' + else: + raise AssertionError('Expected language metadata source errors to propagate') + + +def test_resolve_path_handles_absolute_and_relative_paths(tmp_path): + """Verify path resolution handles absolute and relative paths.""" + + assert localize.resolve_path(root_dir=str(tmp_path), path='locale') == os.path.join(str(tmp_path), 'locale') + assert localize.resolve_path(root_dir='ignored', path=str(tmp_path)) == str(tmp_path) + + +def test_discover_locale_codes_returns_sorted_directories(tmp_path): + """Verify locale discovery returns sorted directory names only.""" + + os.makedirs(os.path.join(str(tmp_path), 'fr')) + os.makedirs(os.path.join(str(tmp_path), 'en')) + with open(os.path.join(str(tmp_path), 'README.md'), mode='w', encoding='utf-8') as file: + file.write('not a locale directory') + + assert localize.discover_locale_codes(locale_dir=str(tmp_path)) == ['en', 'fr'] + assert localize.discover_locale_codes(locale_dir=os.path.join(str(tmp_path), 'missing')) == [] + + +def test_collect_source_files_scans_existing_source_directories(tmp_path): + """Verify source discovery filters files by configured roots and extensions.""" + + root_dir = str(tmp_path) + files = [ + os.path.join(root_dir, 'src', 'main.cpp'), + os.path.join(root_dir, 'src', 'include', 'app.hpp'), + os.path.join(root_dir, 'lib', 'ignored.cpp'), + os.path.join(root_dir, 'src', 'notes.txt'), + ] + for file in files: + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, mode='w', encoding='utf-8') as file_handle: + file_handle.write('// test\n') + + assert localize.collect_source_files( + root_dir=root_dir, + source_directories=['src', 'missing'], + extensions=['cpp', '.hpp'], + ) == [ + os.path.join('src', 'include', 'app.hpp'), + os.path.join('src', 'main.cpp'), + ] + + +def test_x_extract_builds_command_and_rewrites_header(monkeypatch, tmp_path): + """Verify xgettext extraction builds command arguments and rewrites headers.""" + + monkeypatch.delenv('GITHUB_REPOSITORY', raising=False) + monkeypatch.delenv('GITHUB_REPOSITORY_OWNER', raising=False) + monkeypatch.delenv('GITHUB_SERVER_URL', raising=False) + + root_dir = str(tmp_path) + os.makedirs(os.path.join(root_dir, 'src', 'nested')) + with open(os.path.join(root_dir, 'src', 'main.cpp'), mode='w', encoding='utf-8') as file: + file.write('translate("Hello")\n') + with open(os.path.join(root_dir, 'src', 'nested', 'helper.hpp'), mode='w', encoding='utf-8') as file: + file.write('gettext("World")\n') + + context = localize.build_context(args=parse_args('--root-dir', root_dir, '--project-name', 'Example')) + calls = [] + + def fake_check_output(args, cwd): + """Record xgettext calls and write a temporary template.""" + + calls.append({ + 'args': args, + 'cwd': cwd, + }) + with open(os.path.join(context.locale_dir, 'example.po'), mode='w', encoding='utf-8') as file: + file.write('# SOME DESCRIPTIVE TITLE.\n') + file.write('# Copyright (C) YEAR PACKAGE\n') + file.write('"Language: \\n"\n') + file.write('msgid ""\n') + + monkeypatch.setattr(localize.subprocess, 'check_output', fake_check_output) + + localize.x_extract(context=context) + + current_year = str(datetime.datetime.now().year) + assert calls == [ + { + 'args': [ + 'xgettext', + *[f'--keyword={keyword}' for keyword in localize.DEFAULT_KEYWORDS], + '--default-domain=example', + f'--output={os.path.join(context.locale_dir, "example.po")}', + '--language=C++', + '--boost', + '--from-code=utf-8', + '-F', + '--msgid-bugs-address=https://github.com/Example/Example', + '--copyright-holder=Example', + '--package-name=Example', + '--package-version=v0', + os.path.join('src', 'main.cpp'), + os.path.join('src', 'nested', 'helper.hpp'), + ], + 'cwd': root_dir, + }, + ] + with open(os.path.join(context.locale_dir, 'example.po'), mode='r', encoding='utf-8') as file: + assert file.read() == ( + '# Translations template for Example.\n' + f'# Copyright (C) {current_year} Example\n' + 'msgid ""\n' + ) + + +def test_x_extract_requires_source_files(tmp_path): + """Verify extraction fails clearly when no source files are found.""" + + context = localize.build_context(args=parse_args('--root-dir', str(tmp_path))) + + try: + localize.x_extract(context=context) + except RuntimeError as err: + assert str(err) == 'No source files found for locale extraction.' + else: + raise AssertionError('Expected locale extraction to require source files') + + +def test_babel_commands(monkeypatch, tmp_path): + """Verify pybabel commands use the resolved locale context.""" + + context = localize.build_context(args=parse_args('--root-dir', str(tmp_path), '--project-name', 'Example')) + calls = [] + + def fake_check_output(args, cwd): + """Record pybabel calls.""" + + calls.append({ + 'args': args, + 'cwd': cwd, + }) + + monkeypatch.setattr(localize.subprocess, 'check_output', fake_check_output) + + localize.babel_init(context=context, locale_code='fr') + localize.babel_update(context=context) + localize.babel_compile(context=context) + + assert calls == [ + { + 'args': [ + 'pybabel', + 'init', + '-i', + os.path.join(context.locale_dir, 'example.po'), + '-d', + context.locale_dir, + '-D', + 'example', + '-l', + 'fr', + ], + 'cwd': str(tmp_path), + }, + { + 'args': [ + 'pybabel', + 'update', + '-i', + os.path.join(context.locale_dir, 'example.po'), + '-d', + context.locale_dir, + '-D', + 'example', + '--update-header-comment', + ], + 'cwd': str(tmp_path), + }, + { + 'args': [ + 'pybabel', + 'compile', + '-d', + context.locale_dir, + '-D', + 'example', + ], + 'cwd': str(tmp_path), + }, + ] + + +def test_init_missing_locales_only_initializes_missing_targets(monkeypatch, tmp_path): + """Verify locale initialization skips existing targets.""" + + context = localize.build_context(args=parse_args('--root-dir', str(tmp_path), '--locale', 'en,fr')) + os.makedirs(os.path.join(context.locale_dir, 'en')) + initialized_locales = [] + + def fake_babel_init(context, locale_code): + """Record initialized locale codes.""" + + initialized_locales.append(locale_code) + + monkeypatch.setattr(localize, 'babel_init', fake_babel_init) + + localize.init_missing_locales(context=context) + + assert initialized_locales == ['fr'] + + +def test_init_missing_locales_loads_shared_targets(monkeypatch, tmp_path): + """Verify locale initialization loads shared targets when none are explicit.""" + + context = localize.build_context(args=parse_args('--root-dir', str(tmp_path))) + os.makedirs(os.path.join(context.locale_dir, 'en')) + initialized_locales = [] + + def fake_load_target_locales(language_source_url): + """Return fake shared target locales.""" + + assert language_source_url == localize.LANGUAGES_URL + return ['en', 'vi'] + + def fake_babel_init(context, locale_code): + """Record initialized locale codes.""" + + initialized_locales.append(locale_code) + + monkeypatch.setattr(localize, 'load_target_locales', fake_load_target_locales) + monkeypatch.setattr(localize, 'babel_init', fake_babel_init) + + localize.init_missing_locales(context=context) + + assert initialized_locales == ['vi'] diff --git a/tests/unit/test_update_clang_format.py b/tests/unit/test_update_clang_format.py index 75de8cd..b73f4ed 100644 --- a/tests/unit/test_update_clang_format.py +++ b/tests/unit/test_update_clang_format.py @@ -1,3 +1,5 @@ +"""Tests for the clang-format helper.""" + # standard imports import os @@ -6,6 +8,8 @@ def test_directories_include_expected_roots(): + """Verify the formatter scans the expected shared source roots.""" + assert update_clang_format.directories == [ 'src', 'tests', @@ -14,6 +18,8 @@ def test_directories_include_expected_roots(): def test_file_types_include_shared_extensions(): + """Verify the formatter includes shared C and C++ extensions.""" + assert update_clang_format.file_types == [ 'c', 'cpp', @@ -26,9 +32,13 @@ def test_file_types_include_shared_extensions(): def test_clang_format_invokes_clang_format(capsys, monkeypatch): + """Verify the formatter delegates to clang-format with the target file.""" + calls = [] def fake_run(command, check): + """Record clang-format subprocess calls.""" + calls.append({ 'check': check, 'command': command, @@ -48,6 +58,8 @@ def fake_run(command, check): def test_main_formats_supported_files_only(monkeypatch, tmp_path): + """Verify the main scan formats supported files only.""" + tmp_root = str(tmp_path) files = [ os.path.join(tmp_root, 'src', 'main.cpp'), @@ -65,6 +77,8 @@ def test_main_formats_supported_files_only(monkeypatch, tmp_path): formatted_files = [] def fake_clang_format(file): + """Record files selected for formatting.""" + formatted_files.append(file.replace(os.sep, '/')) monkeypatch.chdir(tmp_root) diff --git a/uv.lock b/uv.lock index 10a8898..8355dca 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "clang-format" version = "21.1.8" @@ -27,6 +36,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/d3/8790b539afb6cb0d4474705d3750e78a1f0847ab6d3ef1b00dd1ae52f364/clang_format-21.1.8-py2.py3-none-win_arm64.whl", hash = "sha256:1aa10b3f647268361d08bf4f17ce70964b8d9c04d5539e7d8acbebd14dc4a49c", size = 1327254, upload-time = "2025-12-16T20:34:31.579Z" }, ] +[[package]] +name = "cmakelang" +version = "0.6.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/c0/75d4806cf21dcb4198e9fba02f4d2fa61c8db919b7db788862d9cd5f4433/cmakelang-0.6.13.tar.gz", hash = "sha256:03982e87b00654d024d73ef972d9d9bb0e5726cdb6b8a424a15661fb6278e67f", size = 123111, upload-time = "2020-08-19T17:15:25.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/a8/c4676cac062d133c6b909d7def80a3194162597968953a3291b309878721/cmakelang-0.6.13-py3-none-any.whl", hash = "sha256:764b9467195c7c36453d60a829f30229720d26c7dffd41cb516b99bd9c7daf4e", size = 159803, upload-time = "2020-08-19T17:15:23.981Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -36,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + [[package]] name = "coverage" version = "7.14.1" @@ -75,6 +108,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "gcovr" +version = "8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorlog" }, + { name = "jinja2" }, + { name = "lxml" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/37/b4a87dff166dc0a5002e9d03fcb6ca8eeff048247b011b67f047e31122c9/gcovr-8.6.tar.gz", hash = "sha256:b2e7042abca9321cadbab8a06eb34d19f801b831557b28cdc30a029313de8b9e", size = 199997, upload-time = "2026-01-13T20:04:30.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/be/f722c843e7875c7cf92cf0e0c1604cddda55a70278c768c6327a78fdba79/gcovr-8.6-py3-none-any.whl", hash = "sha256:dbf9d87c38042752ad6f530aa8210427e22b526611bb7b7bfed0e81977d1f1ef", size = 254618, upload-time = "2026-01-13T20:04:28.15Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -84,21 +146,53 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "lizardbyte-common" version = "0.0.0" source = { editable = "." } [package.dev-dependencies] +c = [ + { name = "babel" }, + { name = "clang-format" }, + { name = "cmakelang" }, + { name = "gcovr" }, +] dev = [ + { name = "babel" }, { name = "clang-format" }, + { name = "cmakelang" }, + { name = "flake8" }, + { name = "gcovr" }, { name = "pytest" }, { name = "pytest-cov" }, ] -lint = [ +lint-c = [ { name = "clang-format" }, + { name = "cmakelang" }, +] +lint-python = [ + { name = "flake8" }, ] -test = [ +locale = [ + { name = "babel" }, +] +test-c = [ + { name = "gcovr" }, +] +test-python = [ { name = "pytest" }, { name = "pytest-cov" }, ] @@ -106,17 +200,116 @@ test = [ [package.metadata] [package.metadata.requires-dev] +c = [ + { name = "babel", specifier = "==2.18.0" }, + { name = "clang-format", specifier = "==21.1.8" }, + { name = "cmakelang", specifier = "==0.6.13" }, + { name = "gcovr", specifier = "==8.6" }, +] dev = [ - { name = "clang-format", specifier = "==21.*" }, + { name = "babel", specifier = "==2.18.0" }, + { name = "clang-format", specifier = "==21.1.8" }, + { name = "cmakelang", specifier = "==0.6.13" }, + { name = "flake8", specifier = "==7.3.0" }, + { name = "gcovr", specifier = "==8.6" }, { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-cov", specifier = "==7.1.0" }, ] -lint = [{ name = "clang-format", specifier = "==21.*" }] -test = [ +lint-c = [ + { name = "clang-format", specifier = "==21.1.8" }, + { name = "cmakelang", specifier = "==0.6.13" }, +] +lint-python = [{ name = "flake8", specifier = "==7.3.0" }] +locale = [{ name = "babel", specifier = "==2.18.0" }] +test-c = [{ name = "gcovr", specifier = "==8.6" }] +test-python = [ { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-cov", specifier = "==7.1.0" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -135,6 +328,24 @@ 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 = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -173,3 +384,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044 wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +]