pax_global_header00006660000000000000000000000064152052243150014511gustar00rootroot0000000000000052 comment=2a56e6bdc835f0c8c3bd1b8e98bfa30329114e53 ruyisdk-ruyi-1f00e2e/000077500000000000000000000000001520522431500145755ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/.editorconfig000066400000000000000000000003741520522431500172560ustar00rootroot00000000000000# EditorConfig is awesome: https://EditorConfig.org root = true [*] charset = utf-8 end_of_line = lf indent_style = space insert_final_newline = true [*.{md,py,sh}] indent_size = 4 [Dockerfile*] indent_size = 4 [*.{toml,yml,yaml}] indent_size = 2 ruyisdk-ruyi-1f00e2e/.github/000077500000000000000000000000001520522431500161355ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/.github/workflows/000077500000000000000000000000001520522431500201725ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/.github/workflows/auto-tag.yml000066400000000000000000000033331520522431500224400ustar00rootroot00000000000000name: Auto-tag releases on: push: paths: - pyproject.toml jobs: auto-tag: runs-on: ubuntu-latest steps: - name: Skip for tags if: ${{ startsWith(github.ref, 'refs/tags/') }} run: exit 0 - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - uses: actions/checkout@v6 if: ${{ !startsWith(github.ref, 'refs/tags/') }} with: ref: ${{ github.head_ref }} # for tag detection and auto-blaming to work fetch-depth: 0 # https://github.com/orgs/community/discussions/25617#discussioncomment-3248494 token: ${{ secrets.GHA_PAT_THIS_REPO_RW }} - name: Install Poetry if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: pipx install poetry # NOTE: the Poetry venv is created during this step - uses: actions/setup-python@v5 if: ${{ !startsWith(github.ref, 'refs/tags/') }} with: python-version-file: pyproject.toml cache: poetry - name: Install deps in the venv if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: poetry install --with=dev - name: Create the tag and push if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: | # Note: the following account information will not work on GHES git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # No signing secret key for this identity on runners export RUYI_NO_GPG_SIGN=x poetry run ./scripts/make-release-tag.py && git push --tags ruyisdk-ruyi-1f00e2e/.github/workflows/ci.yml000066400000000000000000000531351520522431500213170ustar00rootroot00000000000000name: CI on: push: paths: - pyproject.toml - poetry.lock - "**.py" - ".github/**" - "resources/**" - "scripts/**" tags: - "*" pull_request: paths: - pyproject.toml - poetry.lock - "**.py" - ".github/**" - "resources/**" - "scripts/**" merge_group: types: [checks_requested] concurrency: group: ${{ github.workflow }}-${{ github.ref }} # for each workflow & branch/PR/tag cancel-in-progress: true # Overall calling sequence: # # 1. Compliance checks # 2. Lints and tests # 3. Dist builds # 4. Release & PyPI publish (in case of a tag push) # # Stages 1 to 3 results are summarized by dedicated jobs respectively to # simplify dependency declaration for the later stages. # # PyPI builds are gated by lints passing, but are independent of dist builds # because the wheels are built differently from the dist artifacts. jobs: dco: name: DCO compliance runs-on: ubuntu-latest steps: # do not run on tags, because our tag description is just "Ruyi 0.x.y" # which causes this check to fail (and possibly among other reasons) - name: Skip for tags if: ${{ startsWith(github.ref, 'refs/tags/') }} run: exit 0 - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block allowed-endpoints: > api.github.com:443 - name: Run dco-check if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: christophebedard/dco-check@0.5.0 with: python-version: "3.13" args: "--verbose" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pylic: name: license compatibility runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block allowed-endpoints: > files.pythonhosted.org:443 github.com:443 pypi.org:443 - uses: actions/checkout@v6 - name: Install Poetry run: pipx install poetry - uses: actions/setup-python@v5 with: python-version-file: "pyproject.toml" cache: poetry - name: Install runtime deps run: poetry install --only=main,dev - name: Install pylic run: poetry run pip install pylic - name: List all licenses involved run: poetry run pylic list - name: Check license compatibility with pylic run: poetry run pylic check --allow-extra-safe-licenses compliance-result: name: compliance check result runs-on: ubuntu-latest needs: - dco - pylic steps: - name: Summarize compliance check results run: | echo "DCO compliance check: ${{ needs.dco.result }}" echo "License compatibility check: ${{ needs.pylic.result }}" if [ "${{ needs.dco.result }}" != "success" ] || [ "${{ needs.pylic.result }}" != "success" ]; then echo "One or more compliance checks failed." exit 1 fi echo "All compliance checks passed." lint: name: "lint & typecheck & test (Python ${{ matrix.python }}${{ matrix.poetry == 1 && ', Poetry 1.8.2' || '' }}${{ matrix.baseline && ', baseline deps' || '' }}${{ matrix.experimental && ', experimental' || '' }})" runs-on: ${{ matrix.runs_on }} needs: - compliance-result continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: true matrix: python: - "3.11" - "3.12" - "3.13" - "3.14" experimental: [false] baseline: [false] runs_on: ["ubuntu-latest"] poetry: [2] include: # not yet available #- python: '3.15' # baseline: false # experimental: true # runs_on: ubuntu-latest - python: "3.11" baseline: true experimental: false runs_on: ubuntu-24.04 poetry: 2 # Poetry 1.x # run on baseline versions for mimicking the packaging environment - python: "3.11" experimental: false baseline: false runs_on: ubuntu-24.04 poetry: 1 steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block # Details about the allowlist, out-of-line because the action seems # to not support comments in the setting, and YAML not recognizing # comments inside the literal: # # * Environment setup # - azure.archive.ubuntu.com:80 # - esm.ubuntu.com:443 # - files.pythonhosted.org:443 # - github.com:443 # - packages.microsoft.com:443 # - pypi.org:443 # - release-assets.githubusercontent.com:443 # * Integration test # - api.ruyisdk.cn:443 # - fast-mirror.isrc.ac.cn:443 # - mirror.iscas.ac.cn:443 # - wps-linux-personal.wpscdn.cn:443 # - wrong_magic.git:443 allowed-endpoints: > azure.archive.ubuntu.com:80 esm.ubuntu.com:443 files.pythonhosted.org:443 github.com:443 packages.microsoft.com:443 pypi.org:443 release-assets.githubusercontent.com:443 api.ruyisdk.cn:443 fast-mirror.isrc.ac.cn:443 mirror.iscas.ac.cn:443 wps-linux-personal.wpscdn.cn:443 wrong_magic.git:443 - uses: actions/checkout@v6 with: submodules: ${{ matrix.baseline && 'false' || 'recursive' }} - name: Install integration test deps if: success() && !matrix.baseline run: | sudo apt-get update sudo apt-get install -y jq llvm-20-tools pipx schroot wget yq sudo apt-get clean sudo ln -s /usr/bin/FileCheck-20 /usr/local/bin/FileCheck export PIPX_BIN_DIR=/usr/local/bin sudo PIPX_BIN_DIR=/usr/local/bin pipx install lit - name: Install baseline deps system-wide if: success() && matrix.baseline run: ./scripts/install-baseline-deps.sh - name: Ensure version metadata consistency # Originally this was only checking the Poetry 1.x metadata, but now # the hard-coded version string in ruyi.version is checked as well. # This needs to be run only once so we still keep it in the Poetry 1.x # job -- if someday Poetry 1.x compatibility is to be removed, be sure # to retain the other check. if: success() && !matrix.baseline && matrix.poetry == 1 run: ./scripts/lint-version-metadata.py - name: Install Poetry if: success() && !matrix.baseline run: pipx install ${{ matrix.poetry == 2 && 'poetry' || '"poetry==1.8.2"' }} - name: Swap in Poetry 1.x metadata if: success() && !matrix.baseline && matrix.poetry == 1 run: cp contrib/poetry-1.x/pyproject.toml contrib/poetry-1.x/poetry.lock . # NOTE: the Poetry venv is created during this step - uses: actions/setup-python@v5 if: success() && !matrix.baseline with: python-version: ${{ matrix.python }} cache: poetry - name: Install deps in the venv if: success() && !matrix.baseline # Poetry 1.0.x does not support group deps run: poetry install ${{ matrix.poetry == 2 && '--with=dev' || '' }} # it seems hard for Poetry to only install the dev tools but not break # referencing system-wide deps, in addition to the trouble of # type-checking with ancient versions of deps that lack type # annotations, so just rely on the CI job running with non-baseline deps # for the various lints (but not tests). # # Also, due to lack of support for group deps, and the Poetry 1.x # metadata provided only for distro packaging, we have no dev deps # available if running with Poetry 1.x, so the remaining tests are all # gated on matrix.poetry == 2. - name: Lint with ruff if: success() && !matrix.baseline && matrix.poetry == 2 run: poetry run ruff check - name: Type-check with mypy if: success() && !matrix.baseline && matrix.poetry == 2 run: poetry run mypy - name: Type-check with pyright if: success() && !matrix.baseline && matrix.poetry == 2 run: poetry run -- pyright --pythonversion ${{ matrix.python }} - name: Test with pytest (in venv) if: success() && !matrix.baseline && matrix.poetry == 2 run: poetry run pytest - name: Test with pytest (system-wide) if: success() && matrix.baseline && matrix.poetry == 2 run: | pip install -e . if command -v pytest-3 > /dev/null; then # this is the case for Ubuntu python3-pytest pytest-3 elif command -v pytest > /dev/null; then # fallback pytest fi - name: Check for import side effects during CLI startup if: success() && !matrix.baseline && matrix.poetry == 2 run: poetry run ./scripts/lint-cli-startup-flow.py - name: Ensure bundled resources are synced with the codebase if: success() && !matrix.baseline && matrix.poetry == 2 run: ./scripts/lint-bundled-resources.sh - name: Run integration tests if: success() && !matrix.baseline && matrix.poetry == 2 run: | sed -i 's@pip install -i https://mirrors.bfsu.edu.cn/pypi/web/simple @pip install @' tests/ruyi-litester/scripts/ruyi/ruyi-src-install.bash sed -i 's@arpy certifi jinja2@arpy babel certifi jinja2@' tests/ruyi-litester/scripts/ruyi/ruyi-src-install.bash export PIPX_BIN_DIR=/usr/local/bin export RUYI_VERSION=0.47.0 ./tests/ruyi-litester/rit.bash -s --suites "$(pwd)/tests/rit-suites" ruyi-gha shellcheck: name: lint shell scripts runs-on: ubuntu-latest needs: - compliance-result steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block allowed-endpoints: > github.com:443 - uses: actions/checkout@v6 - name: Lint with shellcheck run: ./scripts/lint-shell-scripts.sh lints-result: name: lints result runs-on: ubuntu-latest needs: - lint - shellcheck steps: - name: Summarize lints results run: | echo "Lint & typecheck & test: ${{ needs.lint.result }}" echo "Shell script lint: ${{ needs.shellcheck.result }}" if [ "${{ needs.lint.result }}" != "success" ] || [ "${{ needs.shellcheck.result }}" != "success" ]; then echo "One or more lint jobs failed." exit 1 fi echo "All lint jobs passed." # https://stackoverflow.com/questions/65384420/how-do-i-make-a-github-action-matrix-element-conditional prepare_matrix_linux: name: "prepare matrix for dist builds (Linux)" runs-on: ubuntu-latest needs: - lints-result outputs: matrix: ${{ steps.gen_matrix.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block allowed-endpoints: > github.com:443 - uses: actions/checkout@v6 - name: Generate the matrix id: gen_matrix run: scripts/gen_matrix.py linux env: RUYI_PR_TITLE: ${{ github.event.pull_request.title }} prepare_matrix_windows: name: "prepare matrix for dist builds (Windows)" runs-on: ubuntu-latest needs: - lints-result outputs: matrix: ${{ steps.gen_matrix.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: block allowed-endpoints: > github.com:443 - uses: actions/checkout@v6 - name: Generate the matrix id: gen_matrix run: scripts/gen_matrix.py windows env: RUYI_PR_TITLE: ${{ github.event.pull_request.title }} dist: needs: - prepare_matrix_linux strategy: # arch: str # build_output_name: str # is_windows: bool # job_name: str # runs_on: RunsOn # skip: bool # upload_artifact_name: str # needs_qemu: bool matrix: ${{ fromJson(needs.prepare_matrix_linux.outputs.matrix) }} name: ${{ matrix.job_name }} runs-on: ${{ matrix.runs_on }} outputs: run_id: ${{ github.run_id }} release_mirror_url: ${{ steps.set_env.outputs.release_mirror_url }} steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit allowed-endpoints: > files.pythonhosted.org:443 ghcr.io:443 github.com:443 pkg-containers.githubusercontent.com:443 pypi.org:443 - uses: actions/checkout@v6 - name: Set up QEMU if: success() && !matrix.skip && matrix.needs_qemu uses: docker/setup-qemu-action@v3 - name: Cache deps and Nuitka output if: success() && !matrix.skip uses: actions/cache@v4 with: key: dist-${{ runner.os }}-${{ matrix.arch }}-r5-${{ hashFiles('poetry.lock') }} restore-keys: | dist-${{ runner.os }}-${{ matrix.arch }}-r5 ${{ runner.os }}-tgt-${{ matrix.arch }}-r4 ${{ runner.os }}-tgt-${{ matrix.arch }} path: | build-cache - name: Record various build info in GHA output if: success() && !matrix.skip id: set_env run: scripts/set-gha-env.py - name: Run dist if: success() && !matrix.skip uses: addnab/docker-run-action@v3 with: registry: ghcr.io image: ghcr.io/ruyisdk/ruyi-python-dist:20260423 options: | --user root --platform linux/${{ matrix.arch }} -v ${{ github.workspace }}:/github/workspace -e CI=true -e GITHUB_ACTIONS=true run: /github/workspace/scripts/dist-gha.sh ${{ matrix.arch }} - name: Upload artifact if: success() && !matrix.skip uses: actions/upload-artifact@v6 with: name: ${{ matrix.upload_artifact_name }} path: build/${{ matrix.build_output_name }} compression-level: 0 # the Nuitka onefile payload is already compressed dist-windows: needs: - prepare_matrix_windows strategy: # arch: str # build_output_name: str # is_windows: bool # job_name: str # runs_on: RunsOn # skip: bool # upload_artifact_name: str matrix: ${{ fromJson(needs.prepare_matrix_windows.outputs.matrix) }} name: ${{ matrix.job_name }} runs-on: ${{ matrix.runs_on }} steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - uses: actions/checkout@v6 - name: Install Poetry if: success() && !matrix.skip run: pipx install poetry - uses: actions/setup-python@v5 if: success() && !matrix.skip with: # don't let the ">=" directive bump the Python version without letting # us know # python-version-file: pyproject.toml python-version: "3.13" cache: poetry - name: Cache/restore Nuitka clcache contents if: success() && !matrix.skip uses: actions/cache@v4 with: key: clcache-${{ runner.os }}-${{ matrix.arch }}-r5-${{ hashFiles('poetry.lock') }} restore-keys: | clcache-${{ runner.os }}-${{ matrix.arch }}-r5 ${{ runner.os }}-tgt-${{ matrix.arch }}-r4 ${{ runner.os }}-tgt-${{ matrix.arch }} path: | /clcache - name: Install deps if: success() && !matrix.skip run: poetry install && mkdir /build - name: Run dist if: success() && !matrix.skip run: "scripts\\dist.ps1" - name: Upload artifact if: success() && !matrix.skip uses: actions/upload-artifact@v6 with: name: ${{ matrix.upload_artifact_name }} path: /build/${{ matrix.build_output_name }} compression-level: 0 # the Nuitka onefile payload is already compressed dist-src: name: "dist build: source archive" runs-on: ubuntu-latest if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: - lints-result steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - uses: actions/checkout@v6 with: # for git-describe to work, but also https://github.com/actions/checkout/issues/1467 # fetch-tags: true fetch-depth: 0 # to include ruyi-litester submodules: recursive - name: Fetch Git tags run: git fetch --tags --force - name: Reproducibly pack the sources id: pack_sources run: ./scripts/make-reproducible-source-tarball.sh /tmp - name: Upload artifact uses: actions/upload-artifact@v6 with: name: ${{ steps.pack_sources.outputs.artifact_name }} path: /tmp/${{ steps.pack_sources.outputs.artifact_name }} compression-level: 0 # the archive is already compressed dist-result: name: dist builds result runs-on: ubuntu-latest needs: - dist - dist-src - dist-windows outputs: run_id: ${{ github.run_id }} release_mirror_url: ${{ needs.dist.outputs.release_mirror_url }} steps: - name: Summarize dist build results run: | echo "Linux dist build: ${{ needs.dist.result }}" echo "Windows dist build: ${{ needs.dist-windows.result }}" echo "Source archive build: ${{ needs.dist-src.result }}" if [ "${{ needs.dist.result }}" != "success" ] || [ "${{ needs.dist-windows.result }}" != "success" ] || [ "${{ needs.dist-src.result }}" != "success" ]; then echo "One or more dist jobs failed." exit 1 fi echo "All dist jobs passed." release: name: make a GitHub Release runs-on: ubuntu-latest if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: - dist-result steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version-file: "pyproject.toml" - name: Download dist build artifacts for release uses: actions/download-artifact@v7 with: run-id: ${{ needs.dist-result.outputs.run_id }} path: tmp/release - name: Organize release artifacts run: scripts/organize-release-artifacts.py tmp/release - name: Render the release notes header run: | sed \ "s!@RELEASE_MIRROR_URL@!${{ needs.dist-result.outputs.release_mirror_url }}!g" \ < resources/release-notes-header-template.md \ > tmp/release-notes-header.md - name: Make the release uses: softprops/action-gh-release@v2 with: body_path: tmp/release-notes-header.md files: tmp/release/ruyi-* generate_release_notes: true prerelease: ${{ contains(needs.dist-result.outputs.release_mirror_url, 'testing') }} pypi-build: name: build artifacts for PyPI if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') runs-on: ubuntu-latest needs: - lints-result steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - uses: actions/checkout@v6 - name: Install Poetry run: pipx install poetry # NOTE: the Poetry venv is created during this step - uses: actions/setup-python@v5 with: python-version-file: pyproject.toml cache: poetry - name: Build wheels and sdist with Poetry run: poetry build - name: Upload artifact uses: actions/upload-artifact@v6 with: name: pypi-dist path: dist compression-level: 0 # all dist files are already compressed pypi-publish: name: upload release to PyPI if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: - pypi-build runs-on: ubuntu-latest # Specifying a GitHub environment is optional, but strongly encouraged environment: pypi permissions: # IMPORTANT: this permission is mandatory for Trusted Publishing id-token: write steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - name: Download built artifacts uses: actions/download-artifact@v7 with: name: pypi-dist path: ${{ github.workspace }}/dist - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@v1.14.0 ruyisdk-ruyi-1f00e2e/.github/workflows/ruyi-package-ci.yml000066400000000000000000000011451520522431500236700ustar00rootroot00000000000000name: Dispatch ruyi-package-ci on: push: tags: - '*' jobs: dispatch-ruyi-package-ci: runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1 with: egress-policy: audit - name: Dispatch ruyi-package-ci uses: benc-uk/workflow-dispatch@v1 with: token: ${{ secrets.GHA_PAT_RUYI_PACKAGE_CI_RW }} repo: ruyisdk/ruyi-package-ci ref: master workflow: deb.yml inputs: '{"ruyi_deb_ref": "${{ github.ref_name }}"}' ruyisdk-ruyi-1f00e2e/.gitignore000066400000000000000000000110401520522431500165610ustar00rootroot00000000000000# Created by https://www.toptal.com/developers/gitignore/api/python,linux,vim,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=python,linux,vim,visualstudiocode ### Linux ### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml # ruff .ruff_cache/ # LSP config files pyrightconfig.json ### Vim ### # Swap [._]*.s[a-v][a-z] !*.svg # comment out if you don't need vector files [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Session Session.vim Sessionx.vim # Temporary .netrwhist # Auto-generated tag files tags # Persistent undo [._]*.un~ ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide # End of https://www.toptal.com/developers/gitignore/api/python,linux,vim,visualstudiocode /tmp # these are pre-built to avoid requiring additional build deps on a multitude # of distros !resources/bundled/locale/**/*.mo ruyisdk-ruyi-1f00e2e/.gitmodules000066400000000000000000000001641520522431500167530ustar00rootroot00000000000000[submodule "tests/ruyi-litester"] path = tests/ruyi-litester url = https://github.com/weilinfox/ruyi-litester.git ruyisdk-ruyi-1f00e2e/.markdownlint.yaml000066400000000000000000000003651520522431500202540ustar00rootroot00000000000000# It's fine to switch to different ul style for better readability MD004: false # Use 4 spaces for nested list items for (1) better visual separation and # (2) consistency with Python MD007: indent: 4 # No impact on readability MD034: false ruyisdk-ruyi-1f00e2e/CONTRIBUTING.md000066400000000000000000000077441520522431500170420ustar00rootroot00000000000000# Contributing to RuyiSDK Thank you for your interest in contributing to RuyiSDK! This document provides guidelines and explains the requirements for contributions to this project. Read in other languages: * [中文](./CONTRIBUTING.zh.md) ## Code of Conduct Please be respectful and considerate of others when contributing to RuyiSDK. We aim to foster an open and welcoming environment for all contributors. Please follow [the RuyiSDK Code of Conduct](https://ruyisdk.org/en/code_of_conduct). ## Developer's Certificate of Origin (DCO) We require that all contributions to RuyiSDK are covered under the [Developer's Certificate of Origin (DCO)](https://developercertificate.org/). The DCO is a lightweight way for contributors to certify that they wrote or otherwise have the right to submit the code they are contributing. ### What is the DCO? The DCO is a declaration that you make when you sign-off a commit, simple enough that the original text is fully reproduced below. ```plain Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` ### How to Sign-Off Commits You need to add a `Signed-off-by` line to each commit message, which certifies that you agree with the DCO: ```plain Signed-off-by: Your Name ``` You can add this automatically by using the `-s` or `--signoff` flag when committing: ```sh git commit -s -m "Your commit message" ``` Make sure that the name and email in the signature matches your Git configuration. You can set your Git name and email with: ```sh git config --global user.name "Your Name" git config --global user.email "your.email@example.com" ``` ### DCO enforcement in CI All pull requests go through an automated DCO check in our continuous integration (CI) pipeline. This check verifies that all commits in your pull request have a proper DCO sign-off. If any commits are missing the sign-off, the CI check will fail, and your pull request cannot be merged until the issue is fixed. ## Pull Request Process 1. Fork the repository and create your branch from `main`. 2. Make your changes, ensuring they follow the project's coding style and conventions. 3. Add tests if applicable. 4. Ensure your commits are signed-off with the DCO. 5. Update documentation if necessary. 6. Submit a pull request to the main repository. ## Development Setup Please refer to the [building documentation](./docs/building.md) for information on setting up your development environment. ## Reporting Issues If you find a bug or have a feature request, please create an issue in [the issue tracker](https://github.com/ruyisdk/ruyi/issues). ## License By contributing to RuyiSDK, you agree that your contributions will be licensed under the [Apache 2.0 License](./LICENSE-Apache.txt). ruyisdk-ruyi-1f00e2e/CONTRIBUTING.zh.md000066400000000000000000000073021520522431500174500ustar00rootroot00000000000000# 为 RuyiSDK 做贡献 感谢您有兴趣为 RuyiSDK 做贡献!本文档提供了贡献指南,并解释了为本项目做贡献的要求。 阅读本文的其它语言版本: * [English](./CONTRIBUTING.md) ## 行为准则 在为 RuyiSDK 做贡献时,请尊重并考虑他人。我们旨在为所有贡献者营造一个开放和友好的环境。 请您遵守[《RuyiSDK 社区行为准则》](https://ruyisdk.org/code_of_conduct)。 ## 开发者原创声明(DCO) 我们要求 RuyiSDK 的所有贡献都包含[开发者原创声明(DCO)](https://developercertificate.org/)。DCO 是一种轻量级方式,使贡献者可以证明他们编写或有权提交所贡献的代码。 ### 什么是 DCO? DCO 是您通过签署(sign-off)提交的方式而作出的声明。其全文非常简短,转载如下: ```plain Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` ### 如何签署提交 您需要在每个提交的说明中添加一行 `Signed-off-by`,证明您同意 DCO: ```plain Signed-off-by: 您的姓名 ``` 您可以通过在提交时使用 `-s` 或 `--signoff` 参数自动添加此行: ```sh git commit -s -m "您的提交说明" ``` 确保签名中的姓名和电子邮件与您的 Git 配置匹配。您可以使用以下命令设置您的 Git 姓名和电子邮件: ```sh git config --global user.name "您的姓名" git config --global user.email "your.email@example.com" ``` ### CI 中的 DCO 验证 所有拉取请求(PR)都会在我们的持续集成 (CI) 流程中接受自动化 DCO 检查。此检查会验证您的拉取请求中的所有提交是否都有适当的 DCO 签名。如果任何提交缺少签名,CI 检查将失败,在解决问题之前,您的拉取请求将无法被合并。 ## 拉取请求流程 1. 从 `main` 分支派生(fork)相应的仓库并创建您的分支。 2. 进行更改,确保它们遵循项目的编码风格和约定。 3. 必要时添加测试。 4. 确保您的提交已包含 DCO 签名。 5. 必要时更新文档。 6. 向主仓库提交拉取请求。 ## 开发环境设置 有关设置开发环境的信息,请参阅[构建文档](./docs/building.md)。 ## 报告问题 如果您发现错误或有功能请求,请在[工单系统](https://github.com/ruyisdk/ruyi/issues)中创建问题。 ## 许可证 您同意您对 RuyiSDK 的贡献将遵循 [Apache 2.0 许可证](./LICENSE-Apache.txt)。 ruyisdk-ruyi-1f00e2e/LICENSE-Apache.txt000066400000000000000000000261351520522431500176060ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ruyisdk-ruyi-1f00e2e/README.md000066400000000000000000000224711520522431500160620ustar00rootroot00000000000000
RuyiSDK Logo

Ruyi

The package manager for RuyiSDK.

Official website | Developer community | Open-source

![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ruyisdk/ruyi/ci.yml) ![GitHub License](https://img.shields.io/github/license/ruyisdk/ruyi) ![Python Version](https://img.shields.io/badge/python-%3E%3D3.11-blue) ![GitHub Tag](https://img.shields.io/github/v/tag/ruyisdk/ruyi?label=latest%20tag) ![PyPI - Version](https://img.shields.io/pypi/v/ruyi) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/ruyisdk/ruyi/total?label=all%20github%20dl) ![PyPI - Downloads](https://img.shields.io/pypi/dm/ruyi?label=pypi%20dl) 🌍 English | [简体中文](./README.zh.md) ## ⬇️ Installation `ruyi` is available in two forms: the PyPI package or the one-file binary distribution. Performance of various `ruyi` operations will be better with the PyPI installation, but the one-file distribution is a bit easier to set up because one doesn't have to first configure a Python environment. Either way, the feature set should be the same. Detailed installation instructions are also available [at our documentation site](https://ruyisdk.org/en/docs/Package-Manager/installation). ### ✅ Recommended: Install from PyPI This is the recommended way to install `ruyi` on your machine. In any Python virtual environment, simply type: ```sh pip install ruyi ``` Or the equivalent with any other Python package manager you prefer. After the installation completes, the `ruyi` command will then show up in the Python virtual environment's `bin` directory; if you have already activated the environment, you may now start using `ruyi`. ### Alternative: Use the one-file distribution You can get pre-built binaries of `ruyi` from [GitHub Releases][ghr] or [the RuyiSDK mirror][mirror-iscas] for easier testing. Rename the downloaded file to `ruyi`, make it executable, put inside your `$PATH` and you're ready to go. [ghr]: https://github.com/ruyisdk/ruyi/releases [mirror-iscas]: https://mirror.iscas.ac.cn/ruyisdk/ruyi/tags/ ### Platform compatibility notes Because `ruyi` is written in platform-independent Python, you may be able to install `ruyi` on any system with a Python package manager. However, you may not be able to install binary packages from the official RuyiSDK Software Repository if you are on a system not listed on [RuyiSDK's platform support documentation][ruyisdk-plat-support-en] ([中文][ruyisdk-plat-support-zh]), due to the packages being only built for the officially supported systems. You may be able to obtain community-provided support from [the RuyiSDK developer community][ruyisdk-community] in such cases. [ruyisdk-plat-support-en]: https://ruyisdk.org/en/docs/Other/platform-support/ [ruyisdk-plat-support-zh]: https://ruyisdk.org/docs/Other/platform-support/ [ruyisdk-community]: https://ruyisdk.cn/ ## 🖥️ Usage You can browse our documentation at [the dedicated RuyiSDK docs site][docs-en] ([中文][docs-zh]). In case you need any assistance, feel free to search and post on [our community forum][ruyisdk-community]. [docs-en]: https://ruyisdk.org/en/docs/intro/ [docs-zh]: https://ruyisdk.org/docs/intro/ ## ⚙️ Configuration Various aspects of `ruyi` can be configured with files or environment variables. ### Config search path `ruyi` respects `$XDG_CONFIG_HOME` and `$XDG_CONFIG_DIRS` settings, and will look up its config accordingly. If these are not explicitly set though, as in typical use cases, the default config directory is most likely `~/.config/ruyi`. GNU/Linux distribution packagers and system administrators will find that the directories `/usr/share/ruyi` and `/usr/local/share/ruyi` are searched for the config file on such systems. ### Config file Currently `ruyi` will look for an optional `config.toml` in its XDG config directory. The file, if present, looks like this, with all values being default: ```toml [packages] # Consider pre-release versions when matching packages in repositories. prereleases = false [repo] # Path to the local RuyiSDK metadata repository. Must be absolute or the setting # will be ignored. # If unset or empty, $XDG_CACHE_HOME/ruyi/packages-index is used. local = "" # Remote location of RuyiSDK metadata repository. # If unset or empty, this default value is used. remote = "https://github.com/ruyisdk/packages-index.git" # Name of the branch to use. # If unset or empty, this default value is used. branch = "main" [telemetry] # Whether to collect telemetry information for improving RuyiSDK's developer # experience, and whether to send the data periodically to RuyiSDK team. # Valid values are `local`, `off` and `on` -- see the documentation for # details. # # If unset or empty, this default value is used: data will be collected and # stored locally; nothing will be uploaded automatically. mode = "local" # The time the user's consent is given to telemetry data uploading. If the # system time is later than the time given here, telemetry consent banner will # not be displayed any more each time `ruyi` is executed. The exact consent # time is also useful should the telemetry policy get updated in the future. # # To hide the consent banner, set it to the current local time, for example: # # upload_consent = 2024-12-02T15:61:00+08:00 # # The timestamp is intentionally invalid for you to notice and modify to your # need. upload_consent = "" # Override the telemetry server URL of the RuyiSDK package manager scope. # If unset, the repo-configured default is used; if set to empty, telemetry # uploading is disabled. #pm_telemetry_url = "" ``` ### Environment variables Currently the following environment variables are supported by `ruyi`: * `RUYI_TELEMETRY_OPTOUT` -- boolean, whether to opt-out of telemetry. * `RUYI_VENV` -- string, explicitly specifies the Ruyi virtual environment to use. For boolean variables, the values `1`, `true`, `x`, `y` or `yes` (all case-insensitive) are all treated as "true". ## 📞 Telemetry The Ruyi package manager collects usage data in order to help us improve your experience. It is collected by the RuyiSDK team and shared with the community. You can opt-out of telemetry by setting the `RUYI_TELEMETRY_OPTOUT` environment variable to any of `1`, `true`, `x`, `y` or `yes` using your favorite shell. Opting out of telemetry is equivalent to the `off` mode described below. There are 3 telemetry modes available: * `local`: data will be collected but not uploaded without user action. * `off`: data will neither be collected nor uploaded, except for a one-time upload of `ruyi`'s version number on first run. * `on`: data will be collected and periodically uploaded. By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which means every `ruyi` invocation will record some non-sensitive information locally alongside various other states of `ruyi`, but collected data will not be uploaded automatically unless you explicitly request so (for example by switching to the `on` mode, or by executing `ruyi telemetry upload`). In case the `on` mode is active, collected data will be periodically uploaded to servers managed by the RuyiSDK team in the People's Republic of China, in a weekly fashion. The upload will happen on a random weekday which is determined by the installation's anonymous ID alone. You can change the telemetry mode by editing `ruyi`'s config file, or simply disable telemetry altogether by setting the `RUYI_TELEMETRY_OPTOUT` environment variable to any of the values accepted as truthy. We collect the following information with `ruyi`: * the running machine's basic information: * architecture and OS * if architecture is RISC-V: * ISA capabilities * model name of the board * number of logical CPUs * OS release ID (roughly equals the distribution type) * type and version of libc * type of the shell (bash, fish, zsh, etc.) * the version of `ruyi` on data upload time * invocation patterns of various `ruyi` subcommands: * without exposing any parameters * invocation time is recorded with a granularity of 1 minute You can see [our Privacy Policy][privacy-policy-en] ([中文][privacy-policy-zh]) on the RuyiSDK website. [privacy-policy-en]: https://ruyisdk.org/en/docs/legal/privacyPolicy/ [privacy-policy-zh]: https://ruyisdk.org/docs/legal/privacyPolicy/ ## 🙋 Contributing We welcome contributions to RuyiSDK! Please see our [contribution guidelines](./CONTRIBUTING.md) ([中文](./CONTRIBUTING.zh.md)) for details on how to get started. ## ⚖️ License Copyright © Institute of Software, Chinese Academy of Sciences (ISCAS). All rights reserved. `ruyi` is licensed under the [Apache 2.0 license](./LICENSE-Apache.txt). The one-file binary distribution of `ruyi` contains code licensed under the [Mozilla Public License 2.0](https://mozilla.org/MPL/2.0/). You can get the respective project's sources from the project's official website: * [`certifi`](https://github.com/certifi/python-certifi): used unmodified All trademarks referenced herein are property of their respective holders. ruyisdk-ruyi-1f00e2e/README.zh.md000066400000000000000000000213041520522431500164740ustar00rootroot00000000000000
RuyiSDK Logo

Ruyi

RuyiSDK 的包管理器。

官网 | 开发者社区 | 开放源代码

![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ruyisdk/ruyi/ci.yml) ![GitHub License](https://img.shields.io/github/license/ruyisdk/ruyi) ![Python Version](https://img.shields.io/badge/python-%3E%3D3.11-blue) ![GitHub Tag](https://img.shields.io/github/v/tag/ruyisdk/ruyi?label=latest%20tag) ![PyPI - Version](https://img.shields.io/pypi/v/ruyi) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/ruyisdk/ruyi/total?label=all%20github%20dl) ![PyPI - Downloads](https://img.shields.io/pypi/dm/ruyi?label=pypi%20dl) 🌍 [English](./README.md) | 简体中文 ## ⬇️ 安装 `ruyi` 以两种形式分发:PyPI 软件包、单二进制文件。通过 PyPI 安装时,`ruyi` 的各项操作通常性能更好;而单文件形式更容易上手,因为无需事先配置 Python 环境。无论您使用哪种方式安装,`ruyi` 支持的功能都应当一致。 您也可以[在我们的文档站](https://ruyisdk.org/docs/Package-Manager/installation)查阅详细的安装步骤说明。 ### ✅ 推荐:通过 PyPI 安装 我们推荐在您的机器上如此安装 `ruyi`。在任意 Python 虚拟环境中,执行: ```sh pip install ruyi ``` 如果您使用其他 Python 包管理器,您也可以执行等效的相应命令。安装完成后,`ruyi` 命令会出现在该虚拟环境的 `bin` 目录中;如果已经激活了该环境,您现在就可以开始使用 `ruyi` 了。 ### 备选:使用单二进制文件发行版 您可以从 [GitHub Releases][ghr] 或 [RuyiSDK 镜像站][mirror-iscas] 获取 `ruyi` 的预构建二进制,以便您试用。将下载的文件重命名为 `ruyi`,赋予其可执行权限,最后放到你的 `$PATH` 中,即可使用了。 [ghr]: https://github.com/ruyisdk/ruyi/releases [mirror-iscas]: https://mirror.iscas.ac.cn/ruyisdk/ruyi/tags/ ### 平台兼容性说明 由于 `ruyi` 是以平台无关的 Python 编写,您通常可以在任何拥有 Python 包管理器的系统上安装它。然而,若您的系统不在 [RuyiSDK 平台支持文档][ruyisdk-plat-support-zh]([English][ruyisdk-plat-support-en])之列,则您可能无法从官方 RuyiSDK 软件源安装二进制软件包:因为这些软件包仅为官方支持的系统构建。这种情况下,您也许能够从 [RuyiSDK 开发者社区][ruyisdk-community]获取由社区提供的支持。 [ruyisdk-plat-support-en]: https://ruyisdk.org/en/docs/Other/platform-support/ [ruyisdk-plat-support-zh]: https://ruyisdk.org/docs/Other/platform-support/ [ruyisdk-community]: https://ruyisdk.cn/ ## 🖥️ 使用 您可以在我们专门设立的 RuyiSDK 文档站查阅[文档][docs-zh]([English][docs-en])。如需帮助,欢迎在[我们的社区论坛][ruyisdk-community]搜索或发贴。 [docs-en]: https://ruyisdk.org/en/docs/intro/ [docs-zh]: https://ruyisdk.org/docs/intro/ ## ⚙️ 配置 `ruyi` 的各项行为可以通过配置文件或环境变量进行配置。 ### 配置搜索路径 `ruyi` 会尊重 `$XDG_CONFIG_HOME` 与 `$XDG_CONFIG_DIRS`,并据此搜索配置文件。如果您未显式设置这些环境变量(这是通常情况),默认的配置目录通常为 `~/.config/ruyi`。 GNU/Linux 发行版打包人员和系统管理员们还会注意到:`/usr/share/ruyi` 与 `/usr/local/share/ruyi` 也会被搜索。 ### 配置文件 目前 `ruyi` 会在其 XDG 配置目录中搜索一个可选的 `config.toml`。若该文件存在,其内容应该类似如下所示,如果一个值没有被指定那么此处展示的是它将取到的默认值: ```toml [packages] # 在匹配仓库中的软件包版本时,是否考虑预发行版本。 prereleases = false [repo] # 本地 RuyiSDK 元数据仓库路径。必须为绝对路径,否则该设置会被忽略。 # 若未设置或为空,则使用 $XDG_CACHE_HOME/ruyi/packages-index。 local = "" # RuyiSDK 元数据仓库的远端地址。 # 若未设置或为空,则使用下述默认值。 remote = "https://github.com/ruyisdk/packages-index.git" # 要使用的分支名。 # 若未设置或为空,则使用下述默认值。 branch = "main" [telemetry] # 是否收集遥测信息以改进 RuyiSDK 的开发者体验,以及是否周期性地将数据发送给 RuyiSDK 团队。 # 可选值为 `local`、`off` 与 `on`——详见文档。 # # 若未设置或为空,则使用下述默认值:仅在本地收集与保存数据;不会自动上传。 mode = "local" # 用户同意上传遥测数据的时刻。如果系统时刻晚于这里给出的时刻,则每次执行 `ruyi` 时将不再展示同意横幅。 # 若未来 RuyiSDK 更新了遥测政策,此处记录的确切时刻也有助于处理相关事项。 # # 如需隐藏同意横幅,请将其设为当前本地时刻,例如: # # upload_consent = 2024-12-02T15:61:00+08:00 # # 此处提供的时间戳格式无效,这是有意而为:如您需要手工修改,请注意。 upload_consent = "" # 覆盖 RuyiSDK 包管理器作用域的遥测服务器 URL。 # 若未设置,则使用仓库配置的默认值;若设为空,则禁用遥测上传。 #pm_telemetry_url = "" ``` ### 环境变量 目前 `ruyi` 支持以下环境变量: * `RUYI_TELEMETRY_OPTOUT` —— 布尔值,是否选择退出遥测。 * `RUYI_VENV` —— 字符串,显式指定要使用的 Ruyi 虚拟环境。 对于布尔值,`1`、`true`、`x`、`y` 或 `yes`(不区分大小写)均视为“真”。 ## 📞 遥测 Ruyi 包管理器会收集使用数据,以帮助我们改进您的使用体验。数据由 RuyiSDK 团队收集,并与社区共享。您可以通过在常用的 Shell 中将环境变量 `RUYI_TELEMETRY_OPTOUT` 设为 `1`、`true`、`x`、`y` 或 `yes` 中的任意一个,来选择退出遥测。退出遥测等效于下述的 `off` 模式。 共有 3 种遥测模式: * `local`:收集数据,但不会在用户未主动操作的情况下上传。 * `off`:既不收集也不上传数据,除首次运行时上传一次 `ruyi` 的版本号之外。 * `on`:收集数据并周期性上传。 从 `ruyi` 0.42.0(含)开始,默认启用的遥测模式是 `local`,这意味着每次执行 `ruyi` 都会在本地记录一些非敏感信息以及 `ruyi` 的若干状态,但这些数据不会被自动上传。仅当您采取明确的行动,例如切换到遥测模式 `on`,或者使用 `ruyi telemetry upload` 手动上传,这些数据才会被上传。 在遥测模式 `on` 下,收集的数据会在每周的某一日上传至服务器,该服务器位于中华人民共和国境内,由 RuyiSDK 团队管理。上传日是星期几仅由当前实例的匿名 ID 决定。 你可以通过编辑 `ruyi` 的配置文件来更改遥测模式,或简单地将 `RUYI_TELEMETRY_OPTOUT` 环境变量设置为任一视同为真的取值来禁用遥测。 我们通过 `ruyi` 收集以下信息: * 运行设备的基础信息: * 架构与操作系统 * 若架构为 RISC-V: * ISA 能力 * 开发板型号名称 * 逻辑 CPU 数量 * 操作系统发行版 ID(大致等同于发行版类型) * libc 的类型与版本 * Shell 类型(bash、fish、zsh 等) * 数据上传时的 `ruyi` 版本 * 各类 `ruyi` 子命令的调用模式: * 不暴露任何参数 * 调用时间以 1 分钟为粒度记录 你可以在 [RuyiSDK 网站][privacy-policy-zh]上查看我们的隐私政策([English][privacy-policy-en])。 [privacy-policy-en]: https://ruyisdk.org/en/docs/legal/privacyPolicy/ [privacy-policy-zh]: https://ruyisdk.org/docs/legal/privacyPolicy/ ## 🙋 贡献 欢迎您为 RuyiSDK 做贡献!请参阅我们的[贡献指南](./CONTRIBUTING.zh.md)([English](./CONTRIBUTING.md))以了解如何开始。 ## ⚖️ 许可 版权所有 © 中国科学院软件研究所(ISCAS)。保留所有权利。 `ruyi` 采用 [Apache 2.0 许可证](./LICENSE-Apache.txt) 授权。 `ruyi` 的单文件二进制发行版含有依据 [Mozilla Public License 2.0](https://mozilla.org/MPL/2.0/) 授权的代码。您可以从相应项目的官方网站获取其源代码: * [`certifi`](https://github.com/certifi/python-certifi):未作修改 本项目所涉商标均归其各自所有者所有。 ruyisdk-ruyi-1f00e2e/contrib/000077500000000000000000000000001520522431500162355ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/contrib/poetry-1.x/000077500000000000000000000000001520522431500201635ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/contrib/poetry-1.x/README.md000066400000000000000000000007271520522431500214500ustar00rootroot00000000000000# Poetry 1.x project metadata files If you are a packager packaging `ruyi` for old distros that only provide Poetry 1.x, here are project metadata files ready for use. Just drop in the files to replace the Poetry 2.x metadata: ```sh # at project root mv contrib/poetry-1.x/{pyproject.toml,poetry.lock} . ``` Then you should be able to continue building with Poetry 1.x. The metadata provided here is generated with Poetry 1.0.7, which is what Ubuntu 22.04 provides. ruyisdk-ruyi-1f00e2e/contrib/poetry-1.x/poetry.lock000066400000000000000000002321621520522431500223650ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "argcomplete" version = "3.6.3" description = "Bash tab completion for argparse" optional = false python-versions = ">=3.8" files = [ {file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"}, {file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"}, ] [package.extras] test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] [[package]] name = "arpy" version = "2.3.0" description = "Library for accessing \"ar\" files" optional = false python-versions = "*" files = [ {file = "arpy-2.3.0.tar.gz", hash = "sha256:8302829a991cfcef2630b61e00f315db73164021cecbd7fb1fc18525f83f339c"}, ] [[package]] name = "babel" version = "2.18.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" files = [ {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, ] [package.extras] dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] [[package]] name = "certifi" version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] name = "cffi" version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] name = "fastjsonschema" version = "2.21.2" description = "Fastest Python implementation of JSON schema" optional = false python-versions = "*" files = [ {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, ] [package.extras] devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "markdown-it-py" version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, ] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "pycparser" version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] [[package]] name = "pygit2" version = "1.19.2" description = "Python bindings for libgit2." optional = false python-versions = ">=3.11" files = [ {file = "pygit2-1.19.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70c7efc426bdae6b67465a03729b79277e7757a29a7d6550b40c18ed36cb7232"}, {file = "pygit2-1.19.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b96d6ed7251eef70cfd4126269f1044fa47bc6da6367300027c5e5d74789f7f"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f3235db6b553b8fb4d3c1dc86af9be1eab445f1d6c42f4ade5cf5f60efd333"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a35d56126f82a303668f4198c138627b3e9820f9f1eec38fff0409be274b9e"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e59a2e9eddd59edf999403c266c891dfc171eb95939d229ed614bc21e0c95804"}, {file = "pygit2-1.19.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0d2437bd5f8dbd652e8a6c318cbcaa245c0528ee48f6d64f4aaef8fd9b36b93"}, {file = "pygit2-1.19.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60d011496e57436b0c8e3fbd4d12745777427b3f33a60710ec3d94d2f76304b7"}, {file = "pygit2-1.19.2-cp311-cp311-win32.whl", hash = "sha256:9b0d5a44ca6d77a8c0e2526f6556d9b37cc85d44983ff3549bf5adbf95d289c4"}, {file = "pygit2-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:0d9c795155086c95ef890c87b50e02792146cfaede2c715698e6988a122373e7"}, {file = "pygit2-1.19.2-cp311-cp311-win_arm64.whl", hash = "sha256:837f0a9a0093cbb213176284d29f0ab754ded3e5af967e7ec6419d590a7da92a"}, {file = "pygit2-1.19.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf479077d48a60b09569a5bb50866d8609f434f8982058594b0d2e2950bd6fce"}, {file = "pygit2-1.19.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e6e7eb5fb49203735627b8e1d410afe19e7d610c9a9733c11084fabd17f0920"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a810da2d108d6bd16115c72a1c3d69fa1528ef927719bdfc94d2cdbc4198288"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b8ae5a822afb2771cbacf7c75140e663bc801c44eaaf2e4017f850cb27227c"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:330430b6c1a3e6d45d1f5f950734d37d849c07924b5b0475cd995a7e541e6ab1"}, {file = "pygit2-1.19.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b7f165d1ddfa1e0f205c1115ee10f5fea700fd3584c727b0d61a57192238449"}, {file = "pygit2-1.19.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e46ec6a97a5c43704473e42a926f7f20f9934ceef4f4891660313f573c4f0ab8"}, {file = "pygit2-1.19.2-cp312-cp312-win32.whl", hash = "sha256:6b4de5469e88e7b069143f7a5d6336a4b3e7d911de4633ef18c113e416feb948"}, {file = "pygit2-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:f064748202928f4e882501521229e378e0b7b69b0e7c433cdb2626d007745973"}, {file = "pygit2-1.19.2-cp312-cp312-win_arm64.whl", hash = "sha256:222f439d751799dc74c3fa75f187abdbc415d12f9a091efa66f0c9ff51893d32"}, {file = "pygit2-1.19.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:df207f93a33851a110dec70108e3f2a1c69578932919fd356303eda83a5624db"}, {file = "pygit2-1.19.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae884cd53e29b3d831f5261f36048a8d5db5642dc98cd63530810e7fd9c9e60d"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bd4059964531d20aaf4577b3761590df9cc7c9e2395df5d33f0552224331b76"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3befcccc7b3b62e45da2cc1ce4095964f7606d3d15b43dc667c6ef2a2ada20d"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cf08b54553f997f6f60a7918504e22e7baa4ba2fbb11d1e1cb6c0a45ac7e04b"}, {file = "pygit2-1.19.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7f630e5a763f01b4be6e2374c487086229c8f7392a2e5591d29095c5e481da4"}, {file = "pygit2-1.19.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6166845f41d4f6be3353997022d64035fe3df348c8e34d7d30c5f95817fbcab4"}, {file = "pygit2-1.19.2-cp313-cp313-win32.whl", hash = "sha256:5bebea045102e87dea142242298d4dd668d0227f76042f98efb1c5d5dd3db21e"}, {file = "pygit2-1.19.2-cp313-cp313-win_amd64.whl", hash = "sha256:7bbfeb680821001a5c1b6959da1eae906806c90c9992ae4564d3ea83a27bb19f"}, {file = "pygit2-1.19.2-cp313-cp313-win_arm64.whl", hash = "sha256:033d489186145cf67b2c60840d2a308f6b1e9d641de12417c447f9829dacde70"}, {file = "pygit2-1.19.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5effee3f4ad0d9c89b34ebecf1acee26f6b117ef3c51345ad022bd521fd8dca"}, {file = "pygit2-1.19.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ed09804dc6b6de0be07a71443122fd7b6458f8466d1134003c2dea55af886fc"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d114aa066e718d5ef3401b366dcb0b37b549c3b3b139f5f0042bd7059a4b0f7"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1becc06071acfdd5ae8523aaeab6d4b0930b2bcb08f5eb878e052e61275000b"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06d2db3bdbf2906eb17112adb14a2fe6e34c1b2bce39c91819f59208d4e56665"}, {file = "pygit2-1.19.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8a7e99e5dfc8d3ed8f849b9688bc3fb1bdc86f34af28159140a8d1e18b703dd8"}, {file = "pygit2-1.19.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7659d59eba6c4a706978237d02e8d719f960843df749256f1656c938c1f4142b"}, {file = "pygit2-1.19.2-cp314-cp314-win32.whl", hash = "sha256:e551908dfd93d471c0b08cfcddbe4924417865aae6ac90d20f3815c9483b0a82"}, {file = "pygit2-1.19.2-cp314-cp314-win_amd64.whl", hash = "sha256:eb1fd8538372230f8a471a5f3629901bc2fc7df992853d97bedc8fa269a9caf3"}, {file = "pygit2-1.19.2-cp314-cp314-win_arm64.whl", hash = "sha256:3cc461245b70be45a936e925744e67a45f6b0ee970aeb8e7a385dd7fe9f40877"}, {file = "pygit2-1.19.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cb686bc81dfe5b13937047643fddb1dd253dae33b4a9ca62858c49ed294e05be"}, {file = "pygit2-1.19.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ec3538d81963bd05dd16c0de75938a9173966e1c853ad7848ebcb60bcfe21b0"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d02ebb50ea082d9631bbfda12787eb5324b8880a72cb8e3b9f11e9b323ad5781"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a3643e4dd569c2909e88586659f617f70315680ca3c619cd8ff9e9c28726c25"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:697e3684cb4ef2bfc084623c3f680d5ae8b4c8afca31a35a731b7b70204d9f83"}, {file = "pygit2-1.19.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:173165b54a2affed918302193f12dd369bec981b1d77904cdcd76b966a824e15"}, {file = "pygit2-1.19.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ff32adce1a48d76b10e790b36784f6cb5ef40699b758c8b84f7f53f13b13d237"}, {file = "pygit2-1.19.2-cp314-cp314t-win32.whl", hash = "sha256:637d7c023f6623da35cf02cd1091f260c709730dd615367f4524ec8d771d0898"}, {file = "pygit2-1.19.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2805a8abd546e38298ce5daf33e444960e483acce68cbfb5d338e72ad5bc3503"}, {file = "pygit2-1.19.2-cp314-cp314t-win_arm64.whl", hash = "sha256:376a0d2c27c082f6bd8b97fd8ffc1939f16dfe8374ec846deee9b11151b37b8a"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4c2d397c887ff5a26b48ebd1bb9c66d2195ad377f0a44e05b79c462fff4040cd"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:69a0d377ee46110bbeea9e4191edee05132d1e7ac84b7cdebc640bc45868a2ec"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57d113a3eb61621ce16ceaa4bae7a93ffe525fd69da905445a0cf798d3601815"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e0bc207abbef4d3be3bd37e0711e6974a148d41806fdc932aef9bb244b157c4"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:219c03bdbca59bd1df12b8bc7974b429872f4267aa2287ec0237c268593c0c5e"}, {file = "pygit2-1.19.2.tar.gz", hash = "sha256:cbeb3dbca9ca6ee3d5ea5d02f5e844c2d6084a2d5d6621e3e06aa2b11c645bfd"}, ] [package.dependencies] cffi = ">=2.0" [[package]] name = "pygments" version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyyaml" version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "requests" version = "2.33.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" files = [ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, ] [package.dependencies] certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "semver" version = "3.0.4" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" files = [ {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] name = "tomlkit" version = "0.14.0" description = "Style preserving TOML library" optional = false python-versions = ">=3.9" files = [ {file = "tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680"}, {file = "tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"}, ] [[package]] name = "tzdata" version = "2026.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, ] [[package]] name = "urllib3" version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0)"] [metadata] lock-version = "2.0" python-versions = ">=3.11" content-hash = "b616eb65a32828b808264a7b10b04b60714ecb25b59173caaf37e96b32098778" ruyisdk-ruyi-1f00e2e/contrib/poetry-1.x/pyproject.toml000066400000000000000000000056271520522431500231110ustar00rootroot00000000000000[build-system] requires = ["poetry-core<2"] build-backend = "poetry.core.masonry.api" [tool.poetry] name = "ruyi" version = "0.49.0" description = "Package manager for RuyiSDK" keywords = ["ruyi", "ruyisdk"] license = "Apache-2.0" readme = "README.md" authors = [ "WANG Xuerui ", ] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Embedded Systems", "Topic :: System :: Software Distribution", "Typing :: Typed", ] include = ["ruyi/py.typed"] [tool.poetry.dependencies] python = ">=3.11" argcomplete = ">=2.0.0" arpy = "*" babel = ">=2.10.3" fastjsonschema = ">=2.16.3" jinja2 = "^3.0.3" pygit2 = ">=1.11.1" pyyaml = ">=6.0" requests = "^2.25.1" rich = ">=11.2.0" semver = ">=2.10" tomlkit = ">=0.9" tzdata = { version = "*", platform = "win32" } [tool.poetry.scripts] ruyi = "ruyi.__main__:entrypoint" [project.urls] homepage = "https://ruyisdk.org" documentation = "https://ruyisdk.org/docs/intro" download = "https://ruyisdk.org/download" github = "https://github.com/ruyisdk/ruyi" issues = "https://github.com/ruyisdk/ruyi/issues" repository = "https://github.com/ruyisdk/ruyi.git" [tool.mypy] files = ["ruyi", "scripts", "tests"] exclude = [ "tests/ruyi-litester", ] show_error_codes = true strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] # https://github.com/eyeseast/python-frontmatter/issues/112 # https://github.com/python/mypy/issues/8545 # have to supply the typing info until upstream releases a new version with # the py.typed marker included mypy_path = "./stubs" [tool.pylic] safe_licenses = [ "Apache Software License", "BSD License", "GPLv2 with linking exception", "MIT", # pyright spells "MIT License" differently "MIT License", "Mozilla Public License 2.0 (MPL 2.0)", # needs mention in license notices "PSF-2.0", # typing_extensions 4.13 # not ruyi deps, but brought in by pylic which unfortunately cannot live # outside of the project venv in order to work. # Fortunately though, they are all permissive licenses, so inclusion of # them would not accidentally allow unsafe licenses into the project. "ISC License (ISCL)", # shellingham "BSD-2-Clause", # boolean.py "BSD-3-Clause", # click "Apache-2.0", # license-expression ] [tool.pyright] include = ["ruyi", "scripts", "tests"] exclude = ["**/__pycache__", "tests/ruyi-litester", "tmp"] stubPath = "./stubs" pythonPlatform = "Linux" [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] extend-exclude = [ "tests/ruyi-litester", ] ruyisdk-ruyi-1f00e2e/contrib/shell-completions/000077500000000000000000000000001520522431500216765ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/contrib/shell-completions/bash/000077500000000000000000000000001520522431500226135ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/contrib/shell-completions/bash/ruyi000066400000000000000000000000571520522431500235300ustar00rootroot00000000000000eval "$(ruyi --output-completion-script=bash)" ruyisdk-ruyi-1f00e2e/contrib/shell-completions/zsh/000077500000000000000000000000001520522431500225025ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/contrib/shell-completions/zsh/_ruyi000066400000000000000000000000741520522431500235550ustar00rootroot00000000000000#compdef ruyi eval "$(ruyi --output-completion-script=zsh)" ruyisdk-ruyi-1f00e2e/docs/000077500000000000000000000000001520522431500155255ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/docs/README.md000066400000000000000000000020121520522431500167770ustar00rootroot00000000000000# RuyiSDK 包管理器的技术文档 以下是一些面向 RuyiSDK 贡献者、打包者群体的技术文档。如果您是使用 RuyiSDK 开发自己项目的用户,请参考 [RuyiSDK 官网文档](https://ruyisdk.org/docs/intro)。 ## 第三方 RuyiSDK 生态 * [如何程序化地与 `ruyi` 交互](./programmatic-usage.md) ## 工程化 * [`ruyi` 的构建方式](./building.md) * [CI: 自动化版本发布](./ci-release-automation.md) * [Repo CI: Self-hosted runner 管理](./ci-self-hosted-runner.md) * [`ruyi` 的依赖兼容基线](./dep-baseline.md) ## 软件源 * [设备、系统镜像的命名约定](./naming-of-devices-and-images.md) * [RuyiSDK 软件源的软件包版本约定](./repo-pkg-versioning-convention.md) * [Ruyi 软件源结构定义](./repo-structure.md) * [多软件源支持(Overlay 架构)设计文档](./multi-repo-design.md) * [多软件源支持(用户文档)](./multi-repo.md) ## 软件包构建 * [构建配方(Build Recipe)设计文档](./build-recipes-design.md) ruyisdk-ruyi-1f00e2e/docs/build-recipes-design.md000066400000000000000000000334021520522431500220470ustar00rootroot00000000000000# 构建配方(Build Recipe)设计文档 ## 背景与动机 当前 RuyiSDK 软件包的构建流程事实上分散在若干独立的仓库与脚本中: * `packages-index` 保存面向终端用户的软件包清单(manifest),是一份"已构建产物的目录"。 * `ruyici` 仓库保存实际的构建脚本:每个目标软件包对应一组 `ruyi-build-*` 外层脚本(Docker 封装层)与 `ruyi-build-*-inner` 内层脚本(容器内实际构建逻辑),以及一批特定驱动所需的配置文件(`toolchain-configs/`、`qemu-configs/`、`llvm-configs/` 等)与补丁(`toolchain-patches/`)。 * 构建操作者(packager)需要凭记忆或参考 README 才知道"构建 `toolchain/gnu-plct` 0.20231118.0 这个版本对应的命令是 `./ruyi-build-ctng ./toolchain-configs/gnu-plct/host-amd64.defconfig`"。这一"部落知识"从未被系统化。 本设计的目的是: 1. 将"如何构建某个软件包"这一知识本身变成**可版本化的数据**,与构建脚本一同由官方或第三方维护。 2. 让 `ruyi` 作为编排入口自动执行该数据所描述的构建流程,并完成产物登记(校验和、大小、清单片段)以便后续合并进 `packages-index`。 3. 不改变 `packages-index` 的既有 schema,不影响终端用户。 ## 核心概念 ### 构建配方仓库(build-recipe repository) 一个**构建配方仓库**是一个独立的目录树(通常也是一个独立的 git 仓库),其根目录存在一个 `ruyi-build-recipes.toml` 标记文件。仓库内的 `*.star` 文件即**构建配方**,每一个配方描述了一个或多个可调度的构建任务。 借鉴 `packages-index` 的 overlay 架构(详见[多软件源支持设计文档](./multi-repo-design.md)),用户可以同时配置多个构建配方仓库: * **官方配方仓库**:目前的 `ruyici` 仓库加上一份 `ruyi-build-recipes.toml` 即成为官方配方仓库;仓库内现有的 `ruyi-build-*` 外层脚本不再被 `ruyi` 直接调用,但可以继续用于手动调试。 * **厂商/OEM 配方仓库**:硬件厂商可在自己的仓库中维护为特定开发板或 BSP 定制的构建流程。 * **个人/实验配方仓库**:开发者可在本地仓库中临时编写配方,无需改动任何上游仓库。 需要强调的是:配方仓库对**仓库内部布局**不做硬性约束。配方可以是 `recipes/foo.star`、`my-builds/bar.star` 乃至根目录下的 `baz.star`,均由配方作者自行决定。`ruyi` 只依赖标记文件定位项目根;配方之间的相互引用使用下文定义的 URI Scheme。 ### 构建配方(build recipe) 一个构建配方是一份 Starlark 源文件,在顶层以与现有插件完全一致的方式声明 API 版本: ```python RUYI = ruyi_plugin_rev(1) ``` 配方在模块加载时通过 `RUYI.build.schedule_build(fn, name=...)` **显式注册**一个或多个可调度的构建函数。每个构建函数接收一个 `ctx` 参数,返回一个子进程调用计划(`Invocation`)。 仅当宿主以"构建配方上下文"加载该 Starlark 模块时,`RUYI.build` 命名空间才可访问;普通插件尝试访问会得到明确的错误。该门控由新增的 `build-recipe-v1` feature flag 控制,复用现有 `RuyiHostAPI.has_feature()` 机制。 ### 配方项目根与安全边界 `ruyi-build-recipes.toml` 不仅是一个分类标记,同时定义了一个安全边界: * `ruyi admin build-package ./xxx.star` 的行为是:对 `xxx.star` 取 `realpath`,自底向上查找第一个含有 `ruyi-build-recipes.toml` 的祖先目录,即视为**项目根**。若追溯到 `/` 仍未找到标记文件,则命令拒绝执行并给出清晰的错误信息。 * 配方在使用 `ruyi-build://` URI 引用项目内其他文件时,最终解析路径必须仍落在项目根以内(反 `..` 逃逸检查)。 * 构建产物默认落在项目根下的 `output_dir`(由标记文件声明,默认 `out/`);若配方希望使用项目根之外的目录作为产物根,则该目录必须出现在标记文件的 `extra_artifact_roots` 白名单中。 该约束与 Bazel 的 `WORKSPACE` / `MODULE.bazel` 根文件机制类似:将**项目边界**从"用户记得切换到哪里"这种易出错的隐式行为,转化为一条可被机器校验的显式事实。 ## 标记文件格式 ```toml format = "v1" [project] name = "ruyici" # 人类可读名称;用于日志与构建报告 output_dir = "out" # 默认产物目录(相对项目根) extra_artifact_roots = ["/tmp"] # 可选:允许产物落在项目根之外的绝对路径前缀白名单 ``` 未来新增字段(例如默认的 builder image 摘要、默认 subprocess 环境变量)时,沿用 `format = "vN"` 语义化递增;`ruyi` 核心对未知版本拒绝加载并建议升级 `ruyi`。 ## Starlark 宿主 API ### 模块加载时(`RUYI`) * `RUYI = ruyi_plugin_rev(1)` — 与既有插件完全相同,仅在启用 `build-recipe-v1` feature 的宿主上下文中,`RUYI.build` 子命名空间才可访问。 * `RUYI.build.schedule_build(fn, name=None)` — 注册一个构建函数。`name` 缺省时取 `fn.__name__`;同一配方内重名为错误;完整加载后未注册任何构建为错误。 * `RUYI.log`、`RUYI.i18n`(若宿主上下文启用 i18n)— 沿用现有 `RuyiHostAPI` 中的对应字段,供配方输出进度或本地化提示。 * 注意:`RUYI.call_subprocess_argv` 在构建配方上下文中被显式关闭。所有子进程调用必须经由下文的 `ctx.subprocess(...)` 以计划形式声明。 ### 构建执行时(`ctx`) `ctx` 是每次调度时由宿主新建的上下文对象,绑定到具体的某次被调度构建,仅在该 `fn(ctx)` 调用期间有效: * `ctx.subprocess(argv, cwd=None, env=None, produces=[]) -> Invocation` — 构造一个子进程调用计划。该方法**不立即执行**,仅返回一个不可变记录,由宿主在计划返回后统一调度。 * `ctx.artifact(glob, root=None) -> Artifact` — 声明一个产物 glob。`root` 为 `None` 时取项目标记文件的 `output_dir`;为绝对路径时必须落在 `extra_artifact_roots` 允许的前缀下。 * `ctx.var(name, default=None) -> str` — 读取命令行传入的 `-v NAME=VALUE` 变量。未提供且无默认值为错误。 * `ctx.repo_root: str` — 项目根绝对路径。 * `ctx.repo_path(rel: str) -> str` — 相对项目根的安全路径拼接(内含反 `..` 逃逸检查)。 * `ctx.name: str` — 当前调度构建的名称;`ctx.recipe_file: str` — 触发本次调度的 `.star` 文件路径。两者主要用于错误信息与构建报告。 故意保持极简:**核心 API 中不存在 `docker_run`、`driver`、`input_config` 这些概念**。Docker 封装与 image tag 管理属于"配方上用户空间"的工具,由配方作者在项目内以 Starlark 库的形式(例如 `lib/docker.star`)提供,通过 `ruyi-build://` URI 被其他配方 `load` 复用。`ruyi` 核心只认 `subprocess`。 ## 加载路径 URI Scheme 现有的 `ruyi/pluginhost/paths.py` 已经支持以下 URI scheme: * `ruyi-plugin://` — 按插件 ID 定位 `packages-index/plugins//mod.star`; * `ruyi-plugin-data:///...` — 对应的数据文件访问。 本设计在此基础上新增两个 scheme,语义一致但解析根不同: * `ruyi-build://` — 将路径解析为**当前配方项目根**下的子路径,用于加载项目内的 Starlark 代码; * `ruyi-build-data://` — 同上,但走数据加载通道(`is_for_data=True`),供 `load_toml` 等场景使用。 两种 scheme 在解析后均强制执行反 `..` 逃逸检查,最终路径必须仍在项目根以内。裸 `//foo` 形式(不含 scheme)继续保持拒绝,与现有策略一致。 在配方文件中的使用示例: ```python RUYI = ruyi_plugin_rev(1) load("ruyi-build://lib/docker.star", "docker_run") load("ruyi-build://lib/images.star", "pkgbuilder_image_tag") ``` ## 完整示例 下例展示将现有 `ruyici/ruyi-build-qemu` 的一次 `both` 变体调用改写为配方: ```python # recipes/qemu-riscv-upstream.star RUYI = ruyi_plugin_rev(1) load("ruyi-build://lib/docker.star", "docker_run") load("ruyi-build://lib/images.star", "pkgbuilder_image_tag") def build_qemu_riscv_upstream(ctx): arch = ctx.var("arch", default = "amd64") flavor = ctx.var("flavor", default = "both") produces = [] if flavor in ("both", "system"): produces.append(ctx.artifact( glob = "qemu-system-riscv-upstream-*.%s.tar.zst" % arch)) if flavor in ("both", "user"): produces.append(ctx.artifact( glob = "qemu-user-riscv-upstream-*.%s.tar.zst" % arch)) return ctx.subprocess( argv = docker_run( image = pkgbuilder_image_tag("unified", "amd64"), mounts_rw = [ (ctx.repo_path("out"), "/out"), (ctx.repo_path("work"), "/work"), ], mounts_ro = [ (ctx.repo_path("ruyi-build-qemu-inner"), "/usr/local/bin/ruyi-build-qemu-inner"), (ctx.repo_path("qemu-configs/upstream-20250908.sh"), "/tmp/config.sh"), (ctx.repo_path("qemu-10.0.4.tar.xz"), "/tmp/src.tar.xz"), ], tmpfs = ["/tmp/mem"], argv = ["ruyi-build-qemu-inner", "/tmp/config.sh", arch, flavor], ), cwd = ctx.repo_root, produces = produces, ) RUYI.build.schedule_build(build_qemu_riscv_upstream) ``` 对于需要"多主机矩阵"的场景(例如 `toolchain/gnu-upstream` 分别为 amd64/arm64/riscv64 三个主机产出 sysroot 归档),在配方顶层以 Python/Starlark 循环显式注册多次即可: ```python for host in ("amd64", "arm64", "riscv64"): RUYI.build.schedule_build(_make_plan(host), name = host) ``` Starlark 本身提供的 `if/for/字符串格式化`等能力足以覆盖所有分支/矩阵场景,无需在配方格式之外再引入 TOML schema 或占位符插值语言。 ## 命令行接口 ``` ruyi admin build-package [-v, --var KEY=VALUE]... [-n, --name BUILD_NAME]... [--dry-run] [--output-dir DIR] ``` * `` — 指向要执行的配方文件的文件系统路径(绝对或相对)。 * `-v / --var` — 以 `KEY=VALUE` 形式注入到 `ctx.var(...)` 可读取的变量空间;可重复。 * `-n / --name` — 仅执行指定名称的调度构建;可重复;缺省时执行配方内全部注册构建。 * `--dry-run` — 执行加载与计划构建阶段,但跳过 `subprocess` 实际执行;打印渲染后的 argv、env、cwd 以供审核。 * `--output-dir` — 覆盖项目标记文件中声明的默认 `output_dir`。 退出码:成功为 `0`;子进程失败原样透传其退出码;校验错误(标记文件不存在、配方未注册任何构建、`ctx.var` 无默认值且未传入等)为 `2`。 ## 构建报告 对每一个成功执行的调度构建,宿主在产物目录下写入一个机器可读的构建报告: ``` /_ruyi-build-report....toml ``` 内容包括:配方文件路径、构建名称、解析后的 argv/env/cwd、所有匹配到的产物绝对路径、每个产物的大小与 sha256/sha512,以及执行时间戳。该报告同时是未来可能实现的 `ruyi admin publish-package` 命令的消费对象。 为便于操作者将构建结果补入 `packages-index`,命令在成功结束时向标准输出打印一段可直接粘贴的 `[[distfiles]]` TOML 片段。当前版本不自动修改任何 `packages-index` 清单。 ## 安全模型 * **信任模型与现有插件一致**:配置或直接调用一个配方仓库,等同于以当前用户身份运行该仓库内任意代码。`ruyi` 不对配方内 `ctx.subprocess(...)` 的 argv 做允许列表校验;信任来源是"用户主动指向了该 `.star` 文件所属项目"。 * **项目边界**由标记文件**客观决定**,不依赖环境变量、工作目录或 git 状态,从而避免"误把某个随意目录当作项目根"的隐性风险。 * **路径逃逸**:`ruyi-build://`、`ruyi-build-data://`、`ctx.repo_path(...)` 均在 realpath 解析后强制路径仍位于项目根下;`ctx.artifact(root=...)` 的绝对路径必须落在 `extra_artifact_roots` 白名单中。 * **模块加载上下文隔离**:`build-recipe-v1` feature 仅在以构建配方身份加载时启用;普通 `packages-index` 插件无法访问 `RUYI.build`;构建配方无法调用 `RUYI.call_subprocess_argv` 绕过 `ctx.subprocess` 的计划化管控。 ## 与现状的关系、演进路径 * **`packages-index` 零改动**:不新增软件包 kind,不新增 category,不影响 `ruyi list` / `ruyi install` 等终端用户命令。 * **`ruyici` 成为官方配方仓库**:仅需增加一份 `ruyi-build-recipes.toml`、`lib/docker.star`、`lib/images.star`,以及若干初始配方文件。现有 `ruyi-build-*` 外层脚本可保留用于手动调试,但不再作为 `ruyi admin build-package` 的入口。官方仓库可分阶段逐步将各 driver 从外层 bash 脚本迁移至 Starlark helper 库。 * **`ruyi` 核心只新增装配代码与一个 admin 子命令**:不新增 package manager 概念,不修改既有 schema。 后续工作(不在本设计范围内): 1. `ruyi admin publish-package`:串联构建、校验、上传、更新 `packages-index` 清单、提交 PR,将当前"构建报告→人工粘贴"的最后一步也自动化。 2. 构建镜像摘要固定与重现性校验:将 builder image 的 `@sha256:...` 摘要写入标记文件或配方,执行时校验漂移。此能力可在"配方空间"或"核心"任一侧实现。 3. 并行调度与缓存策略:在单次 `build-package` 调用内并行执行多个调度构建,或基于内容寻址跳过已完成的构建。 ruyisdk-ruyi-1f00e2e/docs/building.md000066400000000000000000000113761520522431500176540ustar00rootroot00000000000000# `ruyi` 的构建方式 为了[让构建产物可复现](https://reproducible-builds.org/),`ruyi` 默认使用基于 Docker 的构建方式。但考虑到调试、复杂的发行版打包场景、为非官方支持架构打包等等因素,`ruyi` 的构建系统也支持以环境变量的形式被调用。 ## 官方支持架构列表 目前 RuyiSDK 官方支持的架构有: |`dist.sh` 架构名|`uname -m` 输出| |----------------|---------------| |`amd64`|`x86_64`| |`arm64`|`aarch64`| |`riscv64`|`riscv64`| 在这些架构上,目前 RuyiSDK 官方支持的操作系统是 Linux。 如果一个架构与操作系统的组合没有出现在这里,其实也有很大可能 `ruyi` 能够在其上正常工作。事实上,只要 `ruyi` 涉及的少数原生扩展库能够在该系统上被构建、工作,那么 `ruyi` 就可以工作。目前这些库有: * [`pygit2`](https://pypi.org/project/pygit2/):涉及 `openssl`、`libssh2`、`libgit2`、`cffi` 请注意:因为 RuyiSDK 官方软件源中的软件包目前主要以二进制方式分发,且 RuyiSDK 团队只会为官方支持的架构、操作系统提供二进制包,所以尽管您可以为非官方支持的架构或操作系统构建出 `ruyi`,但这样构建出的 `ruyi` 用途可能十分有限。如果您仍然准备这样做,您需要有**自行维护一套“平行宇宙”软件源**的预期。 ## Linux 环境下基于 Docker 的构建 如果不需要什么特殊定制,`ruyi` 的构建方法十分简单。因为会使用预制的构建容器镜像的缘故,在宿主方面需要做的准备工作很少。您只需要确保: * bash 版本大于等于 4.0, * `docker` 可用, * GitHub 容器镜像源 `ghcr.io` 可访问, 便可在 `ruyi` 仓库根目录下执行: ```sh # 为当前(宿主)架构构建 ruyi # 仅保证在官方支持架构上正常工作 ./scripts/dist.sh # 也可以明确指定目标架构 # 受限于 Nuitka 工作原理,必须使用目标架构的 Python 执行构建。 # 因此如果您需要交叉构建,则需要首先自行配置 QEMU linux-user binfmt_misc ./scripts/dist.sh riscv64 ``` 许多发行版的 QEMU linux-user 模拟器包都会自带 binfmt\_misc 配置,例如在 systemd 系统上,往 `/etc/binfmt.d` 安装相应的配置文件。由于模拟器的执行环境在 Docker 容器内,因此需要使用静态链接的 QEMU linux-user 模拟器,并且您需要确保 binfmt\_misc 配置中使用了 `F` (freeze) flag 以保证从未经修改的目标架构 sysroot 中也能访问到模拟器程序。 ## Linux 环境下非基于 Docker 的构建 对于没有条件运行 Docker,或者官方未提供适用的构建容器镜像等等场合,您只能选择非基于 Docker 的构建方式。您需要自行准备环境: * Python 版本:详见 `pyproject.toml`。目前官方使用的 Python 版本为 3.12.x。 * 需要在 `PATH` 中有以下软件可用: * 所有情况下 * `poetry` * 需要现场编译原生扩展的情况下 * `auditwheel` * `cibuildwheel` * `maturin` 如果您的架构、操作系统不在官方支持的列表,那么 `scripts/dist.sh` 将发出警告并自动切换为非 Docker 的构建。不过,如果您的环境实际上支持 `docker` 并且您仿照 `scripts/dist-image` 中的官方构建容器镜像描述自行打包了您环境的构建容器镜像,您也可以强制使用 Docker 构建: ```sh export RUYI_DIST_FORCE_IMAGE_TAG=your-account/your-builder-image:tag # 如果您的架构的 Docker 架构名(几乎总是等价于 GOARCH)与 dist.sh 或曰 Debian # 架构名不同,则设置 RUYI_DIST_GOARCH # 此处假设您的架构在 `uname -m` 叫 foo64el,在 Debian 叫 foo64,在 Go 叫 foo64le export RUYI_DIST_GOARCH=foo64le ./scripts/dist.sh foo64 ``` 可以设置以下的环境变量来覆盖它们各自的默认取值。以下约定: * 以 `$REPO_ROOT` 表示 `ruyi` 仓库的 checkout 路径, * 以 `$ARCH` 表示 `scripts/dist.sh` 接受的参数,即 Debian 式的目标架构名。 |变量名|含义|默认取值| |------|----|--------| |`CCACHE_DIR`|ccache 缓存|`$REPO_ROOT/tmp/ccache.$ARCH`| |`MAKEFLAGS`|`make` 默认参数|`-j$(nproc)`| |`POETRY_CACHE_DIR`|Poetry 缓存|`$REPO_ROOT/tmp/poetry-cache.$ARCH`| |`RUYI_DIST_BUILD_DIR`|`ruyi` 构建临时目录|`$REPO_ROOT/tmp/build.$ARCH`| |`RUYI_DIST_CACHE_DIR`|`ruyi` 构建系统缓存|`$REPO_ROOT/tmp/ruyi-dist-cache.$ARCH`| 请自行翻阅源码以了解更详细的行为。如果此文档没有收到及时更新,也应以源码行为为准。 ## Windows 环境下的构建 除了使用 PowerShell 以及 Windows 各种惯例之外,Windows 下构建 `ruyi` 的方式与 Linux 环境下的非基于 Docker 的构建很相似。请参考 GitHub Actions 中的相应定义。 ruyisdk-ruyi-1f00e2e/docs/ci-release-automation.md000066400000000000000000000034541520522431500222440ustar00rootroot00000000000000# CI: 自动化版本发布 为方便、规范 RuyiSDK 的发版工作,有必要将这些工作自动化。目前,RuyiSDK 包管理器(`ruyi`,也即本仓库)已经接入了自动化发版机制。 ## `ruyi` 的发版自动化 详见本仓库的 GitHub Actions workflow 定义。 ### RuyiSDK 镜像源的同步 在 GHA 自动创建 Release 之后,为将此 release 同步进 RuyiSDK 镜像源,从而方便境内用户下载使用,需要额外做一些工作。 理想情况下,这可用一个监听 release 类型消息的 GitHub Webhook 实现,但这要求部署一个 HTTP 服务,从而造成很大的额外运维成本。因此,目前使用一种开销相对较高但仍可接受的方式:轮询,来实现与 GitHub Release 的同步。 首先准备一台既能访问 GitHub API、GitHub Release assets,又有权限访问 RuyiSDK rsync 镜像源的 Linux 主机,用来部署 helper 服务。 在此主机上准备一个 `ruyi-backend` 的开发环境: ```sh git clone https://github.com/ruyisdk/ruyi-backend.git cd ruyi-backend # 略过了初始化 Python virtualenv 的步骤 poetry install ``` 准备一个目录,用于存储 rsync 同步状态与相关的 release assets: ```sh # 假设以 /opt/ruyi-tmp-rsync 为状态目录 mkdir /opt/ruyi-tmp-rsync ``` 配置系统,使此任务被周期性执行。您可参考 [ruyi-backend 项目随附的 systemd 单元定义][systemd-example-units]: [systemd-example-units]: https://github.com/ruyisdk/ruyi-backend/tree/main/examples/systemd ```sh # 把示例单元文件复制到 /etc/systemd/system/ 然后调整其内容以适应您的环境 systemctl daemon-reload systemctl enable ruyi-ci-sync-release.timer ``` 后续,应不时更新此 `ruyi-backend` checkout,并跟进依赖版本变更、此处的流程变更等等。 ruyisdk-ruyi-1f00e2e/docs/ci-self-hosted-runner.md000066400000000000000000000061651520522431500221740ustar00rootroot00000000000000# Repo CI: Self-hosted runner 管理 目前 GitHub Actions 官方提供的 runners 仅支持 amd64 架构,且官方的 self-hosted runner 支持仅覆盖 amd64 与 arm64 架构。鉴于 `ruyi` 需要支持 amd64、arm64 与 riscv64 三种架构,并且 Nuitka [架构上无法支持交叉编译](https://github.com/Nuitka/Nuitka/issues/43)而 QEMU 模拟下的 Nuitka 又很慢(半小时左右),因此总之我们都需要自行维护一些 runners 以使 CI 运行速度不至于过分缓慢。 ## riscv64 runner GitHub Actions Runner 官方暂未提供 riscv64 架构支持,所幸社区已有勇士将流程走通。 我们使用的是 [dkurt/github_actions_riscv](https://github.com/dkurt/github_actions_riscv) 项目提供的成品包。 有一些地方需要注意: * v2.312.0 中的 `externals/node16` 未替换为 riscv64 二进制,这会导致 `actions/cache@v4` 等 action 运行失败。需要手动编译替换。 * 如果宿主系统是 Debian 系的发行版:Runner 依赖 `docker` 但发行版未打包。 需要安装 `podman` 并做些特殊处理。 ### 替换 Node.js 16.x v2.312.0 中的 `node16` 是 16.20.2 这个当下最新的 LTS 版本。应该不用非得是这个版本。 去 https://nodejs.org/download/release/latest-v16.x/ 下载源码,解压,然后构建 tarball: ```sh # 以 v16.20.2 为例 tar xf node-v16.20.2.tar.xz cd node-16.20.2 # 如果准备启用 LTO,可能需要调整 LTO 并发数,否则默认的 4 喂不饱一些核数多的硬件 # vim common.gypi # 寻找 flto=4 的字样并调整之 # 自行调整并发 # 该版本 node 自带的 openssl 无法以默认参数通过编译(系统会被探测为 x86_64), # 因此需要在系统级别安装 libssl-dev 并动态链接之 # 为了提高构建速度,使用 Ninja (apt-get install ninja-build) make binary -j64 CONFIG_FLAGS='--enable-lto --ninja --shared-openssl' ``` 然后替换 GHA runner 的 `externals/node16`: ```sh cd /path/to/gha/externals rm -rf node16 tar xf /path/to/your/node-v16.20.2-linux-riscv64.tar.xz mv node-v16.20.2-linux-riscv64 node16 ``` ### 配置 podman 本库使用基于容器的 CI 配置,因此需要在 runner 宿主上准备好容器运行时。由于目前 Debian riscv64 port 没有打包 `docker`,我们换用 `podman`。但 GitHub Actions runner 官方[暂未支持 `podman`](https://github.com/actions/runner/issues/505),因此也需要一些特殊处理。 为了避免不必要的麻烦,最好在 GHA 以自己的用户身份第一次发起 `podman` 调用之前执行。 ```sh cd /usr/local/bin sudo ln -s /usr/bin/podman docker cd /var/run sudo ln -s podman/podman.sock docker.sock # 本例中 GHA runner 以 gha 用户身份执行,系统上已分配了 100000-231071 的 subuid/subgid 范围 # 请自行调整 sudo usermod --add-subuids 231072-296607 --add-subgids 231072-296607 gha ``` 阅读材料: * 关于 `cannot re-exec process` 相关错误 - https://github.com/containers/podman/issues/9137 - https://github.com/containers/podman/issues/14635 * [Podman rootless mode tutorial](https://github.com/containers/podman/blob/v4.9/docs/tutorials/rootless_tutorial.md) ruyisdk-ruyi-1f00e2e/docs/dep-baseline.md000066400000000000000000000116731520522431500204070ustar00rootroot00000000000000# `ruyi` 的依赖兼容基线 为降低发行版打包的工作量,以及保证非单文件形式分发的 `ruyi` 能与发行版在系统级提供的各种依赖组件正常交互,有必要认真对待 `ruyi` 的各种依赖的版本。在实现或修复某些功能的时候,如果涉及新增依赖或变更依赖版本,需要谨慎行事。 本文档是对 RuyiSDK 文档站[《RuyiSDK 的平台支持情况》](https://ruyisdk.org/docs/Other/platform-support/)一文,从开发角度进行的补充:为了实现 RuyiSDK 所承诺的平台兼容性,在代码层面需要考虑的各项依赖的最低版本。 以下是 `ruyi` 重点依赖的 **架构相关** 软件包在一些发行版的提供情况: | 发行版版本 | 年代 | glibc | Python | pygit2 | pyyaml | |-------------------------|------|-------|--------|--------|------------------------| | Debian 12 | 2023 | 2.36 | 3.11 | 1.11.1 | 6.0 [^debian-pyyaml] | | Debian 13 | 2025 | 2.41 | 3.13 | 1.17.0 | 6.0.2 [^debian-pyyaml] | | deepin 25 | 2025 | 2.38 | 3.12 | :x: | 6.0.1 [^debian-pyyaml] | | Fedora 42 | 2025 | 2.41 | 3.13 | 1.17.0 | 6.0.2 | | Fedora 43 | 2025 | 2.42 | 3.14 | 1.18.2 | 6.0.2 | | Fedora 44 [^pre] | 2026 | 2.43 | 3.14 | 1.19.2 | 6.0.3 | | OpenCloudOS 9.4 | 2025 | 2.38 | 3.11 | 1.12.2 | 6.0.1 | | openEuler 24.03 LTS SP2 | 2025 | 2.38 | 3.11 | :x: | 6.0.1 | | openEuler 24.03 LTS SP3 | 2025 | 2.38 | 3.11 | :x: | 6.0.1 | | openEuler 25.09 | 2025 | 2.38 | 3.11 | :x: | 6.0.2 | | openKylin 2.0 | 2024 | 2.38 | 3.12 | :x: | 6.0.1 [^debian-pyyaml] | | openRuyi 2026.03 | 2026 | 2.42 | 3.13 | :x: | :x: | | Ubuntu 24.04 LTS | 2024 | 2.39 | 3.12 | 1.14.1 | 6.0.1 [^debian-pyyaml] | | Ubuntu 26.04 LTS [^pre] | 2026 | 2.43 | 3.14 | 1.19.1 | 6.0.3 [^debian-pyyaml] | [^pre]: 尚未正式发布,但软件包版本已在一定程度上冻结 [^debian-pyyaml]: 包名为 `python3-yaml` 以下是 `ruyi` 依赖的 **架构无关** 软件包在一些发行版的提供情况: | 发行版版本 | argcomplete | arpy | babel | certifi | fastjsonschema | jinja2 | requests | rich | semver | tomlkit | typing\_extensions | | ----------------------- | ----------- | ----- | ------ | ---------- | -------------- | ------ | -------- | ------ | ------ | ------- | ------------------ | | Debian 12 | 3.6.2 | 1.1.1 | 2.10.3 | 2020.6.20 | 2.16.3 | 3.0.3 | 2.25.1 | 11.2.0 | 2.10.2 | 0.9.2 | 3.10.0.2 | | Debian 13 | 3.6.2 | 1.1.1 | 2.17.0 | 2025.1.31 | 2.21.1 | 3.1.6 | 2.32.3 | 13.9.4 | 3.0.2 | 0.13.2 | 4.13.2 | | deepin 25 | 3.6.2 | 1.1.1 | 2.17.0 | 2023.11.17 | 2.19.1 | 3.1.3 | 2.32.4 | 13.7.1 | 2.10.2 | 0.11.7 | 4.12.2 | | Fedora 42 | 3.6.2 | 2.3.0 | 2.17.0 | 2024.08.30 | 2.21.1 | 3.1.6 | 2.32.3 | 13.9.4 | 3.0.2 | 0.13.2 | 4.12.2 | | Fedora 43 | 3.6.3 | 2.3.0 | 2.18.0 | 2025.07.09 | 2.21.2 | 3.1.6 | 2.32.5 | 14.1.0 | 3.0.4 | 0.13.2 | 4.15.0 | | Fedora 44 [^pre] | 3.6.3 | 2.3.0 | 2.18.0 | 2026.01.04 | 2.21.2 | 3.1.6 | 2.32.5 | 14.3.2 | 3.0.4 | 0.13.2 | 4.15.0 | | OpenCloudOS 9.4 | 3.1.2 | :x: | 2.12.1 | 2023.7.22 | 2.18.0 | 3.1.4 | 2.32.3 | 13.5.3 | 3.0.1 | 0.12.1 | 4.7.1 | | openEuler 24.03 LTS SP2 | 3.2.2 | :x: | 2.12.1 | 2024.2.2 | 2.19.1 | 3.1.3 | 2.31.0 | 13.7.1 | 3.0.2 | 0.13.2 | 4.12.2 | | openEuler 24.03 LTS SP3 | 3.2.2 | :x: | 2.12.1 | 2024.2.2 | 2.19.1 | 3.1.3 | 2.31.0 | 13.7.1 | 3.0.2 | 0.13.3 | 4.12.2 | | openEuler 25.09 | 3.6.2 | :x: | 2.17.0 | 2025.6.15 | 2.21.1 | 3.1.6 | 2.32.3 | 13.9.4 | 3.0.4 | 0.13.3 | 4.14.0 | | openKylin 2.0 | 1.8.1 | 1.1.1 | 2.10.3 | 2023.11.17 | :x: | 3.1.2 | 2.31.0 | :x: | 2.0.1 | :x: | 4.10.0 | | openRuyi 2026.03 | :x: | :x: | :x: | :x: | :x: | 3.1.6 | :x: | :x: | :x: | :x: | :x: | | Ubuntu 24.04 LTS | 3.1.4 | 1.1.1 | 2.10.3 | 2023.11.17 | 2.19.0 | 3.1.2 | 2.31.0 | 13.7.1 | 2.10.2 | 0.12.4 | 4.10.0 | | Ubuntu 26.04 LTS [^pre] | 3.6.3 | 1.1.1 | 2.17.0 | 2026.1.4 | 2.21.1 | 3.1.6 | 2.32.5 | 13.9.4 | 3.0.2 | 0.13.3 | 4.15.0 | ruyisdk-ruyi-1f00e2e/docs/multi-repo-design.md000066400000000000000000000427731520522431500214300ustar00rootroot00000000000000# 多软件源支持(Overlay 架构)设计文档 ## 背景与动机 目前 Ruyi 仅支持单一元数据软件源(`packages-index`),通过 `GlobalConfig.repo` 以单例形式进行配置。这在以下场景中存在局限: 1. **厂商/OEM 软件源** — 硬件厂商应能够提供额外的或经过定制的工具链、板卡映像和设备安装策略,而无需 fork 或修改官方软件源。 2. **实验用途** — 开发者在实验新软件包、profile 或插件时,需要一种可在本地快速迭代的方式,无需将内容发布到官方软件源。 3. **企业/内部软件包** — 组织可能需要永远不会发布到上游的私有二进制软件包。 4. **带有附加内容的区域镜像** — 某些地区的镜像站可能希望捆绑补充内容。 Gentoo 的 overlay 系统解决了 ebuild 软件源中类似的问题,提供了一套久经验证的设计思路。我们借鉴的核心原则如下: * 有序的软件源集合,每个可独立拉取。 * 当同一软件包(以 `category/name` 标识)出现在多个软件源中时,通过明确的**优先级**解析顺序确定生效版本。 * 每个软件源具备自描述能力(`config.toml`、软件源 ID、名称)。 * Overlay 软件源可以**新增**软件包,也可以**遮蔽**(覆盖)低优先级软件源中的特定版本。 ## 设计概览 ``` ┌──────────────────────────────────────────────┐ │ ruyi CLI │ ├──────────────────────────────────────────────┤ │ CompositeRepo │ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │ │ Repo 0 │ │ Repo 1 │ │ Repo 2 │ ... │ │ │pri = 0 │ │pri = 50│ │pri =100│ │ │ │ 官方 │ │ 厂商 │ │ 本地 │ │ │ └────────┘ └────────┘ └────────┘ │ ├──────────────────────────────────────────────┤ │ ProvidesPackageManifests(协议不变) │ └──────────────────────────────────────────────┘ ``` 本设计引入两个新概念: 1. **`RepoEntry`** — 指向单个元数据软件源的配置条目,附带其元信息(ID、名称、优先级、是否启用)。 2. **`CompositeRepo`** — 聚合体,通过按优先级合并所有活跃 `MetadataRepo` 实例的结果来实现 `ProvidesPackageManifests` 协议。 ## 详细设计 ### 1. 软件源配置 #### 1.1 配置文件 schema 用户级配置文件(`~/.config/ruyi/config.toml`)新增 `[[repos]]` 表数组(table-array)段落。现有的 `[repo]` 段落保留以确保向后兼容,其隐式定义"默认"软件源条目。 ```toml # 现有段落 — 保留以确保向后兼容。 # 如存在,这些字段仅适用于"默认"软件源(软件源 ID 为 # "ruyisdk")。 [repo] remote = "https://github.com/ruyisdk/packages-index.git" branch = "main" # local = "/path/to/override" # 可选的绝对路径覆盖 # 新增:额外的软件源,按声明的优先级排序。 [[repos]] id = "my-vendor" name = "My Vendor Overlay" remote = "https://git.example.com/my-vendor/ruyi-overlay.git" branch = "main" priority = 50 # active = true # 默认值 [[repos]] id = "local-testing" name = "本地测试 overlay" local = "/home/user/ruyi-local-overlay" priority = 100 ``` 每个 `[[repos]]` 条目的字段说明: | 字段 | 类型 | 必填 | 默认值 | 说明 | |------------|--------|--------|-------------|------| | `id` | string | 是 | — | 该软件源的唯一标识符,须匹配 `^[a-z0-9][a-z0-9_-]*$`。 | | `name` | string | 否 | 与 `id` 相同 | 人类可读的显示名称。 | | `remote` | string | 条件必填 | — | Git 远程 URL。在未设置 `local` 时必填。 | | `branch` | string | 否 | `"main"` | 要追踪的 Git 分支。 | | `local` | string | 否 | — | 指向本地 checkout 的绝对路径。若设置,`remote`/`branch` 仅在 `ruyi update` 时使用。 | | `priority` | int | 否 | `50` | 优先级**更高**的软件源将遮蔽优先级更低的同名包。默认的官方软件源优先级为 0。 | | `active` | bool | 否 | `true` | 该软件源是否参与解析。 | 由旧版 `[repo]` 段落(或内置默认值)派生的隐式"默认"软件源条目,始终为 `id = "ruyisdk"`、`priority = 0`。 #### 1.2 软件源内部的身份标识 每个软件源的 `config.toml`(位于 git 工作树内)已经支持带有可选 `id` 和 `name` 字段的 `[repo]` 段落: ```toml ruyi-repo = "v1" [repo] id = "my-vendor" name = "My Vendor Overlay" ``` 当此软件源内部身份标识存在时,Ruyi 在同步时将其与用户配置中的条目进行比较,如不匹配则发出警告,但**用户配置中的值始终优先**,以防止劫持。 #### 1.3 全局/系统级配置 发行版打包者和系统管理员可以在系统级配置文件中放置软件源条目(`/usr/share/ruyi/config.toml`、`/usr/local/share/ruyi/config.toml` 或 XDG 配置目录)。这些条目的行为与用户配置中的条目完全相同,但以更低的优先级加载,可被用户覆盖(或停用)。 ### 2. 磁盘布局 每个软件源的本地 checkout 位于缓存目录下以软件源为单位的子目录中: ``` ~/.cache/ruyi/ ├── packages-index/ # 旧版 — 首次运行时迁移 └── repos/ ├── ruyisdk/ # 官方软件源(从 packages-index/ 迁移而来) ├── my-vendor/ # overlay 软件源 └── local-testing/ # 若设置了 `local`,则为符号链接或直接路径 ``` **迁移**:升级后首次运行时,若旧版 `packages-index/` 目录存在而 `repos/ruyisdk/` 不存在,Ruyi 会将旧目录移动(或创建符号链接)到新位置,并输出提示信息。 `GlobalConfig.get_repo_dir()` 方法将被替换为 `GlobalConfig.get_repo_dir(repo_id: str)`,返回 `/repos/`。 ### 3. 核心抽象 #### 3.1 `RepoEntry` 数据类 ```python @dataclass class RepoEntry: """已配置的软件源指针。""" id: str name: str remote: str | None branch: str local_path: str | None # 绝对路径覆盖 priority: int # 值越高 = 冲突时优先 active: bool @cached_property def metadata_repo(self) -> MetadataRepo: """惰性构建此条目对应的 MetadataRepo。""" ... ``` `RepoEntry` 是一个轻量级、可序列化的值对象。较重的 `MetadataRepo` 仅在该条目处于活跃状态时惰性构建。 #### 3.2 `CompositeRepo` ```python class CompositeRepo(ProvidesPackageManifests): """按优先级顺序聚合多个 MetadataRepo 实例。""" def __init__(self, entries: list[RepoEntry]) -> None: # 按优先级升序排列;高优先级的软件源遮蔽低优先级的 self._entries = sorted(entries, key=lambda e: e.priority) # --- ProvidesPackageManifests 实现 --- def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]: ... def iter_pkgs(self) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: ... def get_pkg(self, name, category, ver) -> BoundPackageManifest | None: ... def get_pkg_latest_ver(self, name, category=None, ...) -> BoundPackageManifest: ... def get_pkg_by_slug(self, slug) -> BoundPackageManifest | None: ... # --- 聚合特有方法 --- def sync_all(self) -> None: """同步所有活跃的软件源。""" ... def iter_repos(self) -> Iterable[MetadataRepo]: """按优先级顺序遍历所有活跃的软件源。""" ... ``` #### 3.3 `GlobalConfig` 变更 ```python class GlobalConfig: ... @cached_property def repo_entries(self) -> list[RepoEntry]: """所有已配置的软件源条目,包括默认条目。""" ... @cached_property def repo(self) -> CompositeRepo: """聚合软件源(替代原来的单一 MetadataRepo)。""" return CompositeRepo(self.repo_entries) # 为兼容性保留 — 仅返回默认/官方软件源。 @cached_property def default_repo(self) -> MetadataRepo: ... ``` `repo` 属性的返回类型从 `MetadataRepo` 变更为 `CompositeRepo`。由于 `CompositeRepo` 实现了 `ProvidesPackageManifests` — 即代码库中已广泛使用的同一协议 — 大多数调用点无需修改。需要访问特定软件源功能(插件、profile、配置、新闻、实体存储)的调用点,须通过 `CompositeRepo.iter_repos()` 改为对单个 `MetadataRepo` 实例进行操作。 ### 4. 软件包解析 #### 4.1 合并语义 软件源按**优先级升序**排列。在遍历所有软件包时,`CompositeRepo` 产出所有软件源中软件包的并集。当同一 `(category, name, version)` 三元组存在于多个软件源中时,来自**最高优先级**软件源的实例胜出(遮蔽其他实例)。 这与 Gentoo 的 overlay 行为一致:高优先级的 overlay 可以替换低优先级软件源中的任意 ebuild 版本。 ``` get_pkg("gcc-cross", "toolchain", "14.1.0") 的解析过程: 软件源 "ruyisdk" (pri 0): toolchain/gcc-cross/14.1.0.toml ← 被遮蔽 软件源 "my-vendor" (pri 50): toolchain/gcc-cross/14.1.0.toml ← 胜出 软件源 "local-test" (pri100): (不存在) ``` #### 4.2 版本列举 `iter_pkg_vers(name, category)` 返回跨所有软件源的合并版本集。若同一版本字符串存在于多个软件源中,则仅出现最高优先级的实例。这确保 `get_pkg_latest_ver` 始终返回经过完整优先级栈解析后的单一最新版本。 #### 4.3 Slug 唯一性 Slug 在聚合集合中是全局唯一的。若两个软件源定义了具有相同 slug 的软件包,高优先级的软件源胜出,并为被遮蔽的 slug 记录警告日志。 #### 4.4 Atom 解析 `Atom.match_in_repo` 和 `Atom.iter_in_repo` 已接受 `ProvidesPackageManifests`,因此无需修改即可与 `CompositeRepo` 配合使用。 ### 5. Profile、插件与实体 这些是软件源特有的资源。`CompositeRepo` 对它们进行聚合: * **Profile**:按架构合并。若两个软件源定义了相同的 `(arch, profile_id)`,高优先级软件源的定义胜出。`CompositeRepo.get_profile(name)` 按优先级降序遍历软件源,返回首个匹配项。 * **插件**:插件 ID 是全局作用域的。当同一插件 ID 存在于多个软件源中时,加载高优先级软件源的插件。每个软件源创建独立的 `PluginHostContext`;各软件源的插件默认仅能访问自身软件源的文件系统。 * **实体**:`EntityStore` 已支持接受多个 `BaseEntityProvider` 实例。`CompositeRepo` 提供的 `entity_store` 将来自所有软件源的 provider 串联起来,高优先级的 provider 优先注册。 * **新闻**:来自所有软件源的新闻条目被聚合在一起。每条新闻附带其来源软件源 ID 以供展示。已读状态追踪保持全局统一。 * **消息**:`RepoMessageStore` 实例仍然按软件源独立维护。在渲染某个软件包的消息时,使用该软件包所属软件源的消息存储(通过 `BoundPackageManifest.repo` 访问)。 ### 6. 同步 / 更新 `ruyi update` 同步所有活跃的软件源: ``` $ ruyi update 正在更新软件源 "ruyisdk"(RuyiSDK 官方软件源)... 正在从 https://github.com/ruyisdk/packages-index.git 拉取... 完成。 正在更新软件源 "my-vendor"(My Vendor Overlay)... 正在从 https://git.example.com/my-vendor/ruyi-overlay.git 拉取... 完成。 软件源已更新。 ``` 可通过 `ruyi update --repo ` 单独同步某个软件源。 对于设置了 `local` 但未设置 `remote` 的软件源,将跳过 git pull 步骤(视为由外部管理),除非同时提供了 `remote` 以支持"按需同步"语义。 ### 7. CLI 命令 新增 `ruyi repo` 命令组用于管理软件源配置: ``` ruyi repo list # 列出已配置的软件源及其状态 ruyi repo add [opts] # 添加新的 overlay 软件源 ruyi repo remove # 移除软件源条目 ruyi repo enable # 设置 active = true ruyi repo disable # 设置 active = false ruyi repo set-priority # 变更优先级 ``` `ruyi repo list` 示例输出: ``` 已配置的软件源(按优先级从高到低): ● local-testing pri=100 (local: /home/user/ruyi-local-overlay) ● my-vendor pri=50 https://git.example.com/my-vendor/ruyi-overlay.git ● ruyisdk pri=0 https://github.com/ruyisdk/packages-index.git [默认] ``` `ruyi repo add` 命令写入用户的本地配置文件(`~/.config/ruyi/config.toml`)。系统级配置提供的条目不能从用户配置中删除,只能停用。 ### 8. 遥测 现有的遥测基础设施已在 `TelemetryScope` 中包含 `repo_name` 字段: ```python class TelemetryScope: def __init__(self, repo_name: str | None) -> None: self.repo_name = repo_name ``` 支持多软件源后,`TelemetryProvider.init_store` 将为每个声明了 `[[telemetry]]` 段落的软件源调用,创建各自的遥测存储。`telemetry/provider.py:161` 处现有的 TODO 将通过遍历 `CompositeRepo.iter_repos()` 而非硬编码 `"ruyisdk"` 来解决。 未声明 `[[telemetry]]` 段落的 overlay 软件源不会有遥测存储 — 其软件包仍被追踪在 PM 级别的遥测范围下。 ### 9. 状态与安装追踪 `PackageInstallationRecord` 已携带 `repo_id` 字段,因此即使跨多个软件源,安装记录也能正确归属。无需变更 schema。 在检查可升级软件包时(`BoundInstallationStateStore.iter_upgradable_pkgs`),将使用已安装软件包的 `repo_id` 优先在其对应软件源中查找最新版本,然后在所有软件源中查找可能已迁移到其他软件源的包。 ### 10. 迁移计划 分阶段过渡以尽量减少对现有用户的影响: #### 阶段一 — 内部重构(非破坏性) * 引入 `RepoEntry` 和 `CompositeRepo`,放置于 `GlobalConfig.repo` 之后。 * 聚合体仅包含一个条目(当前的默认软件源),因此行为不变。 * 将磁盘布局迁移至 `repos//`,从旧的 `packages-index/` 路径创建兼容性符号链接。 * 更新所有访问 `MetadataRepo` 特有方法(插件、profile、新闻、实体存储)的调用点,改为通过 `CompositeRepo` 的聚合辅助方法进行访问。 #### 阶段二 — 多软件源配置支持 * 从配置文件中解析 `[[repos]]`。 * 实现 `ruyi repo {list,add,remove,enable,disable,set-priority}`。 * 在 `CompositeRepo` 中实现聚合解析。 * 更新 `ruyi update` 以同步所有软件源。 #### 阶段三 — 细化与文档 * 面向用户的文档和迁移指南。 * 包含多软件源 fixture 的集成测试。 * 稳定 overlay 软件源格式(明确对纯 overlay 软件源的约束,如是否必须声明 `config.toml`)。 * 在有足够采用率之后移除旧的 `[repo]` 单软件源代码路径(保留配置解析以确保向后兼容)。 ### 11. 安全考量 * **信任模型**:Overlay 软件源扩展了信任边界。用户必须通过 `ruyi repo add` 显式添加软件源。系统级配置可以预装发行版信任的软件源。 * **插件沙箱**:现有的插件宿主不提供沙箱(这已在文档中明确说明)。每个 overlay 软件源的插件以相同权限运行。多软件源支持不改变这一点,但按软件源维护插件加载边界,使得某个 overlay 的插件无法通过插件宿主 API 直接访问其他软件源的文件。 * **配置劫持**:overlay 的 `config.toml` 无法更改用户配置级别的软件源 ID 或优先级。软件源内部的身份标识仅供参考,不匹配时会发出警告。 * **名称抢注**:overlay 可能会用恶意内容遮蔽官方软件包。通过要求显式的优先级分配,以及在日志中记录每个已安装软件包来自哪个软件源来缓解此风险。 ### 12. 待讨论问题 1. **overlay 中的 `config.toml` 是否应为必需?** Gentoo 要求 `profiles/repo_name`。我们可以至少要求包含 `ruyi-repo = "v1"` 和 `[repo] id = "..."`。 2. **跨软件源依赖**:overlay A 中的软件包是否应能声明对软件源 B 中软件包的依赖?目前软件包是独立的,解析是全局性的 — 不需要显式的跨软件源依赖声明。 3. **overlay 中的分发文件镜像**:overlay 是否应能定义自己的 `[[mirror]]` 条目?这些条目应当是 overlay 作用域的还是全局合并的?建议:默认为 overlay 作用域,可通过镜像声明中的 `global = true` 标志开启全局合并。 4. **软件源数量上限**:是否应有硬限制?Gentoo 不设上限。建议:不设硬限制,但当配置的软件源数量超过 20 个时发出警告(出于性能考虑)。 5. **虚拟环境的软件源固定**:虚拟环境当前在 `ruyi-venv.toml` 中按 `repo_id` 记录软件包。支持多软件源后,虚拟环境应继续记录 `repo_id`,以便重新创建时指向同一软件源。若某软件源后续被移除,虚拟环境可回退到在剩余软件源中进行解析,并发出警告。 ruyisdk-ruyi-1f00e2e/docs/multi-repo.md000066400000000000000000000126511520522431500201510ustar00rootroot00000000000000# 多软件源支持(用户文档) Ruyi 支持同时配置多个软件包软件源(repo)。多个软件源按照优先级叠加: 优先级更高的软件源,会遮蔽低优先级软件源中类别、名称、版本均相同的软件包。 默认的 `ruyisdk` 软件源始终存在,优先级固定为 0。 ## 配置方式 额外的软件源通过用户配置文件(`$XDG_CONFIG_HOME/ruyi/config.toml`)中的 TOML array-of-tables 语法声明,例如: ```toml [[repos]] id = "my-overlay" name = "My Overlay Repo" remote = "https://git.example.com/overlay.git" branch = "main" priority = 100 active = true [[repos]] id = "local-dev" local = "/home/user/repos/local-dev" priority = 50 active = true ``` ### 字段说明 | 字段 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | `id` | string | 是 | 无 | 软件源唯一标识(`[a-z0-9][a-z0-9_-]*`) | | `name` | string | 否 | 与 `id` 相同 | 供人阅读的软件源名称 | | `remote` | string | 否* | 无 | Git 远端 URL | | `branch` | string | 否 | `"main"` | 要跟踪的 Git 分支 | | `local` | string | 否* | 无 | 本地绝对路径,会覆盖默认缓存位置 | | `priority` | int | 否 | `0` | 数值越大,优先级越高 | | `active` | bool | 否 | `true` | 此软件源是否启用 | \* `remote` 和 `local` 至少要提供一个。 保留 ID `ruyisdk` 不能用于额外软件源;默认软件源应通过 `[repo]` 段进行配置。 ### 系统提供的软件源 在系统级配置文件(如 `/etc/xdg/ruyi/config.toml`、`/usr/share/ruyi/config.toml` 等)中声明的软件源,会被标记为“系统提供”。这些条目在 `ruyi repo list` 输出中会带有 `(system)` 标记,并且不能被移除,只能通过 `ruyi repo disable` 禁用。 ## CLI 命令 ### `ruyi repo list` 列出所有已配置的软件源,并按优先级从高到低排序。 ``` $ ruyi repo list * ruyisdk (default) priority=0 https://github.com/ruyisdk/packages-index.git * my-overlay priority=100 https://git.example.com/overlay.git local-dev priority=50 /home/user/repos/local-dev ``` * `*` 表示该软件源已启用。 * `(default)` 表示内建默认软件源。 * `(system)` 表示系统提供的软件源(如果有)。 ### `ruyi repo add [url] [options]` 向用户配置中新增一个软件源条目。 ``` ruyi repo add my-overlay https://git.example.com/overlay.git --priority 100 ruyi repo add local-dev --local /path/to/repo --priority 50 ruyi repo add mixed https://example.com/repo.git --local /path/to/cache --branch dev ``` 可用选项: * `--branch `:要跟踪的 Git 分支。 * `--priority `:优先级,默认为 0。 * `--local `:本地绝对路径。 * `--name `:供人阅读的软件源名称。 ### `ruyi repo remove [--purge]` 从用户配置中移除某个软件源。传入 `--purge` 时,还会一并删除磁盘上的缓存数据。 默认软件源(`ruyisdk`)和系统提供的软件源不能移除;如需停用,请改用 `ruyi repo disable`。 ### `ruyi repo enable ` / `ruyi repo disable ` 在保留配置条目的前提下启用或禁用某个软件源。被禁用的软件源不会进行同步, 其软件包也不会出现在列表和安装候选中。 ### `ruyi repo set-priority ` 修改某个软件源的优先级。 ### `ruyi update [--repo ]` 同步软件源元数据。不带 `--repo` 时会同步所有已启用的软件源;带 `--repo ` 时只同步指定软件源。 ## 优先级与遮蔽规则 当多个软件源提供同一个软件包(即类别、名称、版本都相同)时,将采用优先级更高的软件源版本。 各软件源中独有的软件包则始终可见,不受优先级影响。 例如:如果 `base`(优先级 0)提供 `toolchain/gcc 13.2.0`,而 `overlay` (优先级 100)也提供 `toolchain/gcc 13.2.0`,那么列表展示和安装时采用的都会是 `overlay` 中的那个版本。 ## 软件包列表展示 当配置了多个软件源时,`ruyi list` 的输出会包含 `[repo-id]` 标记,用于说明每个软件包来自哪个软件源。 ## 软件源目录布局 额外软件源默认存放在 `$XDG_CACHE_HOME/ruyi/repos//` 下。可以通过每个软件源的 `local` 字段覆盖这一默认位置。 为保持向后兼容,默认软件源仍使用旧路径 `$XDG_CACHE_HOME/ruyi/packages-index/`。 ## 创建一个 Overlay 软件源 Overlay 软件源本质上是一个标准的 Ruyi 元数据软件源,也就是一个 Git 仓库。 其最少需要包含以下内容: 1. 仓库根目录下的 `config.toml`: ```toml ruyi-repo = "v1" [repo] id = "my-overlay" [[mirrors]] id = "ruyi-dist" urls = ["https://example.com/dist/"] ``` 其中 `[repo].id` 应与用户配置中 `[[repos]]` 条目的 `id` 一致。如果两者不一致, `ruyi update` 会发出警告。 2. 一个 `packages/` 目录,内部包含符合标准[软件源结构定义](repo-structure.md)的软件包定义。 ## 迁移说明 现有的单软件源配置无需修改。默认的 `ruyisdk` 软件源仍会像以前一样继续工作。 `[repo]` 配置段也仍然保留,用于配置默认软件源的 URL 和分支。 新增 Overlay 软件源时,可按以下步骤操作: 1. 在 `~/.config/ruyi/config.toml` 中添加 `[[repos]]` 条目,或使用 `ruyi repo add`。 2. 运行 `ruyi update` 同步新软件源。 3. 使用 `ruyi list --all` 查看所有软件源中的软件包。 ruyisdk-ruyi-1f00e2e/docs/naming-of-devices-and-images.md000066400000000000000000000101241520522431500233430ustar00rootroot00000000000000# 设备、系统镜像的命名约定 为便于自动化集成、管理 RuyiSDK 所支持的设备型号与系统镜像,也便于用户、开发者理解、接受,有必要为设备与系统镜像在 RuyiSDK 体系内的命名作出一些约定。 以下是目前在用的约定,随着事情发展,可能会有调整。 ## 设备型号 ID 设备型号 ID (device ID) 应当符合 `$vendor-$model` 的形式,其中 `$vendor` 是供应商 ID,`$model` 是型号 ID。 例: * `sipeed-lcon4a`: Sipeed Lichee Console 4A * `sipeed-tangmega138kpro`: Sipeed Tang Mega 138K Pro * `starfive-visionfive`: StarFive VisionFive * `wch-ch32v203-evb`: WCH CH32V203 EVB ### 供应商 ID 供应商 ID 一般取相应供应商的英文商标名的全小写形式;如果供应商全名显得太长,也可取其知名缩写的全小写形式。 已知(已在使用)的供应商 ID 如下: | 供应商 ID | 供应商名称 | |-----------|------------| | `awol` | Allwinner | | `canaan` | Canaan | | `milkv` | Milk-V | | `pine64` | Pine64 | | `sifive` | SiFive | | `sipeed` | Sipeed | | `spacemit` | SpacemiT | | `starfive` | StarFive | | `wch` | WinChipHead | 如后续有增加适配其他未在列表中的供应商,请同步更新此文档。 ### 型号 ID 型号 ID 的具体形式目前没有特别的约定,但一般遵循以下规则: * 如在厂商文档、示例代码、SDK 等公开资料存在较为一致的 codename 称呼,则使用 codename 的全小写形式。例如: * Duo S = `duos` * Kendryte K230 = `k230` * LicheePi 4A = `lpi4a` * Meles = `meles` * Pioneer Box = `pioneer` * 如相应厂商没有对某型板卡使用完善、一致的 codename,但在自然语言中,该板卡一般被称作“芯片型号 (chip model) + 产品形态 (form factor)”的形式,则使用 `$chip_model-$form_factor` 的全小写形式。例如: * CH32V203 EVB = `ch32v203-evb` * 如果上述两条都不能很好满足,则使用产品市场名称的全小写形式。例如: * Tang Mega 138K Pro = `tangmega138kpro` ## 型号变体 ID 有些不同的板卡 SKU 型号之间存在相当的相似度,一般是源自某些产品属性维度的排列组合。在 RuyiSDK 设备安装器中,我们不在“设备”一级区分这些 SKU,而是将相关联的 SKU 全部视作某个型号的“变体” (variant),以便降低用户的信息处理负担。 有些时候,虽然某个型号有多种变体,但从软件视角看来它们完全兼容,此时出于维护成本考虑,也可以不单独定义变体。但对于软件上不能做到完全兼容的多种变体,为了成功支持它们,就必须定义清楚。 由于 SKU 的制定方式众多,我们对于变体 ID 的具体写法除了应为全小写形式之外,不作明确的风格要求,但一般以简短为好。自动化处理相关数据的组件可能需要支持一定程度的模板字符串渲染等功能。 例如: * `sipeed-lpi4a` 有 8G RAM 与 16G RAM 两种配置,部分软件不能通用,必须区分。设置两种变体: * `8g`: `Sipeed LicheePi 4A (8G RAM)` * `16g`: `Sipeed LicheePi 4A (16G RAM)` * `wch-ch32v203-evb` 有 11 种配置,对应 CH32V203 的 11 种各项指标各异的 SKU。设置 11 种变体: * `c6t6`: `WCH CH32V203 EVB (CH32V203C6T6)` * `c8t6`: `WCH CH32V203 EVB (CH32V203C8T6)` * `c8u6`: `WCH CH32V203 EVB (CH32V203C8U6)` * `f6p6`: `WCH CH32V203 EVB (CH32V203F6P6)` * etc. * 多数型号没有明确的变体,对此均设置单一 `generic` 变体,称呼为 `generic variant`。以 `sipeed-maix1` 为例: * `generic`: `Sipeed Maix-I (generic variant)` ## 系统镜像包名 应为 `board-image/$os-$device_id` 或 `board-image/$os-$device_id-$variant` (当 variant 不为 `generic` 且区分 variant 很重要时) 的形式。 例如: * `board-image/revyos-milkv-meles`: 虽然 `milkv-meles` 有 `4g` 与 `8g` 两种变体,但 RevyOS 对此无感,故不在命名上体现变体。 * `board-image/uboot-revyos-milkv-meles-4g`: 由于 U-Boot 对板载 RAM 容量敏感,故需要在名称上区分不同变体。 ruyisdk-ruyi-1f00e2e/docs/programmatic-usage.md000066400000000000000000000073631520522431500216470ustar00rootroot00000000000000# 如何程序化地与 `ruyi` 交互 在一些场景下,如编辑器或 IDE 的 RuyiSDK 插件,这些外部程序需要与 `ruyi` 进行 non-trivial 的交互:不仅仅是以一定的命令行参数调用 `ruyi` 并判断其退出状态码,而需要处理一定的输出信息,甚至可能还涉及向 `ruyi` 输入大量信息。我们不希望这些外部程序解析 `ruyi` 面向人类的、不保证格式始终兼容的命令行输出格式,而希望暴露一个对机器友好的、尽量保证稳定、兼容的界面。 借鉴了 Git 一些命令所支持的 `--porcelain` 选项,我们为 `ruyi` 也定义了全局选项 `--porcelain`,用来启用这样的输出格式。并非所有的 `ruyi` 子命令都适配了 `--porcelain` 选项:对于那些暂未适配或没有适配意义的子命令,`ruyi` 除日志输出之外的行为将保持不变。 注意:由于 `ruyi` 的 `--porcelain` 选项是全局的,调用者需要将它置于 `argv` 中的所有子命令之前,否则 `ruyi` 将会报错。 ```sh # Correct ruyi --porcelain news list # Wrong # ruyi: error: unrecognized arguments: --porcelain ruyi news list --porcelain ``` ## `ruyi` 的 porcelain 输出模式 当处于 porcelain 输出模式时,如无特别说明,`ruyi` 的 stdout 与 stderr 输出格式将变为一行一个 JSON 对象。`ruyi` 不保证此 JSON 序列化结果仅包含 ASCII 字符:目前序列化这些对象时,在 Python 一侧采用了 `ensure_ascii=False` 的配置。 所有的 porcelain 输出对象都有 `ty` 字段,用来指示此对象的类型。目前已定义的类型有以下几种: ```python # ty: "log-v1" class PorcelainLog(PorcelainEntity): t: int """Timestamp of the message line in microseconds""" lvl: str """Log level of the message line (one of D, F, I, W)""" msg: str """Message content""" # ty: "newsitem-v1" # see ruyipkg/news.py class PorcelainNewsItemV1(PorcelainEntity): id: str ord: int is_read: bool langs: list[PorcelainNewsItemContentV1] # ty: "pkglistoutput-v1" # see ruyipkg/pkg_cli.py class PorcelainPkgListOutputV1(PorcelainEntity): category: str name: str vers: list[PorcelainPkgVersionV1] # ty: "entitylistoutput-v1" # see ruyipkg/entity_provider.py class PorcelainEntityListOutputV1(PorcelainEntity): entity_type: str entity_id: str display_name: str | None data: Mapping[str, Any] related_refs: list[str] reverse_refs: list[str] ``` 当工作在 porcelain 输出模式时,`ruyi` 平时的 stderr 日志信息格式将变为类型为 `log-v1` 的输出对象。 每条消息都带时间戳、日志级别,消息正文末尾不会被自动附加 1 个换行(但如果某条日志的末尾碰巧有一个或一些换行,那么这些换行将不会被删除)。 ## 已适配 porcelain 输出模式的命令 ### `ruyi list` 调用方式: ```sh ruyi --porcelain list ``` 输出格式: * stdout:一行一个 `pkglistoutput-v1` 类型的对象 * stderr:无意义 请注意:`-v` 选项在 porcelain 输出模式下会被无视。 ### `ruyi entity list` 调用方式: ```sh ruyi --porcelain entity list # 仅输出特定类型的实体 ruyi --porcelain entity list -t cpu -t device ``` 输出格式: * stdout:一行一个 `entitylistoutput-v1` 类型的对象 * stderr:无意义 ### `ruyi news list` 调用方式: ```sh ruyi --porcelain news list # 如同常规命令行用法,仅请求未读条目也是允许的 ruyi --porcelain news list --new ``` 输出格式: * stdout:一行一个 `newsitem-v1` 类型的对象 * stderr:无意义 请注意:单纯调用 `ruyi news list` 不会更新文章的已读状态。请另行进行 `ruyi news read -q item...` 的调用以标记用户实际阅读了的文章。 ruyisdk-ruyi-1f00e2e/docs/repo-pkg-versioning-convention.md000066400000000000000000000112101520522431500241270ustar00rootroot00000000000000# RuyiSDK 软件源的软件包版本约定 RuyiSDK 包管理器对所有软件包都采用[语义化版本][semver](SemVer)风格的版本号。而 RuyiSDK 打包的许多软件,其上游并未采用 SemVer 规范,这样便给打包者带来了“我在打包上游版本 X 时,应当采用怎样的 RuyiSDK 版本 Y”这样一个问题。 [semver]: https://semver.org/ 下面按照软件包的上游性质进行分类讨论。 ## 以 RuyiSDK 团队或相关方为上游的软件包 由于这些软件包的打包与发布过程为 RuyiSDK 团队所控制或影响,考虑到 RuyiSDK 包管理器这一分发渠道,RuyiSDK 团队一般会选择 SemVer 风格的版本。此时上游版本号与 RuyiSDK 软件包版本号始终保持一致。 对于工具链打包(涉及众多组件版本)、滚动打包(上游版本不更新或不清晰)等场景,此时可以附加 RuyiSDK 特定的日期时间戳,见“RuyiSDK 日期时间戳”一节。 ## 不以 RuyiSDK 团队或相关方为上游的软件包 这些软件包的版本号规律 RuyiSDK 团队不能决定,因此对于它们向 RuyiSDK 软件包版本号的映射方式,存在几种情况。 * 上游采用 SemVer。 * 可以直接采用上游版本号。 * 上游采用[日历化版本][calver](CalVer)或其他版本方案,但与 SemVer 相对较为兼容;或上游尽管不采用 SemVer 但该软件的流行程度相当之高。 * 当对应 SemVer 大版本号的位置发生变化时,用户必须或十分建议跟进升级; * 且当对应 SemVer 大版本号的位置不变时,用户不是那么需要跟进升级。 * 或者,如果不满足上述任一条件,但由于用户群体的心智已经十分根深蒂固,以至于采用编造的 SemVer 方案反而将对用户体验造成损失:例如 GCC、LLVM/Clang、QEMU 都不采用 SemVer,它们的大版本变更不见得会影响所有项目,但想必很难说服用户使用不同的版本号称呼这些软件。 * 在这些情况下,可以直接采用上游版本号。 * 上游采用 CalVer 或其他版本方案,且与 SemVer 相对不兼容,且相应软件不甚驰名。 * 不满足前述 CalVer 情况的任一判断要件,将造成用户使用不便:要么无意中被卡在旧的版本,要么将收到“SemVer major”一致但与先前所用版本不兼容的新版本。 * 需要维护者按照 SemVer 规范,编造一种映射。目前不对映射的具体方式进行强制规定。举例如下: * 上游版本 `23.10`,对应 RuyiSDK 版本 `1.2310.0`。 * 与上述上游版本兼容的 `24.04`,对应 RuyiSDK 版本 `1.2404.0`。 * 与上述上游版本不兼容的 `24.10`,对应 RuyiSDK 版本 `2.2410.0`。 * 上游采用其他版本方案。 * 也需要维护者按照 SemVer 规范,编造一种映射。目前不对映射的具体方式进行强制规定。 在映射版本号的过程中,应尽量保持关键要素的对齐。例如: * 为 Python 软件包所采用的 [PEP 440][pep-0440] 风格版本,其“prerelease”部分如果为 `a` `b`,应被映射到 SemVer 的 `alpha` `beta` 写法。 * 如上游版本号中包含 Git commit hash,为减少对排序算法的影响,应将其表示为 SemVer build tag,并在必要时补充 RuyiSDK 日期时间戳等体现排序的信息。 * 例如:`emulator/qemu-user-riscv-xthead` 的 `6.1.0-ruyi.20231207+g03813c9fe8` 版本。 [pep-0440]: https://peps.python.org/pep-0440/ ## RuyiSDK 日期时间戳 RuyiSDK 包管理器会特殊对待形如 `-ruyi.YYYYMMDD` 的 SemVer prerelease 标签:如无其他 prerelease 标记,则不将其视作 prerelease 版本。 作为[日历化版本][calver](CalVer)的实践,这使得 RuyiSDK 可以为相同的上游版本打出不同的包:如为了修复打包脚本中的错误或上游未修复的紧急问题等。或者对于那些 RuyiSDK 能够自行决定发版方式与节奏的包,发版日期即可直接成为版本,使得发版的心智负担更低。 [calver]: https://calver.org/ 原则上,只要一个软件包的内容受到了源自 RuyiSDK 的影响,包括但不限于: * RuyiSDK 团队对上游发行版本进行了重打包,且对文件内容进行了修改; * 软件包是 RuyiSDK 基于上游提供的源码包自行编译构建的; 那么在该软件包的版本号中,就应体现一个 RuyiSDK 日期时间戳。 ## 上游版本号与 RuyiSDK 版本号映射关系的记录 目前 RuyiSDK 软件源并未提供记录该信息的手段。后续需要在 RuyiSDK 软件源中对此进行支持。在此之前,需要开发者在下游工具中自行跟踪相关信息。 ruyisdk-ruyi-1f00e2e/docs/repo-structure.md000066400000000000000000000455151520522431500210640ustar00rootroot00000000000000# Ruyi 软件源结构定义 Ruyi 软件源承担两种职能,从而也由两部分构成: * 软件包的元数据描述, * 相关文件的分发。 以下分别描述。 ## 元数据 参考了 Rust 生态系统的经验,目前的 Ruyi 软件源元数据部分是一个 Git 储存库。其结构类似如下: ```plain packages-index ├── config.toml ├── messages.toml ├── news │   ├── YYYY-MM-DD-news-title.en_US.md │   └── YYYY-MM-DD-news-title.zh_CN.md ├── packages │   └── toolchain │   └── plct │   └── 0.20231026.0.toml ├── plugins │   ├── ruyi-cmd-foo-bar │   │ └── mod.star │   ├── ruyi-device-provision-strategy-baz │   │ └── mod.star │   ├── ruyi-device-provision-strategy-std │   │ └── mod.star │   └── ruyi-profile-riscv64 │   └── mod.star └── README.md ``` 以下分别说明。 ### README 不是必须的,目前也没有被用于界面展示等 `ruyi` 命令之内的用途,而仅仅用于网页端浏览。 ### 全局配置 每个具体的 Ruyi 软件源都必须包含一个全局配置文件,文件名为 `config.toml`。文件内容的格式必须与其扩展名所表示的格式一致。 当此数据的顶层不包含 `ruyi-repo` 字段时,应将其视作“旧版配置”解读。 旧版配置支持两种配置字段,如示例: ```toml dist = "https://path-to-distfiles-host" doc_uri = "https://ruyisdk.github.io/docs/" ``` * `dist` 字段表示 Ruyi 软件源分发路径,以 `/` 结尾与否均可。以下将此配置值称作 `${config.dist}`。 * `doc_uri` 是可选的指向该仓库的配套文档首页的 URI 字符串。 当此数据的顶层包含 `ruyi-repo` 字段时,支持以下的配置字段,如示例: ```toml ruyi-repo = "v1" [repo] doc_uri = "https://ruyisdk.org/docs/intro" [[mirror]] id = "ruyi-dist" urls = [ "https://path-to-distfiles-host", ] [[mirror]] id = "foo" urls = [ "https://mirrors.foo.com/foo", "https://mirrors.bar.org/dist/foo", "https://mirrors.baz.edu.cn/quux", ] [[telemetry]] id = "ruyisdk-pm" scope = "pm" url = "https://test.example.ruyisdk.org/v1/analytics/pm" [[telemetry]] id = "ruyisdk-repo" scope = "repo" url = "https://test.example.ruyisdk.org/v1/analytics/repo/ruyisdk" ``` 其中: * `repo.doc_uri` 字段含义同旧版配置的 `doc_uri` 字段。 * `repo.id` 是可选的仓库标识字符串。当通过 `[[repos]]` 配置使用多仓库时, 此字段的值应与用户配置中指定的 `id` 一致;如果不一致,`ruyi` 会在同步时发出警告。 如未提供,则默认为 `ruyisdk`。 * `repo.name` 是可选的仓库人类可读名称。 * `mirror` 是镜像源定义,其中 ID 为 `ruyi-dist` 的镜像具备特殊含义:其 `urls` 字段含义是旧版配置的 `dist` 字段含义的超集。 * `telemetry` 是遥测服务端配置,其中 `scope` 的含义为: * `pm` 表示此遥测服务端将被用于 RuyiSDK 包管理器相关的用户使用数据收集; * `repo` 表示此遥测服务端将被用于当前软件源的用户使用数据收集。 #### 镜像源定义 可以使用“镜像源”的概念,方便地表达某一实体所提供的多种文件分发渠道。 例如,某软件包定义中的 `urls` 字段的某一条记录形如 `mirror://foo/bar/baz.tar.zst`,那么如果搭配上述配置示例中的 `foo` 镜像源定义, 这条记录就与以下几条具体 URLs 等价: * `https://mirrors.foo.com/foo/bar/baz.tar.zst` * `https://mirrors.bar.org/dist/foo/bar/baz.tar.zst` * `https://mirrors.baz.edu.cn/quux/bar/baz.tar.zst` 每个镜像源的定义格式如下: * `id` 是镜像源的 ID。 * `urls` 是此镜像源下属的所有可用镜像的基础 URL 列表。 虽然目前 Ruyi 在下载文件时的实际行为是按列表顺序逐个尝试下载,但不对此做兼容性保证。 ### 全局字符串定义 `messages.toml` 如题,示例: ```toml ruyi-repo-messages = "v1" [foo] en_US = "A message template in Jinja" zh_CN = "一条 Jinja 格式的文案模板" [bar] en_US = "Another message" ``` ### `packages` 此目录内含 0 或多个子目录,子目录对应软件包类别(category),目录名为类别名。 每个类别目录内含 0 或多个子目录,子目录 1:1 对应软件包;目录名为软件包名。 合法的软件包名是仅由字母、数字、`-`(中划线)组成的非空字符串,且不以 `_`(下划线)开头。 以 `_` 开头的软件包名为保留名,留作表示特殊语义用,或用于其他的必要使用场景。 每个软件包的对应目录下,存在 0 或多个对应该包特定版本的具体定义文件;文件名即为版本号,格式为 TOML,后缀为 `.toml`。 举例说明: ```toml # 工具链包示例 format = "v1" [metadata] desc = "RuyiSDK RISC-V Linux Toolchain 20231026 (maintained by PLCT)" vendor = { name = "PLCT", eula = null } [[metadata.service_level]] level = "known-issue" msgid = "known-issue-foo" params = {} [[distfiles]] name = "RuyiSDK-20231026-HOST-riscv64-linux-gnu-riscv64-plct-linux-gnu.tar.xz" size = 162283388 checksums = { sha256 = "6e7f269e52afd07b5fb03d1d96666fa12561586e5558fc841cb81bb35f2e3b9b", sha512 = "ad5da6ea6a68d5d572619591e767173433db005b78d0e7fbcfe53dc5a17468eb83c72879107e51aa70a42c9bca03f1b0e483eb00ddaf074bf00488d5a4f54914" } [[distfiles]] name = "RuyiSDK-20231026-riscv64-plct-linux-gnu.tar.xz" size = 171803916 checksums = { sha256 = "2ae0ad6b513a8cb9541cb6a3373d7d1517a8848137b27cc64823582d3e9c01de", sha512 = "6fabe9642a0b2c60f67cdb6162fe6f4bcf399809ca4e0e216df7bebba480f2965e9cd49e4502efbdcc0174ea7dc1c8784bf9f9c920c33466189cd8990fa7c98e" } [[binary]] host = "riscv64" distfiles = ["RuyiSDK-20231026-HOST-riscv64-linux-gnu-riscv64-plct-linux-gnu.tar.xz"] [[binary]] host = "x86_64" distfiles = ["RuyiSDK-20231026-riscv64-plct-linux-gnu.tar.xz"] [toolchain] target = "riscv64-plct-linux-gnu" quirks = [] components = [ {name = "binutils", version = "2.40"}, {name = "gcc", version = "13.1.0"}, {name = "gdb", version = "13.1"}, {name = "glibc", version = "2.38"}, {name = "linux-headers", version = "6.4"}, ], included_sysroot = "riscv64-plct-linux-gnu/sysroot" ``` 其中: * `format` 是软件包定义文件的格式版本,目前支持 `v1` 一种。 * `slug` 是可选的便于称呼该包的全局唯一标识。目前未有任何特定的命名规范,待后续出现第三方软件源再行定义。 * `kind` 说明软件包的性质。如果不提供此字段,则 Ruyi 将根据本数据中提及的额外信息种类自动为其赋值。目前定义了以下几种: - `binary`:该包为二进制包,安装方式为直接解压。 - `blob`:该包为不需安装动作、非结构化的纯二进制数据。 - `source`:该包为源码包,安装方式为直接解压。 - `toolchain`:该包提供了一套工具链。 - `emulator`:该包提供了一个或多个模拟器二进制。 - `provisionable`:该包含有可用于 Ruyi 设备安装器的描述信息。 * `desc` 是包内容的一句话描述,仅用于向用户展示。 * `doc_uri` 是可选的指向该包的配套文档首页的 URI 字符串。 * `vendor` 提供了包的提供者相关信息。其中: - `name`:提供者名称,目前仅用于向用户展示。 - `eula`:目前仅支持取值为 `null`,表示安装该包前不需要征得用户明确同意任何协议。 * `service_level` 是可选的该包的服务等级描述。如果不提供该字段,则等效于存在一条 `untested` 的记录。 - `level`:服务等级。目前支持以下取值: - `known_issue`:存在已知问题。 - `untested`:测试状态未知:可能稳定可用,也可能存在问题。 - `msgid`:当 `level` 为 `known_issue` 时,用来描述问题的文案字符串在 `messages.toml` 中的消息 ID。 - `params`:键、值类型均为字符串的键值对,是渲染上述消息时要传入的参数。 * `upstream_version` 是可选的字符串,用来记录该包在上游所用的版本号。这可以让 RuyiSDK 软件源的管理工具在一定程度上感知、处理那些不遵循 SemVer 规范的上游版本号。 * `distfiles` 内含包的相关分发文件(distfile)声明。其中每条记录: - `name` 是文件名。当 `urls` 字段不存在时,表示此文件可从 `${config.dist}/dist/${name}` 这样的路径获取到。 - `urls` 是可选的 URL 字符串列表,表示此文件可额外从这些 URL 中的任意一个获取到。下载到本地的文件仍应被保存为 `name` 所指的文件名。 - `restrict` 是可选的对于该文件应施加的额外限制列表。每个元素可选以下之一: - `mirror`:该文件只能从 `urls` 所给定的 URLs 获取,不要试图从镜像源获取(默认会带上对应从镜像源获取的 URL,且优先从镜像源获取)。 - `fetch`:该文件不应被自动获取。应提示用户自行下载并放置于规定位置,尔后再重试其先前操作。 - `size` 是以字节计的文件大小,用于完整性校验。 - `checksums` 是文件内容校验和的 K-V 映射,每条记录的 key 为所用的算法,value 为按照该算法得到的该文件预期的校验和。目前接受以下几种算法: - `sha256`:值为文件 SHA256 校验和的十六进制表示。 - `sha512`:值为文件 SHA512 校验和的十六进制表示。 - `strip_components` 是可选的整数,表示解压此文件内容时,对每个成员文件名需要忽略的路径前缀段数,遵循 GNU tar 的 `--strip-components` 参数的语义。如不存在,则视作 1。 - `prefixes_to_unpack` 是可选的字符串列表,表示仅解压归档中以指定路径前缀开头的文件。目前该字段仅对 tar 格式的归档文件有效,会作为路径参数传递给 `tar` 命令。如果不指定或指定了空列表,则解压归档中的所有文件。出于信息安全考虑,列表中的路径前缀不能以 `-` 字符开头。 - `unpack` 是可选的字符串枚举,表示应如何解包此文件。如不存在此字段,视为 `auto`。如存在此字段,则应无视 `name` 字段所示的文件扩展名,而一定按此字段指定的语义处理。 - `auto`:按 `name` 字段所示的文件扩展名,自动处理。如遇到不知道如何处理的扩展名则应当报错。 - `tar.auto`:视作 tarball,但交由 `tar` 命令检查、处理具体的压缩格式。 - `raw`:不解包。解包动作等价于复制。 - `tar` `tar.gz` `tar.bz2` `tar.lz4` `tar.xz` `tar.zst`:视作相应压缩算法(或未压缩)的 tarball 处理。 - `gz` `bz2` `lz4` `xz` `zst`:视作相应压缩算法的字节流处理,解包后的文件名为 `name` 所示文件名去除最后一层后缀后的结果。 - `zip` :视作 Zip 归档文件处理。 - `deb`:视作 Debian 软件包文件处理。 - `fetch_restriction` 是可选的该文件所受的下载限制信息,具体来说是一条面向用户的、描述如何手工下载该文件的字符串。其中: - `msgid`:该文件的下载步骤说明文案,在 `messages.toml` 中的消息 ID。 - `params`:键、值类型均为字符串的键值对,是渲染该消息时要传入的参数。 * `binary` 仅在 `kind` 含有 `binary` 时有意义,表示适用于二进制包的额外信息。其类型为列表,每条记录: - `host` 代表该条记录所指的二进制包适用的宿主架构与操作系统,格式为 `os/arch` 或 `arch`;当 `os` 部分省略时,视作 `linux`。`arch` 部分的语义与 Python 的 `platform.machine()` 返回值相同。`os` 部分的语义与 Python 的 `sys.platform` 相同,但将 `win32` 变为 `windows`。 - `distfiles` 是分发文件名的列表,每条分发文件的具体定义参照 `distfiles` 字段。要为此宿主安装该包,下载并解压所有这些分发文件到相同目标目录即可。 - `commands` 是可选的键值对,用来将该包所提供的一些命令暴露供用户或虚拟环境调用。其中每个键是需要暴露的命令名称,值为相应命令基于该包安装路径根的相对路径。 * `blob` 仅在 `kind` 含有 `blob` 时有意义,表示适用于二进制数据包的额外信息。其中: - `distfiles` 是分发文件名的列表,每条分发文件的具体定义参照 `distfiles` 字段。此包不应被安装;对分发文件的引用应直接指向相应文件的下载目的地。 * `source` 仅在 `kind` 含有 `source` 时有意义,表示适用于源码包的额外信息。其中: - `distfiles` 是分发文件名的列表,每条分发文件的具体定义参照 `distfiles` 字段。要向某目标目录解压该源码包,下载并解压所有这些分发文件到该目标目录即可。 * `toolchain` 仅在 `kind` 含有 `toolchain` 时有意义,表示适用于工具链包的额外信息。 - `target` 是工具链在运行时所预期的 target tuple 取值。 - `quirks` 是自由形态字符串的列表,用于表示工具链的特殊特征(如支持某厂商未上游的特性,或 `-mcpu` 逻辑与社区版本不同等等)。目前定义了: - `xthead`:工具链是由 T-Head 源码构建而成,尤其其 `-mcpu` 取值方式与上游不同。 - `flavors` 是 `quirks` 的别名,为了兼容性而保留。 - `components` 是该包所含的标准组件及相应(等价)版本的列表。目前暂时没有用上,后续可能会基于此提供展示、过滤、匹配等功能。 - `included_sysroot` 是可选的字符串。如果该字段存在,则代表在解压该包后,此相对路径是指向目标目录下的一个可供直接复制而为虚拟环境所用的 sysroot 目录。 * `emulator` 仅在 `kind` 含有 `emulator` 时有意义,表示适用于模拟器包的额外信息。其中: - `quirks` 是自由形态字符串的列表,用于表示模拟器的特殊特征(如支持某厂商未上游的特性,或 `-cpu` 逻辑与社区版本不同等等)。目前定义了: - `xthead`:模拟器是由 T-Head 源码构建而成,尤其其 `-cpu`/`QEMU_CPU` 取值方式与上游不同。 - `flavors` 是 `quirks` 的别名,为了兼容性而保留。 - `programs` 是该包内可用的模拟器二进制的定义列表,每条记录: - `path` 是相对包安装根目录的,指向相应二进制的相对路径。 - `flavor` 是该模拟器二进制的性质。可选的值有: - `qemu-linux-user`:该二进制的用法如同静态链接的 QEMU linux-user 模拟器。 - `supported_arches` 是该二进制支持模拟的架构列表。架构值的语义与 `binary` 的 `host` 字段相同。 - `binfmt_misc` 是适合该二进制的 Linux `binfmt_misc` 配置串。注意转义。其中支持的特殊写法: - `$BIN`:将在渲染时被替换为指向该二进制的绝对路径。 * `provisionable` 仅在 `kind` 含有 `provisionable` 时有意义,表示可被 Ruyi 设备安装器读取的额外信息。其中: - `partition_map` 是该包提供的分区映像信息,是键值对;每条记录的 key 为目标分区性质,value 为相对于该包安装目录的,对应目标分区的未压缩原始映像文件的路径。目前支持的分区性质有: - `disk`:特殊,表示全盘映像。 - `live`:特殊,表示 Live 介质、安装介质等。 - `boot`:对于使用 fastboot 烧写的设备,代表 fastboot 视角的 `boot` 分区。 - `root`:对于使用 fastboot 烧写的设备,代表 fastboot 视角的 `root` 分区。 - `uboot`:对于使用 fastboot 烧写的设备,代表 fastboot 视角的 `uboot` 分区。 - `strategy` 是 Ruyi 设备安装器在安装该包时所应采取的策略,可选的值有: - `dd-v1`:对 `partition_map` 中声明的每个分区,询问用户相应的设备文件路径,然后分别以 `sudo dd` 方式刷写目标设备。 - `fastboot-v1`:按照 `partition_map` 的定义,以 `sudo fastboot` 方式刷写目标设备。 - `fastboot-v1(lpi4a-uboot)`:以 LicheePi 4A 文档推荐的方式,按照 `partition_map` 中 `uboot` 分区的定义刷写目标设备。 同时请注意,目前 `ruyi` 的参考实现存在如下的特殊情况: * 目前未实现分发文件的 `restrict: fetch` 功能。其具体实现细节仍待进一步细化。 * 目前 Zip 压缩包的解压工作由系统的 `unzip` 命令提供。由于该命令不支持类似 `tar` 的 `--strip-components` 选项,因此 Zip 格式的分发文件的 `strip_components` 配置目前不会被尊重。 ### `news` 此目录内含 0 或多份 Markdown 格式的通知消息。 每个通知消息文件的文件名应遵循 `YYYY-MM-DD-title[.LANG].md` 格式, 如 `2024-01-02-foo.md` 或 `2024-01-02-foo.zh-CN.md`。 其中 `LANG` 的部分,意在对应 Linux 系统上 `$LANG` 环境变量的语言部分; 如果该部分存在于文件名之中,那么在运行时,带有 `$LANG` 所对应的语言的那些通知消息文件, 将被视为覆盖了名为 `YYYY-MM-DD-title.md` 的通知消息文件。 通知消息的内容为带有 frontmatter 的 Markdown,形如下: ```markdown --- title: '文章的完整标题' if-installed: 'toolchain/plct(<1.0.0)' --- # 文章的完整标题 文章正文…… ``` 在 frontmatter 中,接受如下的字段: * `title`: 必须提供,文章的完整标题。 * `if-installed`: 可选提供,格式为 atom。 如果提供了此字段,那么只有当本地已经安装了此字段取值所能匹配到的包版本时,相应的通知消息才会被重点展示。 ### `plugins` 此目录内含 0 或多个 RuyiSDK 插件的代码实现。目前实现了如下几种插件类型: * 形如 `ruyi-cmd-foo-bar` 的插件,可以 `ruyi admin run-plugin-cmd foo-bar` 的方式被调用。 * 形如 `ruyi-device-provision-strategy-foo` 的插件,可供设备安装器调用。 * 特别地,`ruyi-device-provision-strategy-std` 是设备安装器的“标准库”。 * 形如 `ruyi-profile-quux` 的插件,为 `ruyi venv` 等工具提供 `quux` 架构的 profile 功能支持。 关于各类插件的具体写作方法,请参考官方软件源中的现有插件源码。 目前我们不对插件 API 的稳定性、兼容性做任何保证,但在需要破坏兼容性的情况下,对位于官方软件源的插件,我们一般会尽力维持它们兼容最近的 1~3 个 `ruyi` 版本。 ## 分发 目前仅要求以 HTTP/HTTPS 协议提供服务。 在 `${config.dist}` 路径下,目前仅要求实现一个 `dist` 目录,内含分发文件。 要求 distfiles 的下载服务必须支持断点续传。 ruyisdk-ruyi-1f00e2e/poetry.lock000066400000000000000000004116601520522431500170010ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "argcomplete" version = "3.6.3" description = "Bash tab completion for argparse" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"}, {file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"}, ] [package.extras] test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] [[package]] name = "arpy" version = "2.3.0" description = "Library for accessing \"ar\" files" optional = false python-versions = "*" groups = ["main"] files = [ {file = "arpy-2.3.0.tar.gz", hash = "sha256:8302829a991cfcef2630b61e00f315db73164021cecbd7fb1fc18525f83f339c"}, ] [[package]] name = "babel" version = "2.18.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, ] [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "certifi" version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "dist"] files = [ {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, ] [[package]] name = "cffi" version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "fastjsonschema" version = "2.21.2" description = "Fastest Python implementation of JSON schema" optional = false python-versions = "*" groups = ["main"] files = [ {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, ] [package.extras] devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] [[package]] name = "idna" version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "librt" version = "0.11.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, ] [[package]] name = "markdown-it-py" version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, ] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] [[package]] name = "markupsafe" version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "mypy" version = "1.20.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, ] [package.dependencies] librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=1.0.0" typing_extensions = [ {version = ">=4.6.0", markers = "python_version < \"3.15\""}, {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, ] [package.extras] dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] name = "nodeenv" version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] files = [ {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, ] [[package]] name = "nuitka" version = "2.8.10" description = "Python compiler with full language support and CPython compatibility" optional = false python-versions = "*" groups = ["dist"] files = [ {file = "nuitka-2.8.10.tar.gz", hash = "sha256:03e4d0756d8a11cb2627da3a2d9b518c802d031bf4f2c629e0a7b8c773497452"}, ] [package.dependencies] ordered-set = ">=4.1.0" zstandard = ">=0.15" [package.extras] all = ["imageio", "setuptools (>=42)", "toml", "zstandard (>=0.15)"] app = ["zstandard (>=0.15)"] build-wheel = ["setuptools (>=42)", "toml", "wheel"] icon-conversion = ["imageio"] onefile = ["zstandard (>=0.15)"] [[package]] name = "ordered-set" version = "4.1.0" description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" optional = false python-versions = ">=3.7" groups = ["dist"] files = [ {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, ] [package.extras] dev = ["black", "mypy", "pytest"] [[package]] name = "packaging" version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] name = "pathspec" version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] [[package]] name = "pathvalidate" version = "3.3.1" description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f"}, {file = "pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177"}, ] [package.extras] docs = ["Sphinx (>=2.4)", "sphinx_rtd_theme (>=1.2.2)", "urllib3 (<2)"] readme = ["path (>=13,<18)", "readmemaker (>=1.2.0)"] test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-md-report (>=0.6.2)"] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pycparser" version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] [[package]] name = "pygit2" version = "1.19.2" description = "Python bindings for libgit2." optional = false python-versions = ">=3.11" groups = ["main"] files = [ {file = "pygit2-1.19.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70c7efc426bdae6b67465a03729b79277e7757a29a7d6550b40c18ed36cb7232"}, {file = "pygit2-1.19.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b96d6ed7251eef70cfd4126269f1044fa47bc6da6367300027c5e5d74789f7f"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f3235db6b553b8fb4d3c1dc86af9be1eab445f1d6c42f4ade5cf5f60efd333"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a35d56126f82a303668f4198c138627b3e9820f9f1eec38fff0409be274b9e"}, {file = "pygit2-1.19.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e59a2e9eddd59edf999403c266c891dfc171eb95939d229ed614bc21e0c95804"}, {file = "pygit2-1.19.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0d2437bd5f8dbd652e8a6c318cbcaa245c0528ee48f6d64f4aaef8fd9b36b93"}, {file = "pygit2-1.19.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60d011496e57436b0c8e3fbd4d12745777427b3f33a60710ec3d94d2f76304b7"}, {file = "pygit2-1.19.2-cp311-cp311-win32.whl", hash = "sha256:9b0d5a44ca6d77a8c0e2526f6556d9b37cc85d44983ff3549bf5adbf95d289c4"}, {file = "pygit2-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:0d9c795155086c95ef890c87b50e02792146cfaede2c715698e6988a122373e7"}, {file = "pygit2-1.19.2-cp311-cp311-win_arm64.whl", hash = "sha256:837f0a9a0093cbb213176284d29f0ab754ded3e5af967e7ec6419d590a7da92a"}, {file = "pygit2-1.19.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf479077d48a60b09569a5bb50866d8609f434f8982058594b0d2e2950bd6fce"}, {file = "pygit2-1.19.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e6e7eb5fb49203735627b8e1d410afe19e7d610c9a9733c11084fabd17f0920"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a810da2d108d6bd16115c72a1c3d69fa1528ef927719bdfc94d2cdbc4198288"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b8ae5a822afb2771cbacf7c75140e663bc801c44eaaf2e4017f850cb27227c"}, {file = "pygit2-1.19.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:330430b6c1a3e6d45d1f5f950734d37d849c07924b5b0475cd995a7e541e6ab1"}, {file = "pygit2-1.19.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b7f165d1ddfa1e0f205c1115ee10f5fea700fd3584c727b0d61a57192238449"}, {file = "pygit2-1.19.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e46ec6a97a5c43704473e42a926f7f20f9934ceef4f4891660313f573c4f0ab8"}, {file = "pygit2-1.19.2-cp312-cp312-win32.whl", hash = "sha256:6b4de5469e88e7b069143f7a5d6336a4b3e7d911de4633ef18c113e416feb948"}, {file = "pygit2-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:f064748202928f4e882501521229e378e0b7b69b0e7c433cdb2626d007745973"}, {file = "pygit2-1.19.2-cp312-cp312-win_arm64.whl", hash = "sha256:222f439d751799dc74c3fa75f187abdbc415d12f9a091efa66f0c9ff51893d32"}, {file = "pygit2-1.19.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:df207f93a33851a110dec70108e3f2a1c69578932919fd356303eda83a5624db"}, {file = "pygit2-1.19.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae884cd53e29b3d831f5261f36048a8d5db5642dc98cd63530810e7fd9c9e60d"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bd4059964531d20aaf4577b3761590df9cc7c9e2395df5d33f0552224331b76"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3befcccc7b3b62e45da2cc1ce4095964f7606d3d15b43dc667c6ef2a2ada20d"}, {file = "pygit2-1.19.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cf08b54553f997f6f60a7918504e22e7baa4ba2fbb11d1e1cb6c0a45ac7e04b"}, {file = "pygit2-1.19.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7f630e5a763f01b4be6e2374c487086229c8f7392a2e5591d29095c5e481da4"}, {file = "pygit2-1.19.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6166845f41d4f6be3353997022d64035fe3df348c8e34d7d30c5f95817fbcab4"}, {file = "pygit2-1.19.2-cp313-cp313-win32.whl", hash = "sha256:5bebea045102e87dea142242298d4dd668d0227f76042f98efb1c5d5dd3db21e"}, {file = "pygit2-1.19.2-cp313-cp313-win_amd64.whl", hash = "sha256:7bbfeb680821001a5c1b6959da1eae906806c90c9992ae4564d3ea83a27bb19f"}, {file = "pygit2-1.19.2-cp313-cp313-win_arm64.whl", hash = "sha256:033d489186145cf67b2c60840d2a308f6b1e9d641de12417c447f9829dacde70"}, {file = "pygit2-1.19.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5effee3f4ad0d9c89b34ebecf1acee26f6b117ef3c51345ad022bd521fd8dca"}, {file = "pygit2-1.19.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ed09804dc6b6de0be07a71443122fd7b6458f8466d1134003c2dea55af886fc"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d114aa066e718d5ef3401b366dcb0b37b549c3b3b139f5f0042bd7059a4b0f7"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1becc06071acfdd5ae8523aaeab6d4b0930b2bcb08f5eb878e052e61275000b"}, {file = "pygit2-1.19.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06d2db3bdbf2906eb17112adb14a2fe6e34c1b2bce39c91819f59208d4e56665"}, {file = "pygit2-1.19.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8a7e99e5dfc8d3ed8f849b9688bc3fb1bdc86f34af28159140a8d1e18b703dd8"}, {file = "pygit2-1.19.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7659d59eba6c4a706978237d02e8d719f960843df749256f1656c938c1f4142b"}, {file = "pygit2-1.19.2-cp314-cp314-win32.whl", hash = "sha256:e551908dfd93d471c0b08cfcddbe4924417865aae6ac90d20f3815c9483b0a82"}, {file = "pygit2-1.19.2-cp314-cp314-win_amd64.whl", hash = "sha256:eb1fd8538372230f8a471a5f3629901bc2fc7df992853d97bedc8fa269a9caf3"}, {file = "pygit2-1.19.2-cp314-cp314-win_arm64.whl", hash = "sha256:3cc461245b70be45a936e925744e67a45f6b0ee970aeb8e7a385dd7fe9f40877"}, {file = "pygit2-1.19.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cb686bc81dfe5b13937047643fddb1dd253dae33b4a9ca62858c49ed294e05be"}, {file = "pygit2-1.19.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ec3538d81963bd05dd16c0de75938a9173966e1c853ad7848ebcb60bcfe21b0"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d02ebb50ea082d9631bbfda12787eb5324b8880a72cb8e3b9f11e9b323ad5781"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a3643e4dd569c2909e88586659f617f70315680ca3c619cd8ff9e9c28726c25"}, {file = "pygit2-1.19.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:697e3684cb4ef2bfc084623c3f680d5ae8b4c8afca31a35a731b7b70204d9f83"}, {file = "pygit2-1.19.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:173165b54a2affed918302193f12dd369bec981b1d77904cdcd76b966a824e15"}, {file = "pygit2-1.19.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ff32adce1a48d76b10e790b36784f6cb5ef40699b758c8b84f7f53f13b13d237"}, {file = "pygit2-1.19.2-cp314-cp314t-win32.whl", hash = "sha256:637d7c023f6623da35cf02cd1091f260c709730dd615367f4524ec8d771d0898"}, {file = "pygit2-1.19.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2805a8abd546e38298ce5daf33e444960e483acce68cbfb5d338e72ad5bc3503"}, {file = "pygit2-1.19.2-cp314-cp314t-win_arm64.whl", hash = "sha256:376a0d2c27c082f6bd8b97fd8ffc1939f16dfe8374ec846deee9b11151b37b8a"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4c2d397c887ff5a26b48ebd1bb9c66d2195ad377f0a44e05b79c462fff4040cd"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:69a0d377ee46110bbeea9e4191edee05132d1e7ac84b7cdebc640bc45868a2ec"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57d113a3eb61621ce16ceaa4bae7a93ffe525fd69da905445a0cf798d3601815"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e0bc207abbef4d3be3bd37e0711e6974a148d41806fdc932aef9bb244b157c4"}, {file = "pygit2-1.19.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:219c03bdbca59bd1df12b8bc7974b429872f4267aa2287ec0237c268593c0c5e"}, {file = "pygit2-1.19.2.tar.gz", hash = "sha256:cbeb3dbca9ca6ee3d5ea5d02f5e844c2d6084a2d5d6621e3e06aa2b11c645bfd"}, ] [package.dependencies] cffi = ">=2.0" [[package]] name = "pygments" version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyright" version = "1.1.409" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3"}, {file = "pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93"}, ] [package.dependencies] nodeenv = ">=1.6.0" typing-extensions = ">=4.1" [package.extras] all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] nodejs = ["nodejs-wheel-binaries"] [[package]] name = "pyrsistent" version = "0.20.0" description = "Persistent/Functional/Immutable data structures" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pyrsistent-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce"}, {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f"}, {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34"}, {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b"}, {file = "pyrsistent-0.20.0-cp310-cp310-win32.whl", hash = "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f"}, {file = "pyrsistent-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7"}, {file = "pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958"}, {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8"}, {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a"}, {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224"}, {file = "pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656"}, {file = "pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee"}, {file = "pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e"}, {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e"}, {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3"}, {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d"}, {file = "pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174"}, {file = "pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d"}, {file = "pyrsistent-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054"}, {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98"}, {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714"}, {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86"}, {file = "pyrsistent-0.20.0-cp38-cp38-win32.whl", hash = "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423"}, {file = "pyrsistent-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d"}, {file = "pyrsistent-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce"}, {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0"}, {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022"}, {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca"}, {file = "pyrsistent-0.20.0-cp39-cp39-win32.whl", hash = "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f"}, {file = "pyrsistent-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf"}, {file = "pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b"}, {file = "pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4"}, ] [[package]] name = "pytest" version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pyyaml" version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "requests" version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" groups = ["main"] files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" version = "0.8.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"}, {file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"}, {file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"}, {file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"}, {file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"}, {file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"}, {file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"}, {file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"}, {file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"}, {file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"}, {file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"}, {file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"}, ] [[package]] name = "semver" version = "3.0.4" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" groups = ["main"] files = [ {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] name = "tomlkit" version = "0.15.0" description = "Style preserving TOML library" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"}, {file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"}, ] [[package]] name = "tomlkit-extras" version = "0.2.0" description = "A Python package that extends the functionality of tomlkit, allowing for advanced manipulation, validation, and introspection of TOML files, including handling comments and nested structures" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "tomlkit_extras-0.2.0-py3-none-any.whl", hash = "sha256:03cebed47c755b1151c7a2551a74167ca34f7569058051f73c1ee0e1127e6537"}, {file = "tomlkit_extras-0.2.0.tar.gz", hash = "sha256:311b5da3d63dbbe6ae45c550573ea076dafddd2158bc095d48f3dab2fe5903fa"}, ] [package.dependencies] charset-normalizer = ">=3.3.2" pathvalidate = ">=3.2.0" pyrsistent = ">=0.20.0" tomlkit = ">=0.13.0" typing_extensions = ">=4.6.0" [[package]] name = "types-cffi" version = "1.17.0.20260307" description = "Typing stubs for cffi" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_cffi-1.17.0.20260307-py3-none-any.whl", hash = "sha256:89b5b2c798d32fc6e3304903ed99af93fd608b741483ce7d57fa69eda40430e5"}, {file = "types_cffi-1.17.0.20260307.tar.gz", hash = "sha256:1a4f1168d43ed8cd2b0ed40a3eb870cda685a154d98478b0a65862084f190a02"}, ] [package.dependencies] types-setuptools = "*" [[package]] name = "types-pygit2" version = "1.15.0.20250319" description = "Typing stubs for pygit2" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "types_pygit2-1.15.0.20250319-py3-none-any.whl", hash = "sha256:42653131c1049272040172488c8e1972cbe2370f7565c4712d67a20795747b42"}, {file = "types_pygit2-1.15.0.20250319.tar.gz", hash = "sha256:b5ef8b857eb712e1ea3e9897b7b0faf03d83b05dba8f70c72ca95e89d039c84a"}, ] [package.dependencies] types-cffi = "*" [[package]] name = "types-pyyaml" version = "6.0.12.20260518" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd"}, {file = "types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466"}, ] [[package]] name = "types-requests" version = "2.33.0.20260518" description = "Typing stubs for requests" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0"}, {file = "types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e"}, ] [package.dependencies] urllib3 = ">=2" [[package]] name = "types-setuptools" version = "82.0.0.20260518" description = "Typing stubs for setuptools" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20"}, {file = "types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07"}, ] [[package]] name = "typing-extensions" version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "tzdata" version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "zstandard" version = "0.25.0" description = "Zstandard bindings for Python" optional = false python-versions = ">=3.9" groups = ["dist"] files = [ {file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"}, {file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"}, {file = "zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550"}, {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d"}, {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b"}, {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0"}, {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e"}, {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74"}, {file = "zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa"}, {file = "zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e"}, {file = "zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c"}, {file = "zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f"}, {file = "zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431"}, {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a"}, {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc"}, {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6"}, {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa"}, {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7"}, {file = "zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4"}, {file = "zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2"}, {file = "zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137"}, {file = "zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b"}, {file = "zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00"}, {file = "zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64"}, {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea"}, {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb"}, {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a"}, {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512"}, {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa"}, {file = "zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd"}, {file = "zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01"}, {file = "zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9"}, {file = "zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94"}, {file = "zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1"}, {file = "zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f"}, {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea"}, {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e"}, {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551"}, {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98"}, {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf"}, {file = "zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09"}, {file = "zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5"}, {file = "zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049"}, {file = "zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3"}, {file = "zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f"}, {file = "zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c"}, {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439"}, {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043"}, {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859"}, {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0"}, {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7"}, {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2"}, {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344"}, {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c"}, {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088"}, {file = "zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12"}, {file = "zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2"}, {file = "zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d"}, {file = "zstandard-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0"}, {file = "zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2"}, {file = "zstandard-0.25.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df"}, {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53"}, {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3"}, {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362"}, {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388"}, {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27"}, {file = "zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649"}, {file = "zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860"}, {file = "zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b"}, ] [package.extras] cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] [metadata] lock-version = "2.1" python-versions = ">=3.11" content-hash = "f98bb846e39a799bb0c30a89ea52ad86fb44198f651927babdba9996dbb769da" ruyisdk-ruyi-1f00e2e/pyproject.toml000066400000000000000000000066431520522431500175220ustar00rootroot00000000000000[build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [project] name = "ruyi" version = "0.49.0" description = "Package manager for RuyiSDK" keywords = [ "package manager", "risc-v", "riscv", "ruyi", "ruyisdk", "sdk", "toolchain", ] license = { file = "LICENSE-Apache.txt" } readme = "README.md" authors = [ { name = "WANG Xuerui", email = "wangxuerui@iscas.ac.cn" } ] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Embedded Systems", "Topic :: System :: Software Distribution", "Typing :: Typed", ] requires-python = ">=3.11" dependencies = [ "argcomplete>=2.0.0", "arpy", "babel>=2.10.3", "fastjsonschema>=2.16.3", "jinja2 (>=3.0.3, <4)", "pygit2>=1.11.1", "pyyaml>=6.0", "requests (>=2.25.1, <3)", "rich>=11.2.0", "semver>=2.10", "tomlkit>=0.9", "tzdata; sys_platform=='win32'", ] [project.scripts] ruyi = "ruyi.__main__:entrypoint" [project.urls] homepage = "https://ruyisdk.org" documentation = "https://ruyisdk.org/docs/intro" download = "https://ruyisdk.org/download" github = "https://github.com/ruyisdk/ruyi" issues = "https://github.com/ruyisdk/ruyi/issues" repository = "https://github.com/ruyisdk/ruyi.git" [tool.poetry] include = ["ruyi/py.typed"] exclude = ["**/.gitignore"] [tool.poetry.group.dev.dependencies] mypy = "^1.9.0" pyright = "^1.1.389" pytest = ">=6.2.5" ruff = "^0.8.1" tomlkit-extras = "^0.2.0" typing-extensions = ">=3.10.0.2" types-cffi = "^1.16.0.20240106" types-pygit2 = "^1.14.0.20240317" types-PyYAML = "^6.0.12.20240311" types-requests = "^2.31.0.20240311" [tool.poetry.group.dist.dependencies] certifi = "*" nuitka = "^2.0" [tool.mypy] files = ["ruyi", "scripts", "tests"] exclude = [ "tests/ruyi-litester", ] show_error_codes = true strict = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] # https://github.com/eyeseast/python-frontmatter/issues/112 # https://github.com/python/mypy/issues/8545 # have to supply the typing info until upstream releases a new version with # the py.typed marker included mypy_path = "./stubs" [tool.pylic] safe_licenses = [ "Apache Software License", "BSD License", "GPLv2 with linking exception", "MIT", # pyright spells "MIT License" differently "MIT License", "Mozilla Public License 2.0 (MPL 2.0)", # needs mention in license notices "PSF-2.0", # typing_extensions 4.13 # not ruyi deps, but brought in by pylic which unfortunately cannot live # outside of the project venv in order to work. # Fortunately though, they are all permissive licenses, so inclusion of # them would not accidentally allow unsafe licenses into the project. "ISC License (ISCL)", # shellingham "BSD-2-Clause", # boolean.py "BSD-3-Clause", # click "Apache-2.0", # license-expression ] [tool.pyright] include = ["ruyi", "scripts", "tests"] exclude = ["**/__pycache__", "tests/ruyi-litester", "tmp"] stubPath = "./stubs" pythonPlatform = "Linux" [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] extend-exclude = [ "tests/ruyi-litester", ] ruyisdk-ruyi-1f00e2e/resources/000077500000000000000000000000001520522431500166075ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/bundled/000077500000000000000000000000001520522431500202245ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/bundled/_ruyi_completion000066400000000000000000000055721520522431500235400ustar00rootroot00000000000000#compdef ruyi # Likely a trimmed down version of https://github.com/kislyuk/argcomplete/blob/main/argcomplete/bash_completion.d/_python-argcomplete # Helpers have distinct prefix from the original script to avoid name collisions # Run something, muting output or redirecting it to the debug stream # depending on the value of _ARC_DEBUG. # If ARGCOMPLETE_USE_TEMPFILES is set, use tempfiles for IPC. __python_argcomplete_ruyi_run() { if [[ -z "${ARGCOMPLETE_USE_TEMPFILES-}" ]]; then __python_argcomplete_ruyi_run_inner "$@" return fi local tmpfile="$(mktemp)" _ARGCOMPLETE_STDOUT_FILENAME="$tmpfile" __python_argcomplete_ruyi_run_inner "$@" local code=$? cat "$tmpfile" rm "$tmpfile" return $code } __python_argcomplete_ruyi_run_inner() { if [[ -z "${_ARC_DEBUG-}" ]]; then "$@" 8>&1 9>&2 1>/dev/null 2>&1 &1 9>&2 1>&9 2>&1 /dev/null; then SUPPRESS_SPACE=1 fi COMPREPLY=($(IFS="$IFS" \ COMP_LINE="$COMP_LINE" \ COMP_POINT="$COMP_POINT" \ COMP_TYPE="$COMP_TYPE" \ _ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \ _ARGCOMPLETE=1 \ _ARGCOMPLETE_SHELL="bash" \ _ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \ __python_argcomplete_ruyi_run ${script:-$1})) if [[ $? != 0 ]]; then unset COMPREPLY elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then compopt -o nospace fi fi } if [[ -z "${ZSH_VERSION-}" ]]; then complete -o nospace -o default -o bashdefault -F _python_argcomplete_ruyi ruyi else # When called by the Zsh completion system, this will end with # "loadautofunc" when initially autoloaded and "shfunc" later on, otherwise, # the script was "eval"-ed so use "compdef" to register it with the # completion system autoload is-at-least if [[ $zsh_eval_context == *func ]]; then _python_argcomplete_ruyi "$@" else compdef _python_argcomplete_ruyi ruyi fi fi ruyisdk-ruyi-1f00e2e/resources/bundled/binfmt.conf.jinja000066400000000000000000000012451520522431500234460ustar00rootroot00000000000000# binfmt_misc config suitable for this Ruyi virtual environment, # in systemd-binfmt config format; see `man binfmt.d` for details. # You should register one of the following declaration(s), in a way # appropriate for your distribution / service manager / etc, or invoke # the emulator binary yourself via the `ruyi-qemu` wrapper. {% for prog in resolved_progs %} # Emulator {{ prog.display_name }} {%- if prog.env %} # # Note that you also have to provide these environment variables at runtime, # in order to achieve correct emulation semantics: # {% for k, v in prog.env.items() %}# - {{ k }}={{ v | sh }} {% endfor %} {%- endif -%} {{ prog.binfmt_misc_str }} {%- endfor %} ruyisdk-ruyi-1f00e2e/resources/bundled/locale/000077500000000000000000000000001520522431500214635ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/bundled/locale/zh_CN/000077500000000000000000000000001520522431500224645ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/bundled/locale/zh_CN/LC_MESSAGES/000077500000000000000000000000001520522431500242515ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/bundled/locale/zh_CN/LC_MESSAGES/argparse.mo000066400000000000000000000060411520522431500264130ustar00rootroot00000000000000!$,- ERp4'*$2'W<*$:!W3yQ#!@bj& (5Niq0 ? `;%)*$ 'O w      " :* ^e   " $ + !8 Z y  <   (default: %(default)s)%(heading)s:%(prog)s: error: %(message)s %(prog)s: warning: %(message)s ambiguous option: %(option)s could match %(matches)sargument "-" with mode %rargument %(argument_name)s: %(message)sargument '%(argument_name)s' is deprecatedcan't open '%(filename)s': %(error)scommand '%(parser_name)s' is deprecatedconflicting option string: %sconflicting option strings: %sexpected %s argumentexpected %s argumentsexpected at least one argumentexpected at most one argumentexpected one argumentignored explicit argument %rinvalid %(type)s value: %(value)rinvalid choice: %(value)r (choose from %(choices)s)invalid choice: %(value)r, maybe you meant %(closest)r? (choose from %(choices)s)not allowed with argument %sone of the arguments %s is requiredoption '%(option)s' is deprecatedoptionspositional argumentsshow program's version number and exitshow this help message and exitsubcommandsthe following arguments are required: %sunexpected option string: %sunknown parser %(parser_name)r (choices: %(choices)s)unrecognized arguments: %susage: Project-Id-Version: PROJECT VERSION Report-Msgid-Bugs-To: EMAIL@ADDRESS POT-Creation-Date: 2026-05-19 02:52+0800 PO-Revision-Date: 2026-01-17 22:25+0800 Last-Translator: WANG Xuerui Language: zh_Hans_CN Language-Team: zh_Hans_CN Plural-Forms: nplurals=1; plural=0; MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Generated-By: Babel 2.18.0 (默认:%(default)s)%(heading)s:%(prog)s:错误:%(message)s %(prog)s:警告:%(message)s 选项存在歧义:%(option)s 可能匹配上 %(matches)s%r 模式的参数 "-"参数 %(argument_name)s: %(message)s参数“%(argument_name)s”已被弃用无法打开“%(filename)s”:%(error)s命令“%(parser_name)s”已被弃用冲突的选项字符串:%s预期 %s 个参数预期至少一个参数预期至多一个参数预期单个参数忽略了显式参数 %r无效的 %(type)s 值:%(value)r无效的选择:%(value)r(从 %(choices)s 中选择)无效的选择:%(value)r,您可能想的是 %(closest)r?(从 %(choices)s 中选择)不可与 %s 参数搭配使用必须传入 %s 其中之一选项“%(option)s”已被弃用选项位置参数显示程序的版本号并退出显示该帮助信息并退出子命令必须传入以下参数:%s非预期的选项字符串:%s未知的解析器 %(parser_name)r(选择:%(choices)s)不认识的参数:%s用法:ruyisdk-ruyi-1f00e2e/resources/bundled/locale/zh_CN/LC_MESSAGES/ruyi.mo000066400000000000000000001712451520522431500256100ustar00rootroot00000000000000'%<tUV.!PL'!7t!1!!{#S#C#:3$n$"$l$(%E%.5)d)e9*V**%+M++2,,p/#1'123#%3 I3U3+h3E313" 4/4-O4}4-40494I655#5;55 56666I6X6!r6'6$66)6A7`71z7E77# 8'/8W8"q8k819129d9|9 99 9F9:L::;>;@Q;;;;,;,<6E<A|<<<4<K%=0q=2=3=) >E3>Gy>J>C ?&P?:w??h?*@2I@1|@)@@3@0(A$YA ~APAAA B&B&CBjByB!BBBCB&7C*^CIC3CD DDO"DIrD$D)D- E{9E4E.EF&2F+YF0F#F0F# G/G)MG=wG%G)G H&H@HWH"`H>HFH' I1IBAII%II&I?J$EJ)jJ?J3JK"K?K"KLd(LL/L+L(M%0M/VM*M,M-Md NWqN7NIOsKORRRMSkSS-SST?TU.U=U-ZUU U#UU-V5VV9VVV#VVW$W"8W[WvW$WW%W&W/XOX7X0Y4HY)}Y.YDY3Z/OZ@Z4ZDZ;:[5v[H[7[7-\<e\G\Y\ED]A]6]'^+^,K^5x^1^;^(_E_!a__!_%_I_;(`9d`4`'`5`<1a8na/a+a b-$bPRb*b,b]bLYc4ccc/c.d ?dP`dHd0d`+e1ee#e@fIAfBfIf&g/?gog!g6gg(gP"h%shhh$h0h,i5i_Si$ipiWIj2jBj@kXkkkk<kk%k&l;l+Tl.l.lilEHmmmBmjn:zn1n<n8$o>]ohohp#npHpppDq2YqKqqSq(Er1nr r#rrrgrgs,s,sksALt<t$tt u!u?u"[u~uuu,uJu:vOv&cvv!vv vvw&7w.^wNwIwZ&x2x>x.x("yZKy yyKylzzz)z'zd{Bf{U{Q{2Q|M|(|P|HL}a}<}+4~6`~@~_~j8?00:E;56`)K$ց($zR\=FI1`$Ԅ&..CrG;-/+]Gcцa5-#%M+y&Ah~ya"sL\C1ːI;,1hkؑ%Dj3PI6:d& =G:Lw 4'U}.H֟+ Kl##7;BX&Hӡ  &2Oj"#բ*.BYH@9'U!}!$l-S9ԥ  6 WH66m3,Ч*!(*J?uѨ7K!h-'$B<H9,39j<$'̫0.%T0s*!Ϭ NM`z! ׭$DQc$'ڮD-GuyXH>'W3o'#'Ks.$9* 3K"<ز"!8Zs :³><[/n$ߴ3E/0u3Bڵ6T$g'EiZķ0''=e:114#vXhϹ;8?tp%D$cEξ!<0 NZ/v!$ $%JTN!%" .B/V0 $-!(Ox, 36/j2,I4D*y<!3*7!bL77 :AL|PSGn:#04/e1:& ) Jk}$RA6P50: :H+(*Z!1|*Qi+/-*K@I3Z 1e!:C BNE+;?!Z6|/K%Iov+H*!&hH*nNK'D3;$Qv>!!$<@4}3r9YCdCx5@73@kep&J>'(fMM$>c $!q"Y4|4tU[?$5$N!s'!@RR: @Yr";V./G+6)+`2Z $A>^.^M8SF9*H <HRL0785p\\4`++-4)P*zKO$A%f_NG3*J=)%(0F,w>09H)I\\S+!+[I Keyboard interrupt received, exiting. ### Binary artifacts ### Toolchain metadata Aborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device. All news items have been read. To see a list of them, run [yellow]ruyi news list[/]. Do you want to opt out of telemetry entirely? Downloads can fail for a multitude of reasons, most of which should not and cannot be handled by [yellow]Ruyi[/]. For your convenience though, please check if any of the following common failure modes apply to you, and take actions accordingly if one of them turns out to be the case: * Basic connectivity problems - is [yellow]the gateway[/] reachable? - is [yellow]common websites[/] reachable? - is there any [yellow]DNS pollution[/]? * Organizational and/or ISP restrictions - is there a [yellow]firewall[/] preventing Ruyi traffic? - is your [yellow]ISP blocking access[/] to the source website? * Volatile upstream - is the recorded [yellow]link dead[/]? (Please raise a Ruyi issue for a fix!) Exiting. The device is not touched and you may re-start the wizard at will. Exiting. You can restart the wizard whenever prepared. Exiting. You may restart the wizard at any time. For initializing this target device, you should plug into this host system the device's storage (e.g. SD card or NVMe SSD), or a removable disk to be reformatted as a live medium, and note down the corresponding device file path(s), e.g. /dev/sdX, /dev/nvmeXnY for whole disks; /dev/sdXY, /dev/nvmeXnYpZ for partitions. You may consult e.g. [yellow]sudo blkid[/] output for the information you will need later. How would you like to proceed? It seems the flashing has finished without errors. [bold green]Happy hacking![/] Newer versions are available for some of your installed packages: Package [green]{atom}[/] already has version constraints. Package has known issue(s): Package versions to be installed: Re-run [yellow]ruyi install[/] to upgrade, and don't forget to re-create any affected virtual environments. Restarting package version selection... RuyiSDK collects minimal usage data in the form of just a version number of the running [yellow]ruyi[/], to help us improve the product. With your consent, RuyiSDK may also collect additional non-tracking usage data to be sent periodically. The data will be recorded and processed by RuyiSDK team-managed servers located in the Chinese mainland. [green]By default, nothing leaves your machine[/], and you can also turn off usage data collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and upload more usage data. You can change this setting at any time by running [yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/]. We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK team can better understand adoption. If you choose to opt out, this will be the only data to be ever uploaded, without any tracking ID being generated or kept. Thank you for helping us build a better experience! Select a version for package [green]{pkg}[/]: Some flashing steps require the use of fastboot, in which case you should ensure the target device is showing up in [yellow]fastboot devices[/] output. Please [bold red]confirm it yourself before continuing[/]. The device has the following variants. Please choose the one corresponding to your hardware at hand: The following devices are currently supported by the wizard. Please pick your device: The following system configurations are supported by the device variant you have chosen. Please pick the one you want to put on the device: There are {count} new news item(s): We are about to download and install the following packages for your device: We have collected enough information for the actual flashing. Now is the last chance to re-check and confirm everything is fine. We are about to: You can read them with [yellow]ruyi news read[/]. [bold green]RuyiSDK Device Provisioning Wizard[/] This is a wizard intended to help you install a system on your device for your development pleasure, all with ease. You will be asked some questions that help RuyiSDK understand your device and your intended configuration, then packages will be downloaded and flashed onto the device's storage, that you should somehow make available on this host system beforehand. Note that, as Ruyi does not run as [yellow]root[/], but raw disk access is most likely required to flash images, you should arrange to allow your user account [yellow]sudo[/] access to necessary commands such as [yellow]dd[/]. Flashing will fail if the [yellow]sudo[/] configuration does not allow so. [bold green]tip[/]: you can enable shell auto-completion for [yellow]ruyi[/] by adding the following line to your [green]{shrc}[/], if you have not done so already: [green]eval "$(ruyi --output-completion-script={shell})"[/] You can do so by running the following command later: [green]echo 'eval "$(ruyi --output-completion-script={shell})"' >> {shrc}[/] [bold]Package Version Selection[/] [bold]Thanks for hacking with [yellow]Ruyi[/]![/] This will uninstall [yellow]Ruyi[/] from your system, and optionally remove all installed packages and [yellow]Ruyi[/]-managed repository data if the [green]--purge[/] switch is given on the command line. Note that your [yellow]Ruyi[/] virtual environments [bold]will become unusable[/] after [yellow]Ruyi[/] is uninstalled. You should take care of migrating or cleaning them yourselves afterwards. - Available command(s): - Distfiles: {distfiles} - Size: [yellow]{size}[/] bytes (no item) (no unread item) - [green]{category}/{name}[/] ({version}) - between [bold green]{time_start}[/] and [bold green]{time_end}[/] - or if the last upload is more than a week ago All indirectly related entities: Direct forward relationships: Direct forward relationships: [gray]none[/] Direct reverse relationships: Direct reverse relationships: [gray]none[/] - opt out with [yellow]ruyi telemetry optout[/] - or give consent with [yellow]ruyi telemetry consent[/]'{id}' is reserved; use [repo] config to configure the default repository(1-{nr_choices})(1-{nr_choices}, default {default})(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos(none)* Components:* Host [green]{host}[/]:* Package kind: {kind}* Quirks: {quirks}* Slug: (none)* Slug: [yellow]{slug}[/]* Target: [bold green]{target}[/]* Upstream version number: (undeclared)* Upstream version number: {version}* Vendor: {vendor}--only-packages is only valid with --repo--without-sysroot cannot be combined with a sysroot source optionAbort device provisioningAccepted choices: Y/y/yes for YES, N/n/no for NO.Accepted choices: an integer number from 1 to {nr_choices} inclusive.Add a package repositoryAlso show details for every packageArguments to pass to the plugin commandAssume yes to all promptsBuild a package from a recipe fileBy default, we'll install the latest version of each package, but in this case, other choices are possible.Check package manifests and metadata repositoriesCheck to run (repeatable; defaults to all checks)Choice? {choices_help} Clear the Ruyi program cacheCommand nameContinue with these versionsContinue?Copy the sysroot from the given directory into the virtual environmentCopyright (C) Institute of Software, Chinese Academy of Sciences (ISCAS). All rights reserved. License: Apache-2.0 Creating a Ruyi virtual environment [cyan]'{name}'[/] at [green]{dest}[/]...Creating a Ruyi virtual environment at [green]{dest}[/]...Describe an entityDestination directory to extract to (default: current directory)Disable a package repositoryDo not include a sysroot inside the new virtual environmentDo not output anything and only mark as readDo not print out the actions being performedDo you agree to have usage data periodically uploaded?EOF while reading user input, assuming the default choice {yesno}Enable a package repositoryEnable verbose outputEntity [bold]{entity}[/] ([green]{display_name}[/]) Extract files directly into DESTDIR instead of package-named subdirectoriesFetch distribution files only without installingFetch package(s) then extract to current directoryForce re-installation of already installed packagesFormat of checksum section to generate inFormat the given package manifests into canonical TOML representationGenerate a checksum section for a manifest file for the distfiles givenGenerate a virtual environment adapted to the chosen toolchain and profileGive consent for uninstallation on CLI; do not ask for confirmationGive consent to telemetry data uploadsGive the output in a machine-friendly format if applicableIDIf you believe this is a bug, please file an issue at [yellow]https://github.com/ruyisdk/ruyi/issues[/].Install package from configured repositoryInteract with entities defined in the repositoriesInteractively initialize a device for developmentIs the device identified by fastboot now?List all available profilesList and read news items from configured repositoryList available packages in configured repositoryList configured package repositoriesList entitiesList entities of this type. Can be passed multiple times to list multiple types.List news itemsList of available packages: List unread news items onlyManage Ruyi's config optionsManage configured package repositoriesManage devicesManage this Ruyi installationManage your telemetry preferencesMark all news items as unreadMatch and show all packagesMatch packages from categories whose names contain the given stringMatch packages from the given categoryMatch packages related to the given entityMatch packages that are installed (y/true/1) or not installed (n/false/0)Match packages whose names contain the given stringNONo news to display.No.Only check packages matching trailing ruyi list filters; valid only with --repoOnly one version available for [green]{pkg}[/]: [blue]{ver}[/], using it.Opt out of telemetry data collectionOrdinal or ID of the news item(s) to readOut-of-range input [yellow]'{user_input}'[/].Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.Override the host architecture (normally not needed)Override the recipe project's output directoryOverride the venv's namePackage declares {count} distfile(s): Path to a metadata repository root to checkPath to a package manifest to check (repeatable)Path to the distfile(s) to checksumPath to the distfile(s) to generate manifest forPath to the new virtual environmentPath to the recipe .star filePlease give the path for the {part_desc}:Please remove it manually if you are sure it's safe to do so.Press [green][/] to continue: Print the build plan without executing itPrint the current telemetry modePrint version informationProceed with flashing?Proceed?Profile to use for the environmentProject a build sysroot from the given distro rootfs directoryProvision a fresh sysroot inside the new virtual environment (default)Query the value of a Ruyi config optionRead news itemsReference to the entity to describe in the form of ':'Remove a package repositoryRemove a section from the Ruyi configRemove all covered dataRemove all downloaded distfiles if anyRemove all installed packages and Ruyi-managed remote repo dataRemove all installed packages if anyRemove all telemetry data recorded if anyRemove the Ruyi repo if located in Ruyi-managed cache directoryRemove various Ruyi-managed data to reclaim storageRestart version selectionRun a plugin-defined commandRuyi does not elevate privileges when creating virtual environments; use a sysroot readable by the current user, --symlink-sysroot-from-dir, or --project-sysroot-from-rootfsRuyi {version} Running on {host}.RuyiSDK Package ManagerSelect a specific scheduled build by name (repeatable); by default all scheduled builds are executedSelected: [blue]{new_atom}[/]Set a user variable for the recipe (repeatable)Set telemetry mode to local collection onlySet the priority of a package repositorySet the value of a Ruyi config optionSpecifier (atom) of the emulator package to useSpecifier (atom) of the package to installSpecifier (atom) of the package to uninstallSpecifier (atom) of the package(s) to extractSpecifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicableSpecifier(s) (atoms) of extra package(s) to add commands to the new virtual environmentSpecifier(s) (atoms) of the toolchain package(s) to useSymlink the virtual environment's sysroot to the given existing directorySysroot provisioning: By default, Ruyi uses the sysroot bundled with the selected toolchain if one is available. Use --copy-sysroot-from-pkg to copy the sysroot from another installed toolchain package. Use --copy-sysroot-from-dir only for a complete sysroot directory readable by the current user; it performs a faithful full-tree copy. Use --symlink-sysroot-from-dir to point the virtual environment at an existing sysroot directory without copying it. Use --project-sysroot-from-rootfs for distro rootfs or chroot trees: Ruyi copies common cross-build directories such as include, lib*, usr/include, usr/lib*, usr/share, bin, and sbin, and skips unreadable or unsupported files. Ruyi never elevates privileges when creating virtual environments. If a rootfs contains private system files such as /etc/shadow, prepare a readable sysroot yourself or use projection mode.The Ruyi config option to queryThe Ruyi config option to setThe Ruyi config option to unsetThe command(s) {cmds} cannot be found in PATH, which [yellow]ruyi[/] requiresThe section to removeThe value to set the option toThis Ruyi installation is externally managed.This distribution of ruyi contains code licensed under the Mozilla Public License 2.0 (https://mozilla.org/MPL/2.0/). You can get the respective project's sources from the project's official website: * certifi: https://github.com/certifi/python-certifi TitleURL of remote '[yellow]{remote}[/]' does not match expected URLUnicodeDecodeError: {path}Uninstall RuyiUninstall installed packagesUnrecognized input [yellow]'{user_input}'[/].Unset a Ruyi config optionUpdate RuyiSDK repo and packagesUpload collected telemetry data nowWould you like to change them?Would you like to customize package versions?YESYou have to specify at least one toolchain atom for now, e.g. [yellow]`-t gnu-plct`[/][bold green]News items:[/] [bold green]info:[/] {message}[bold red]fatal error:[/] {message}[bold yellow]warn:[/] {message}[green]downloaded[/][green]installed[/][red]no binary for current host[/][yellow]has known issue[/][yellow]ruyi[/] is uninstalleda repo with id '{id}' already existsaborting uninstallationactual remote URL: [yellow]{url}[/]also remove cached repo data from diskat least one of URL or --local must be providedat most one of --copy-sysroot-from-pkg, --copy-sysroot-from-dir, --symlink-sysroot-from-dir, and --project-sysroot-from-rootfs may be specifiedatom [yellow]{atom}[/] is non-existent or not installedatom {atom} matches no package in the repositoryattempt to modify protected global config key: {key}build {name!r} completed: {n} artifact(s)bzip2 failed: command {cmd} returned {retcode}cannot copy sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}cannot copy sysroot from [yellow]{src}[/]: {reason}cannot fast-forward repo to newly fetched statecannot find a GCC include & lib directory in the sysroot packagecannot find the installed directory for the emulatorcannot find the installed directory for the package [yellow]{pkg}[/]cannot find the installed directory for the sysroot packagecannot find the installed directory for the toolchaincannot handle package [green]{pkg}[/]: package is both binary and sourcecannot match a toolchain package with [yellow]{atom}[/]cannot match an emulator package with [yellow]{atom}[/]cannot match an extra command package with [yellow]{atom}[/]cannot project sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}cannot project sysroot from [yellow]{src}[/]: no supported sysroot directories were foundcannot remove system-provided repo '{id}'; use 'repo disable' insteadcannot remove the default repo '{id}'; use 'repo disable' insteadcheck out `ruyi venv` for making a virtual environmentchecksum algorithm {kind} not supportedclearing the Ruyi program cachecloning from [cyan link={remote}]{remote}[/]could not find package [yellow]{pkg}[/] in repositorydon't know how to extract package [green]{pkg}[/]don't know how to handle non-binary package [green]{pkg}[/]don't know how to unpack file {filename}downloading {url} to {dest}entity [yellow]{ref}[/] not foundexiting due to EOFexiting due to keyboard interruptexpected remote URL: [yellow]{url}[/]extra command {cmd} is already provided by another package, overriding itextracting [green]{distfile}[/] for package [green]{pkg}[/]failed to access entity schemas directory {dir}: {reason}failed to compile schema for {entity_type}: {reason}failed to download and install packagesfailed to fetch '{dest}': all source URLs have failedfailed to fetch distfile: command '{cmd}' returned {retcode}failed to fetch distfile: {file} failed integrity checksfailed to fetch from remote URL {url}: {reason}failed to load entity from {path}: {reason}failed to probe system libcryptofile '{filename}' does not appear to be a debfile {file} is corrupt: size too big ({actual_size} > {expected_size}); deletingfile {file} is corrupt: {reason}; deletingflashing failed, check your device right nowfor now, only toolchains with differing target tuples can co-exist in one virtual environmentfor the old behavior of listing all packages, try [yellow]ruyi list --all[/]forcing return code to 1; the plugin should be fixedgit branch to trackgit remote URLgunzip failed: command {cmd} returned {retcode}has known issueshuman-readable name for the repoignoring [[repos]] entry '{id}': at least one of 'remote' or 'local' must be setignoring [[repos]] entry '{id}': the local path '{path}' is not absoluteignoring [[repos]] entry with invalid id: '{id}'ignoring [[repos]] entry with reserved id '{id}'; use [repo] to configure the default repositoryignoring duplicate [[repos]] entry with id '{id}'in order to hide this banner:instructions on fetching this file:internal error: CLI entrypoint was added without a telemetry keyinternal error: no bindir configured for target [yellow]{target_tuple}[/]internal error: no target data for tuple [yellow]{target_tuple}[/]internal error: resolved command path is outside of the providing packageinvalid --var spec {spec!r}: empty keyinvalid --var spec {spec!r}: expected KEY=VALUEinvalid config key: {key}invalid config section: {section}invalid config value for key {key} (type {typ}): {val}invalid repo id '{id}'invalid restrict kinds given: {restrict}invalid value type for config key {key}: {actual_type}, expected {expected_type}it is now [yellow]'{current_name}'[/]latestlatest-prereleaselocal path '{path}' must be absolutelocal path to use instead of or alongside remotelz4 failed: command {cmd} returned {retcode}malformed config file: {path}malformed config: device variant '{devid}' asks for no packages but provides no messages eithermalformed package fetch instructionsmalformed package fetch instructions: the param named '{param}' is reserved and cannot be overridden by packagesmalformed telemetry state: unable to determine upload weekday, nothing will be uploadedmanual intervention is required to avoid data lossmigrating repo directory from [yellow]{old}[/] to [yellow]{new}[/]multiple toolchains specified with differing target architecturenew priority valueno active repo with id '{id}'no argv?no configured target found for command [yellow]{basename}[/]no data specified for cleaningno fetcher is available on the systemno filter specified for list operationno packages to uninstallno repo with id '{id}' found in user configno schema found for entity type: {entity_type}no versions found for package [yellow]{pkg}[/]non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {weekday}[/]not removing the Ruyi repo: it is outside of the Ruyi cache directoryone entry could not be copiedonly sync the repo with this IDor opt out at any time by running [yellow]ruyi telemetry optout[/]package '{category}/{name}' was installed from repo '{repo}' but the latest version is in a different repopackage [green]{pkg}[/] declares no binary for host {host}package [green]{pkg}[/] declares no blob distfilepackage [green]{pkg}[/] declares no distfile for host {host}package [green]{pkg}[/] has been extracted to {dest_dir}package [green]{pkg}[/] installed to [yellow]{install_root}[/]package [green]{pkg}[/] is not tracked as installed, but its directory [yellow]{install_root}[/] exists.package [green]{pkg}[/] seems already installed; purging and re-installing due to [yellow]--reinstall[/]package [green]{pkg}[/] uninstalledpackage [green]{pkg}[/]{repo_tag} installed to [yellow]{install_root}[/]package has known issue(s)package repository is updatedpath [cyan]'{path}'[/] is currently mounted at [yellow]'{target}'[/]please [bold red]fix the repo settings manually[/]please check [yellow]ruyi self clean --help[/] for a list of cleanable dataplease install and retryplease install them and press [green]Enter[/] to retry, or [green]Ctrl+C[/] to exitplease rename the command file and retryplease uninstall via the external manager insteadprereleasepriority (higher = overrides lower)processing deltasprofile '{profile}' not foundprojected sysroot from [yellow]{src}[/]: copied {copied_count} entries, skipped {skipped_count} entriespurged cached data at '{path}'quirks needed by profile: {humanized_list}quirks provided by package: {humanized_list}re-run with environment variable [yellow]{env_var}[/] set to one of [yellow]{choices}[/] to signify consentrefusing to run as super user outside CI without explicit consentrejecting the path for safety; please double-check and retryremovable disk to use as live mediumremoving all telemetry dataremoving cached dataremoving downloaded distfilesremoving installed packagesremoving read status of news itemsremoving state dataremoving the Ruyi reporemoving the ruyi binaryrepo '{id}' added; run 'ruyi update' to syncrepo '{id}' declares id '{on_disk_id}' in its config.toml; expected '{id}'repo '{id}' disabledrepo '{id}' enabledrepo '{id}' priority set to {priority}repo '{id}' removedrepo directory migration completerepository identifierrepository identifier to disablerepository identifier to enablerepository identifier to removerepository: [yellow]{path}[/]retrying download ({current} of {total} times)scope {scope}: the next upload will happen [bold green]today[/] if not alreadyscope {scope}: usage information has already been uploaded sometime todayscope {scope}: usage information has already been uploaded today at {last_upload_time_str}skipping already installed package [green]{pkg}[/]skipping installation because [yellow]--fetch-only[/] is givenskipping not-installed package [green]{pkg}[/]skipping sync for local-only repo '{id}'some unreadable or unsupported files were skipped; the projected sysroot may be incompletesubcommandssyncing repo '{id}'sysroot is requested but the package [yellow]{atom}[/] does not contain onesysroot is requested but the toolchain package does not include one, and no explicit sysroot source is giventarget's '{part}' partitiontarget's whole disktelemetry data collection is now disabledtelemetry data uploading is now enabledtelemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requestedtelemetry mode is [green]off[/]: no further data will be collectedtelemetry mode is [green]off[/]: nothing is collected or uploaded after the first runtelemetry mode is [green]on[/]: usage data is collected and periodically uploadedtelemetry mode is now set to local collection onlythe 'restrict' field to use for all mentioned distfiles, separated with commathe Ruyi toolchain mux is not configuredthe config [yellow]{key}[/] is protected and not meant to be overridden by usersthe config key [yellow]{key}[/] cannot be set from user config; ignoringthe emulator package [yellow]{atom}[/] does not support the target architecture [yellow]{arch}[/]the file [yellow]'{file}'[/] cannot be automatically fetchedthe following packages will be uninstalled:the local repo path '{path}' is not absolute; ignoringthe next upload will happen anytime [yellow]ruyi[/] is executed:the package [yellow]{atom}[/] does not provide any command for host [yellow]{host}[/], ignoringthe package [yellow]{atom}[/] does not support all necessary features for the profile [yellow]{profile}[/]the package [yellow]{atom}[/] is not a binary-providing packagethe package [yellow]{atom}[/] is not a toolchainthe package [yellow]{atom}[/] is not an emulatorthe package repository does not exist at [yellow]{root}[/]the requested fetcher '{name}' is unavailable on the systemthe rootfs directory [yellow]{path}[/] does not existthe sysroot directory [yellow]{path}[/] does not existthe target tuple [yellow]{target_tuple}[/] is already covered by one of the requested toolchainsthe {ruyi_exe} executable must be named [green]'{expected_name}'[/] to workthere is no news item with ID '{id}'there is no news item with ordinal {ord}this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {weekday}[/]this [yellow]ruyi[/] is externally managed, for example, by the system package manager, and cannot be uninstalled this waythis [yellow]ruyi[/] is not in standalone form, and cannot be uninstalled this waythis virtual environment has no QEMU-like emulator configuredtransferring objectsunexpected return type of cmd plugin '{plugin_id}': {type} is not int.uninstallation aborteduninstallation consent given over CLI, proceedinguninstalling package [green]{pkg}[/]unique repository identifierunknown fetcher '{name}'unrecognized dist URL scheme: {scheme}untar failed: command {cmd} returned {retcode}unzip failed: command {cmd} returned {retcode}updating the package repositoryupstream: {upstream_ver}using the target architecture of the first toolchain: [yellow]{arch}[/]version cannot be overridden for slug atom [green]{atom}[/]wrong {kind} checksum: want {want}, got {got}xz failed: command {cmd} returned {retcode}you can opt out at any time by running [yellow]ruyi telemetry optout[/]you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]your device was not touchedzstd failed: command {cmd} returned {retcode}{count} entries could not be copied{profile_id} (arch: [green]{arch}[/]){profile_id} (arch: [green]{arch}[/], needs quirks: [yellow]{need_quirks}[/])Project-Id-Version: ruyi 0.49.0-alpha.20260422 Report-Msgid-Bugs-To: https://github.com/ruyisdk/ruyi/issues POT-Creation-Date: 2026-05-19 14:37+0800 PO-Revision-Date: 2026-01-16 01:05+0800 Last-Translator: WANG Xuerui Language: zh_Hans_CN Language-Team: zh_Hans_CN Plural-Forms: nplurals=1; plural=0; MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Generated-By: Babel 2.18.0 收到键盘中断,正在退出。 ### 二进制制品 ### 工具链元数据 正在中止。设备未受任何操作。您可在修复 [yellow]fastboot[/] 对设备的识别后重新启动向导。 所有新闻条目均已读。如要查看新闻列表,请执行 [yellow]ruyi news list[/]。 您是否要完全禁用遥测? 下载可能因各种原因失败,其中大多数原因不应也无法由 [yellow]Ruyi[/] 处理。不过为了您的方便, 请检查以下常见失败模式是否适用于您。如果您的情况属于其中之一,请采取相应措施: * 基本连接问题 - [yellow]网关[/]是否可访问? - [yellow]常用网站[/]是否可访问? - 是否存在 [yellow]DNS 污染[/]? * 您的组织 和/或 互联网服务提供商(ISP)限制 - 是否有[yellow]防火墙[/]阻止 Ruyi 流量? - 您的 [yellow]ISP 是否阻止访问[/]源网站? * 上游不稳定 - 软件包内记录的[yellow]链接是否失效[/]?(请向 Ruyi 提起工单以获取修复!) 正在退出。设备未受任何操作,您可随时重新启动向导。 正在退出。您可在准备好之后随时重新启动向导。 正在退出。您可随时重新启动向导。 要初始化此目标设备,您需要将设备的存储(例如 SD 卡或 NVMe SSD)插入此主机系统,或插入一个 可移动磁盘以重新格式化为 live 介质。您还需要记下相应的设备文件路径,例如 /dev/sdX、 /dev/nvmeXnY(对于完整磁盘),或者 /dev/sdXY、/dev/nvmeXnYpZ(对于分区)。您可参考例如 [yellow]sudo blkid[/] 输出等,以获取稍后需要的信息。 您想如何继续? 看起来刷写已顺利完成。 [bold green]祝您折腾愉快![/] 您的某些已安装软件包有更新的版本可用: 软件包 [green]{atom}[/] 已有版本约束。 软件包有已知问题: 要安装的软件包版本: 重新运行 [yellow]ruyi install[/] 以升级,不要忘记重新创建任何受影响的虚拟环境。 正在重新选择软件包版本... RuyiSDK 仅以运行中的 [yellow]ruyi[/] 版本号的形式最小化地收集使用数据,以帮助我们改进产品。经您同意, RuyiSDK 也可以收集并定期发送额外的、无法用来追踪用户身份的使用数据。数据将由位于中国大陆的、 由 RuyiSDK 团队管理的服务器记录和处理。 [green]默认情况下,任何内容都不会离开您的计算机[/],您也可以完全关闭使用数据收集。 只有在您明确许可的情况下,[yellow]ruyi[/] 才能收集和上传更多使用数据。您随时可以通过运行 [yellow]ruyi telemetry consent[/]、[yellow]ruyi telemetry local[/] 或 [yellow]ruyi telemetry optout[/] 更改设置。 我们还将一次性从本台 [yellow]ruyi[/] 实例发送单条报告,以便 RuyiSDK 团队更好地了解产品用户增长情况。 如果您选择不同意,这将是唯一被上传的数据,我们不会生成或保留任何跟踪用的 ID。 感谢您帮助我们构建更好的体验! 为软件包 [green]{pkg}[/] 选择一个版本: 某些刷写步骤需要使用 fastboot,此时您应该确保目标设备显示在了 [yellow]fastboot devices[/] 输出中。 请[bold red]在继续之前自行确认[/]。 该设备有以下变体。请选择与您手头硬件对应的变体: 向导当前支持以下设备。请选择您的设备: 您选择的设备变体支持以下系统配置。请选择您想要安装到设备上的配置: 有 {count} 条新的新闻条目: 我们即将为您的设备下载并安装以下软件包: 我们已收集到足够的信息以进行实际刷写。现在是您重新检查并确认一切正常的最后机会。 我们即将: 您可以使用 [yellow]ruyi news read[/] 阅读它们。 [bold green]RuyiSDK 设备安装向导[/] 这是一个向导,旨在帮助您轻松地在您的设备上安装系统,以便您开展开发工作。 您将被问及一些问题,好让 RuyiSDK 了解您的设备和您预期的配置,然后一些软件包将被下载并刷写到 设备的存储上。您应当事先以某种方式在此主机系统上提供该存储。 请注意,由于 Ruyi 不以 [yellow]root[/] 身份运行,但刷写镜像很可能需要底层的磁盘访问权限, 您应当配置您的用户能够以 [yellow]sudo[/] 方式运行 [yellow]dd[/] 等必要命令。 如果您的 [yellow]sudo[/] 配置不允许您这么做,刷写将会失败。 [bold green]提示[/]:您可以为 [yellow]ruyi[/] 启用 shell 自动补全,方法是将以下 行添加到 [green]{shrc}[/] 中(如果您还没有如此操作过): [green]eval "$(ruyi --output-completion-script={shell})"[/] 您也可以稍后运行以下命令来完成此操作: [green]echo 'eval "$(ruyi --output-completion-script={shell})"' >> {shrc}[/] [bold]软件包版本选择[/] [bold]感谢您折腾 [yellow]Ruyi[/]![/] 这将从您的系统中卸载 [yellow]Ruyi[/]。如果在命令行中提供了[green]--purge[/] 开关, 则还将移除所有已安装的软件包和 [yellow]Ruyi[/] 所管理的仓库数据。 请注意,您的 [yellow]Ruyi[/] 虚拟环境在 [yellow]Ruyi[/] 卸载后[bold]将变得不可用[/]。您需要自行迁移或清理它们。 - 可用命令: - 分发文件:{distfiles} - 大小:[yellow]{size}[/] 字节 (无条目) (无未读条目) - [green]{category}/{name}[/]({version}) - 在 [bold green]{time_start}[/] 和 [bold green]{time_end}[/] 之间 - 或者如果上次上传是一周之前 所有间接相关的实体: 直接正向关系: 直接正向关系:[gray]无[/] 直接反向关系: 直接反向关系:[gray]无[/] - 使用 [yellow]ruyi telemetry optout[/] 退出遥测 - 或使用 [yellow]ruyi telemetry consent[/] 明确同意'{id}' 是保留的;请使用 [repo] 配置来配置默认仓库(1-{nr_choices})(1-{nr_choices},默认为 {default})(非面向普通用户)用来管理 Ruyi 软件源仓库的子命令(无)* 组件:* 主机 [green]{host}[/]:* 软件包类型:{kind}* 特殊特性:{quirks}* Slug:(无)* Slug:[yellow]{slug}[/]* 目标:[bold green]{target}[/]* 上游版本号:(未声明)* 上游版本号:{version}* 供应商:{vendor}--only-packages 仅能配合 --repo 指定--without-sysroot 不能与任何 sysroot 来源选项同时使用中止设备配置可接受的选择:Y/y/yes 表示“是”,N/n/no 表示“否”。可接受的选择:从 1 到 {nr_choices}(含)的整数。添加一个软件包仓库也显示每个软件包的详细信息要传递给插件命令的参数对所有问题都回答“是”从一个配方文件构建软件包我们默认将安装每个软件包的最新版本,但在当前情况下,也可以选择其他版本。检查软件包清单文件和元数据仓库要运行的检查(可重复;默认为所有检查)选择?{choices_help} 清除 Ruyi 程序缓存命令名称继续使用这些版本继续吗?将给定目录中的 sysroot 复制到虚拟环境中版权所有 (C) 中国科学院软件研究所 (ISCAS)。所有权利保留。 许可证:Apache-2.0 正在在 [green]{dest}[/] 创建 Ruyi 虚拟环境 [cyan]'{name}'[/]...正在在 [green]{dest}[/] 创建 Ruyi 虚拟环境...描述一个实体解压缩的目标目录(默认:当前目录)禁用一个软件包仓库不在新虚拟环境内准备任何 sysroot不输出任何内容,仅标记为已读不要打印正在执行的操作您是否同意定期上传使用数据?读取用户输入时遇到 EOF,假定为默认选择 {yesno}启用一个软件包仓库启用详细输出实体 [bold]{entity}[/]([green]{display_name}[/]) 直接将文件解压缩到 DESTDIR 而不是以软件包命名的子目录仅获取分发文件而不安装获取软件包然后解压缩到当前目录强制重新安装已安装的软件包要生成的校验和章节的格式将给定的软件包清单文件格式化为规范的 TOML 表示为给定的分发文件生成清单文件的校验和章节生成适应所选工具链和配置文件的虚拟环境在 CLI 中确认卸载;不要请求确认同意上传遥测数据在适用的情况下,以机器友好格式输出ID如果您认为这是一个 bug,请在 [yellow]https://github.com/ruyisdk/ruyi/issues[/] 提交工单。从已配置的仓库安装软件包与仓库中定义的实体进行交互交互式地初始化一台设备做开发工作设备现在是否被 fastboot 识别到了?列出所有可用配置文件列出并阅读已配置仓库中的新闻条目列出已配置仓库中的可用软件包列出已配置的软件包仓库列出实体列出此类型的实体。可以多次传递该参数以列出多种类型。列出新闻条目可用软件包列表: 仅列出未读的新闻条目管理 Ruyi 的配置选项管理已配置的软件包仓库管理设备管理当前的 Ruyi 安装管理您的遥测偏好设置将所有新闻条目标记为未读匹配、显示所有软件包匹配属于特定类别的软件包,其中类别的名称包含给定字符串匹配来自给定类别的软件包匹配与给定实体相关的软件包匹配已安装(y/true/1)或未安装(n/false/0)的软件包匹配名称包含给定字符串的软件包否没有要显示的新闻。序号仅检查与后接的 ruyi list 过滤器匹配的软件包;仅能配合 --repo 指定[green]{pkg}[/] 仅有一个可用版本:[blue]{ver}[/],使用它。退出遥测数据收集要阅读的新闻条目的序号或 ID输入 [yellow]'{user_input}'[/] 超出了范围。将新闻条目输出到控制台并标记为已读。如果未指定条目,默认阅读所有未读条目。覆盖主机架构(通常不需要)覆盖构建配方项目的输出目录自定义虚拟环境的名称软件包声明了 {count} 个分发文件: 要检查的元数据仓库根路径要检查的软件包清单文件的路径(可重复)要计算校验和的分发文件的路径要为其生成清单文件的分发文件的路径新虚拟环境的路径配方 .star 文件的路径请提供 {part_desc} 的路径:如果您确定这样做是安全的,请手动移除它。按 [green][/] 键继续:打印构建计划而不执行它打印当前遥测模式打印版本信息进行刷写吗?继续吗?环境使用的配置文件从给定的发行版 rootfs 目录投影构建用 sysroot在新虚拟环境内准备一个全新的 sysroot(默认)查询 Ruyi 配置选项的值阅读新闻条目以 ':' 形式描述实体的引用移除一个软件包仓库从 Ruyi 配置中移除一个章节移除所有涵盖的数据移除所有已下载的分发文件(如果有)移除所有已安装的软件包和 Ruyi 管理的远端仓库数据移除所有已安装的软件包(如果有)移除所有已记录的遥测数据(如果有)移除 Ruyi 仓库(如果位于 Ruyi 管理的缓存目录中)移除 Ruyi 管理的各种数据以回收存储空间重新选择版本运行一个由插件定义的命令Ruyi 创建虚拟环境时不会提权;请使用当前用户可读的 sysroot、--symlink-sysroot-from-dir,或 --project-sysroot-from-rootfsRuyi {version} 在 {host} 上运行。RuyiSDK 包管理器按名称选择特定的计划构建任务(可重复);默认情况下执行所有计划构建任务已选择:[blue]{new_atom}[/]为构建配方设置用户变量(可重复)将遥测模式设置为仅本地收集为一个软件包仓库设置优先级设置 Ruyi 配置选项的值要使用的模拟器软件包的指示表达式(atom)要安装的软件包的指示表达式(atom)要卸载的软件包的指示表达式(atom)要解压缩的软件包的指示表达式(atom)要使用的 sysroot 软件包的指示表达式(atom),如工具链软件包也内置了 sysroot 则优先于它要向新虚拟环境添加额外命令,这些命令的提供者软件包的指示表达式(atoms)要使用的工具链软件包的指示表达式(atoms)将虚拟环境的 sysroot 符号链接到给定的现有目录Sysroot 准备方式: 默认情况下,如果所选工具链内置了 sysroot,Ruyi 会使用该 sysroot。 使用 --copy-sysroot-from-pkg 可从另一个已安装的工具链软件包复制 sysroot。 仅当给定目录是当前用户可读的完整 sysroot 时,才使用 --copy-sysroot-from-dir;该选项会忠实复制整棵目录树。 使用 --symlink-sysroot-from-dir 可让虚拟环境的 sysroot 指向已有 sysroot 目录,而不复制其内容。 对于发行版 rootfs 或 chroot 目录树,请使用 --project-sysroot-from-rootfs:Ruyi 会复制 include、lib*、usr/include、usr/lib*、usr/share、bin、sbin 等常见交叉构建目录,并跳过不可读或不受支持的文件。 Ruyi 创建虚拟环境时绝不提权。如果 rootfs 含有 /etc/shadow 等私有系统文件,请自行准备可读的 sysroot,或使用投影模式。要查询的 Ruyi 配置选项要设置的 Ruyi 配置选项要取消设置的 Ruyi 配置选项在 PATH 中找不到命令 {cmds},而 [yellow]ruyi[/] 需要它们要移除的章节要设置的值该 Ruyi 安装由外部管理。此 ruyi 发行版包含以 Mozilla Public License 2.0 (https://mozilla.org/MPL/2.0/) 授权的代码。您可以从相应项目的官方网站获取源代码: * certifi: https://github.com/certifi/python-certifi 标题远端 '[yellow]{remote}[/]' 的 URL 与预期 URL 不匹配Unicode 解码错误:{path}卸载 Ruyi卸载已安装的软件包未识别的输入 [yellow]'{user_input}'[/]。取消设置 Ruyi 配置选项更新 RuyiSDK 仓库和软件包立即上传已收集的遥测数据您想更改它们吗?您想自定义软件包版本吗?是当前,您必须至少指定一个工具链 atom,例如 [yellow]`-t gnu-plct`[/][bold green]新闻条目:[/] [bold green]信息:[/]{message}[bold red]致命错误:[/]{message}[bold yellow]警告:[/]{message}[green]已下载[/][green]已安装[/][red]无适用当前主机的二进制文件[/][yellow]有已知问题[/][yellow]ruyi[/] 已被卸载已有一个 ID 为 '{id}' 的软件包仓库了中止卸载实际远端 URL: [yellow]{url}[/]同时从磁盘中移除缓存的仓库数据URL 或 --local 至少需要提供一个至多只能指定 --copy-sysroot-from-pkg、--copy-sysroot-from-dir、--symlink-sysroot-from-dir 和 --project-sysroot-from-rootfs 中的一个atom [yellow]{atom}[/] 不存在或未安装atom {atom} 在仓库中未匹配到任何软件包尝试修改受保护的全局配置项:{key}构建任务 {name!r} 完成:{n} 个构建产物bzip2 失败:命令 {cmd} 返回 {retcode}无法将 sysroot 从 [yellow]{src}[/] 复制到 [green]{dest}[/]:{err}无法从 [yellow]{src}[/] 复制 sysroot:{reason}无法将仓库快进到新获取的状态在 sysroot 软件包中找不到 GCC include 和 lib 目录找不到模拟器的安装目录找不到软件包 [yellow]{pkg}[/] 的安装目录找不到 sysroot 软件包的安装目录找不到工具链的安装目录无法处理软件包 [green]{pkg}[/]:软件包既是二进制又是源码无法将 [yellow]{atom}[/] 匹配到工具链软件包无法将 [yellow]{atom}[/] 匹配到模拟器软件包无法将 [yellow]{atom}[/] 匹配到额外命令软件包无法将 [yellow]{src}[/] 投影为 [green]{dest}[/] 处的 sysroot:{err}无法从 [yellow]{src}[/] 投影 sysroot:未找到受支持的 sysroot 目录无法移除由系统提供的仓库 '{id}';请使用 'repo disable' 来禁用它无法移除默认仓库 '{id}';请使用 'repo disable' 来禁用它要创建虚拟环境,请查阅 `ruyi venv` 相关信息校验和算法 {kind} 不被支持正在清除 Ruyi 程序缓存正在从 [cyan link={remote}]{remote}[/] 克隆在仓库中找不到软件包 [yellow]{pkg}[/]不知道如何解压缩软件包 [green]{pkg}[/]不知道如何处理非二进制软件包 [green]{pkg}[/]不知道如何解包文件 {filename}正在将 {url} 下载到 {dest}未找到实体 [yellow]{ref}[/]由于 EOF 退出由于键盘中断退出预期远端 URL: [yellow]{url}[/]额外命令 {cmd} 已由另一个软件包提供,将用当前软件包覆盖它正在为软件包 [green]{pkg}[/] 解压缩 [green]{distfile}[/]访问实体格式定义目录 {dir} 失败:{reason}编译 {entity_type} 的格式定义失败:{reason}下载和安装软件包失败获取 '{dest}' 失败:所有源 URL 均失败获取分发文件失败:命令 '{cmd}' 返回 {retcode}获取分发文件失败:{file} 未通过完整性检查从远端 URL {url} 获取失败:{reason}从 {path} 加载实体失败:{reason}探测系统 libcrypto 失败文件 '{filename}' 似乎不是一个 deb文件 {file} 已损坏:大小过大({actual_size} > {expected_size});正在移除文件 {file} 已损坏:{reason};正在移除刷写失败,请立即检查您的设备目前,只有目标元组不同的工具链才能在一个虚拟环境中共存如果您意图唤起“列出所有软件包”这一旧版行为,请尝试 [yellow]ruyi list --all[/]强制返回码为 1;该插件需要被修复要跟踪的 Git 分支Git 远端 URLgunzip 失败:命令 {cmd} 返回 {retcode}有已知问题仓库的可读名称忽略 [[repos]] 条目 '{id}':'remote' 和 'local' 至少要设置一个忽略 [[repos]] 条目 '{id}':本地路径 '{path}' 不是绝对路径忽略具有无效 id 的 [[repos]] 条目:'{id}'忽略具有保留 id '{id}' 的 [[repos]] 条目;请使用 [repo] 来配置默认仓库忽略具有重复 id '{id}' 的 [[repos]] 条目要隐藏此横幅:获取此文件的方法说明:内部错误:CLI 入口点被添加时未附带遥测键内部错误:未为目标 [yellow]{target_tuple}[/] 配置 bindir内部错误:元组 [yellow]{target_tuple}[/] 没有目标数据内部错误:解析的命令路径落在了提供者软件包之外无效的 --var 规范 {spec!r}:键为空无效的 --var 规范 {spec!r}:预期格式为 KEY=VALUE无效的配置项:{key}无效的配置章节:{section}配置项 {key} 的值无效(类型 {typ}):{val}无效的仓库 ID '{id}'给出了无效的 restrict 类型:{restrict}配置项 {key} 的值类型无效:{actual_type},期望 {expected_type}当前为 [yellow]'{current_name}'[/]最新最新预发布本地路径 '{path}' 必须是绝对路径本地路径,用于替代远端路径,或与远端路径一起使用lz4 失败:命令 {cmd} 返回 {retcode}配置文件格式错误:{path}配置格式错误:设备变体 '{devid}' 未要求任何软件包,但也未提供任何消息文案软件包下载方法文案的格式错误软件包下载方法文案的格式错误:名为 '{param}' 的参数是保留的,不能被软件包覆盖遥测状态格式错误:无法确定上传日,将不会上传任何数据需要手动干预以避免数据丢失正在将仓库目录从 [yellow]{old}[/] 迁移到 [yellow]{new}[/]指定了具有不同目标架构的多个工具链新的优先级取值没有 ID 为 '{id}' 的活动仓库没有 argv?未找到命令 [yellow]{basename}[/] 对应的已配置目标未指定要清理的数据系统上没有可用的下载器未为 list 操作指定过滤器没有要卸载的软件包在用户配置中未找到 ID 为 '{id}' 的软件包仓库未找到实体类型的格式定义:{entity_type}找不到软件包 [yellow]{pkg}[/] 的任何版本无法用来追踪用户身份的使用信息将于[bold green]每{weekday}[/]上传到 RuyiSDK 管理的服务器不移除 Ruyi 仓库:它位于 Ruyi 缓存目录之外有 1 个条目无法复制仅同步 ID 为此值的仓库或随时通过运行 [yellow]ruyi telemetry optout[/] 退出遥测软件包 '{category}/{name}' 是从仓库 '{repo}' 安装的,但最新版本在另一个仓库中软件包 [green]{pkg}[/] 未为主机 {host} 声明二进制文件软件包 [green]{pkg}[/] 未声明 blob 分发文件软件包 [green]{pkg}[/] 未为主机 {host} 声明分发文件软件包 [green]{pkg}[/] 已被解压缩到 {dest_dir}软件包 [green]{pkg}[/] 已安装到 [yellow]{install_root}[/]软件包 [green]{pkg}[/] 未被记录为已安装,但其目录 [yellow]{install_root}[/] 存在。软件包 [green]{pkg}[/] 似乎已安装;由于传入了 [yellow]--reinstall[/],将移除并重新安装它软件包 [green]{pkg}[/] 已被卸载软件包 [green]{pkg}[/]{repo_tag} 已安装到 [yellow]{install_root}[/]软件包有已知问题软件包仓库已更新路径 [cyan]'{path}'[/] 当前挂载在 [yellow]'{target}'[/]请[bold red]手动修复仓库设置[/]请查看 [yellow]ruyi self clean --help[/] 以获取可清理数据的列表请安装并重试请安装它们并按 [green]Enter[/] 重试,或按 [green]Ctrl+C[/] 退出请给命令文件重命名后重试请通过外部管理器卸载预发布优先级(高值 = 覆盖低值)正在处理 deltas未找到配置文件 '{profile}'已从 [yellow]{src}[/] 投影 sysroot:复制了 {copied_count} 个条目,跳过了 {skipped_count} 个条目已清除 '{path}' 的缓存数据配置文件所需的特殊特性:{humanized_list}软件包所提供的特殊特性:{humanized_list}将环境变量 [yellow]{env_var}[/] 设为 [yellow]{choices}[/] 其中之一以明示授权,而后请重新运行拒绝在无明示授权的前提下,在 CI 环境以外以超级用户身份运行出于安全考虑,拒绝该路径;请再次检查后重试用作 live 介质的可移动磁盘正在移除所有遥测数据正在移除缓存数据正在移除已下载的分发文件正在移除已安装的软件包正在移除新闻条目的阅读状态正在移除状态数据正在移除 Ruyi 仓库正在移除 ruyi 可执行文件已添加软件包仓库 '{id}';运行 'ruyi update' 以同步仓库 '{id}' 在其 config.toml 中声明了 id '{on_disk_id}';预期为 '{id}'已禁用软件包仓库 '{id}'已启用软件包仓库 '{id}'软件包仓库 '{id}' 的优先级已设置为 {priority}已移除软件包仓库 '{id}'仓库目录迁移完成软件包仓库标识符要禁用的仓库的标识符要启用的仓库的标识符要移除的仓库的标识符仓库: [yellow]{path}[/]正在重试下载(第 {current} 次,共 {total} 次){scope} 范畴:下次上传将在[bold green]今天[/]进行(如果尚未上传){scope} 范畴:使用信息已于今天上传{scope} 范畴:使用信息已于今天 {last_upload_time_str} 上传跳过已安装的软件包 [green]{pkg}[/]由于传入了 [yellow]--fetch-only[/],跳过安装跳过未安装的软件包 [green]{pkg}[/]为设置为仅本地的仓库 '{id}' 跳过同步已跳过一些不可读或不受支持的文件;投影得到的 sysroot 可能不完整子命令正在同步仓库 '{id}'请求了 sysroot,但软件包 [yellow]{atom}[/] 不含 sysroot请求了 sysroot,但工具链软件包不含 sysroot,且未给出显式的 sysroot 来源目标的 '{part}' 分区目标的整个磁盘现已禁用遥测数据收集现已启用遥测数据上传遥测模式为 [green]local[/]:仅本地使用收集,除非明确请求否则不会上传遥测模式为 [green]off[/]:不会收集更多数据遥测模式为 [green]off[/]:首次运行后,不会收集或上传任何内容遥测模式为 [green]on[/]:使用数据会被收集并定期上传遥测模式现已设置为仅本地收集所有提及的分发文件使用的 'restrict' 字段,用逗号分隔Ruyi 工具链复用器未配置配置项 [yellow]{key}[/] 受保护,不应被用户覆盖配置项 [yellow]{key}[/] 不能从用户配置文件被设置;忽略模拟器软件包 [yellow]{atom}[/] 不支持目标架构 [yellow]{arch}[/]文件 [yellow]'{file}'[/] 无法被自动获取以下软件包将被卸载:本地仓库路径 '{path}' 不是绝对路径;忽略下次上传将在执行 [yellow]ruyi[/] 时进行:软件包 [yellow]{atom}[/] 未为主机 [yellow]{host}[/] 提供任何命令,将被忽略软件包 [yellow]{atom}[/] 不支持配置文件 [yellow]{profile}[/] 所需的所有特性软件包 [yellow]{atom}[/] 未提供二进制文件软件包 [yellow]{atom}[/] 不是工具链软件包 [yellow]{atom}[/] 不是模拟器软件包仓库不存在于 [yellow]{root}[/]请求的下载器 '{name}' 在当前系统不可用rootfs 目录 [yellow]{path}[/] 不存在sysroot 目录 [yellow]{path}[/] 不存在目标元组 [yellow]{target_tuple}[/] 已被请求的工具链之一涵盖{ruyi_exe} 可执行文件必须名为 [green]'{expected_name}'[/] 才能工作没有 ID 为 '{id}' 的新闻条目没有序号为 {ord} 的新闻条目本 [yellow]ruyi[/] 安装的遥测模式被设置为 [yellow]on[/],[bold]将于[bold green]每{weekday}[/]上传无法用来追踪用户身份的使用信息到 RuyiSDK 管理的服务器[/]这个 [yellow]ruyi[/] 由外部管理,例如系统包管理器,不能以这种方式卸载这个 [yellow]ruyi[/] 不是独立形式,不能以这种方式卸载此虚拟环境未配置有类似 QEMU 的模拟器正在传输对象命令插件 '{plugin_id}' 的返回类型非预期:{type} 不是 int。卸载已中止已通过 CLI 同意卸载,继续进行正在卸载软件包 [green]{pkg}[/]唯一的仓库标识符未知的下载器 '{name}'未识别的分发 URL 协议:{scheme}tar 解包失败:命令 {cmd} 返回 {retcode}unzip 失败:命令 {cmd} 返回 {retcode}正在更新软件包仓库上游:{upstream_ver}将使用第一个工具链的目标架构:[yellow]{arch}[/]slug atom [green]{atom}[/] 的版本无法覆盖错误的 {kind} 校验和:期望 {want},得到 {got}xz 失败:命令 {cmd} 返回 {retcode}您可以随时通过运行 [yellow]ruyi telemetry optout[/] 退出遥测您可以随时通过运行 [yellow]ruyi telemetry consent[/] 重新启用遥测数据上传您可以随时通过运行 [yellow]ruyi telemetry consent[/] 重新启用遥测数据上传您的设备未受任何操作zstd 失败:命令 {cmd} 返回 {retcode}有 {count} 个条目无法复制{profile_id}(架构:[green]{arch}[/]){profile_id}(架构:[green]{arch}[/],需要特殊特性:[yellow]{need_quirks}[/])ruyisdk-ruyi-1f00e2e/resources/bundled/meson-cross.ini.jinja000066400000000000000000000010421520522431500242640ustar00rootroot00000000000000# Use like: # # meson setup --cross-file {{ venv_root }}/meson-cross.ini ... # # Needs meson 0.56.0+. [binaries] c = '{{ cc }}' cpp = '{{ cxx }}' {%- for key, path in meson_additional_binaries.items() %} {{ key }} = '{{ path }}' {%- endfor %} {% if sysroot %} [built-in options] prefix = '{{ sysroot }}' {% endif %} [properties] cmake_toolchain_file = '{{ cmake_toolchain_file }}' {%- if sysroot %} sys_root = '{{ sysroot }}' {%- endif %} [host_machine] system = 'linux' cpu_family = '{{ processor }}' cpu = '{{ processor }}' endian = 'little' ruyisdk-ruyi-1f00e2e/resources/bundled/prompt.venv-created.en.txt.jinja000066400000000000000000000010431520522431500263610ustar00rootroot00000000000000The virtual environment is now created. You may activate it by sourcing the appropriate activation script in the [green]bin[/] directory, and deactivate by invoking `ruyi-deactivate`. {%- if sysroot %} A fresh sysroot/prefix is also provisioned in the virtual environment. It is available at the following path: [green]{{ sysroot }}[/] {%- endif %} The virtual environment also comes with ready-made CMake toolchain file and Meson cross file. Check the virtual environment root for those; comments in the files contain usage instructions. ruyisdk-ruyi-1f00e2e/resources/bundled/prompt.venv-created.zh_CN.txt.jinja000066400000000000000000000010741520522431500267640ustar00rootroot00000000000000现已创建完成虚拟环境。 您可“source” [green]bin[/] 目录下合适的激活脚本,以激活此虚拟环境;而后可调用 [yellow]ruyi-deactivate[/] 以离开它。 {%- if sysroot %} 在此虚拟环境中,亦为您部署了一套新的 sysroot/prefix 在以下路径: [green]{{ sysroot }}[/] {%- endif %} 此虚拟环境还内置了开箱即用的 CMake 工具链配置文件(CMake toolchain file)与 Meson 交叉编译文件(Meson cross file)。您可在虚拟环境的根目录找到它们;文件的注释中有使用说明。 ruyisdk-ruyi-1f00e2e/resources/bundled/ruyi-activate.bash.jinja000066400000000000000000000024711520522431500247470ustar00rootroot00000000000000# This file must be used with "source bin/ruyi-activate" *from bash* # you cannot run it directly if [ "${BASH_SOURCE-}" = "$0" ]; then echo "You must source this script: \$ source $0" >&2 exit 33 fi ruyi-deactivate () { # reset old environment variables # ! [ -z ${VAR+_} ] returns true if VAR is declared at all if ! [ -z "${_RUYI_OLD_PATH:+_}" ] ; then PATH="$_RUYI_OLD_PATH" export PATH unset _RUYI_OLD_PATH fi # invalidate the PATH cache hash -r 2>/dev/null if ! [ -z "${_RUYI_OLD_PS1+_}" ] ; then PS1="$_RUYI_OLD_PS1" export PS1 unset _RUYI_OLD_PS1 fi unset RUYI_VENV unset RUYI_VENV_PROMPT if [ ! "${1-}" = "nondestructive" ] ; then # Self destruct! unset -f ruyi-deactivate fi } # unset irrelevant variables ruyi-deactivate nondestructive RUYI_VENV={{ RUYI_VENV | sh }} export RUYI_VENV _RUYI_OLD_PATH="$PATH" PATH="$RUYI_VENV/bin:$PATH" export PATH # invalidate the PATH cache hash -r 2>/dev/null {% if RUYI_VENV_NAME -%} RUYI_VENV_PROMPT={{ RUYI_VENV_NAME | sh }} {%- else -%} RUYI_VENV_PROMPT="$(basename "$RUYI_VENV")" {%- endif %} export RUYI_VENV_PROMPT if [ -z "${RUYI_VENV_DISABLE_PROMPT-}" ] ; then _RUYI_OLD_PS1="${PS1-}" PS1="«Ruyi ${RUYI_VENV_PROMPT}» ${PS1-}" export PS1 fi ruyisdk-ruyi-1f00e2e/resources/bundled/ruyi-activate.fish.jinja000066400000000000000000000035031520522431500247600ustar00rootroot00000000000000# This file must be sourced from fish: `source bin/ruyi-activate.fish` # you cannot run it directly if test "$_" != "source" -a "$_" != "." echo 'You must source this script: $ source '(status filename) >&2 return 33 end function ruyi-deactivate -d "Exit ruyi virtual environment and return to normal shell environment" # reset old environment variables if set -q _RUYI_OLD_PATH set -gx PATH $_RUYI_OLD_PATH set -e _RUYI_OLD_PATH end if set -q _RUYI_OLD_FISH_PROMPT_OVERRIDE set -e _RUYI_OLD_FISH_PROMPT_OVERRIDE if functions -q _ruyi_old_fish_prompt functions -e fish_prompt functions -c _ruyi_old_fish_prompt fish_prompt functions -e _ruyi_old_fish_prompt end end set -e RUYI_VENV set -e RUYI_VENV_PROMPT if test "$argv[1]" != "nondestructive" # Self-destruct! functions -e ruyi-deactivate end end # unset irrelevant variables ruyi-deactivate nondestructive set -gx RUYI_VENV {{ RUYI_VENV | sh }} set -gx _RUYI_OLD_PATH $PATH set -gx PATH "$RUYI_VENV/bin" $PATH {% if RUYI_VENV_NAME -%} set -gx RUYI_VENV_PROMPT {{ RUYI_VENV_NAME | sh }} {%- else -%} set -gx RUYI_VENV_PROMPT (basename $RUYI_VENV) {%- endif %} if test -z "$RUYI_VENV_DISABLE_PROMPT" # Save the current fish_prompt function as the function _ruyi_old_fish_prompt functions -c fish_prompt _ruyi_old_fish_prompt function fish_prompt # Save the return status of the last command set -l old_status $status # Output the prompt echo -n "«Ruyi $RUYI_VENV_PROMPT» " # Restore the return status of the previous command echo "exit $old_status" | . # Output the original/"old" prompt _ruyi_old_fish_prompt end set -gx _RUYI_OLD_FISH_PROMPT_OVERRIDE "$RUYI_VENV" end ruyisdk-ruyi-1f00e2e/resources/bundled/ruyi-cache.toml.jinja000066400000000000000000000014001520522431500242370ustar00rootroot00000000000000# NOTE: This file is managed by ruyi. DO NOT EDIT! [cached_v2] {% if qemu_bin %}qemu_bin = "{{ qemu_bin }}"{% endif %} {% if profile_emu_env %} [cached_v2.profile_emu_env] {% for k, v in profile_emu_env.items() %}{{ k }} = "{{ v }}" {% endfor %}{% endif %} {% for k, v in targets.items() %}[cached_v2.targets.{{ k }}] toolchain_bindir = "{{ v['toolchain_bindir'] }}" toolchain_flags = "{{ v['toolchain_flags'] }}" {% if v["toolchain_sysroot"] %}toolchain_sysroot = "{{ v['toolchain_sysroot'] }}"{% endif %} {% if v["gcc_install_dir"] %}gcc_install_dir = "{{ v['gcc_install_dir'] }}"{% endif %} {% endfor %} {% for k, v in cmd_metadata_map.items() %}[cached_v2.cmd_metadata_map."{{ k }}"] dest = "{{ v['dest'] }}" target_tuple = "{{ v['target_tuple'] }}" {% endfor %} ruyisdk-ruyi-1f00e2e/resources/bundled/ruyi-venv.toml.jinja000066400000000000000000000016541520522431500241650ustar00rootroot00000000000000[config] profile = "{{ profile }}" {% if sysroot -%} sysroot = "{{ sysroot }}" {%- endif %} [metadata] version = 1 {% macro pkg_metadata(p) -%} repo_id = "{{ p['repo_id'] }}" category = "{{ p['category'] }}" name = "{{ p['name'] }}" version = "{{ p['version'] }}" {%- endmacro %} {% if metadata["sysroot_pkg"] %} [metadata.packages.sysroot] {{ pkg_metadata(metadata["sysroot_pkg"]) }} {% endif %} {% if metadata["emulator_pkgs"] %} {% for target_arch, p in metadata["emulator_pkgs"].items() -%} [metadata.packages.emulator."{{ target_arch }}"] {{ pkg_metadata(p) }} {% endfor %} {% endif %} {% if metadata["extra_pkgs"] %} {% for p in metadata["extra_pkgs"] -%} [[metadata.packages.extra]] {{ pkg_metadata(p) }} {% endfor %} {% endif %} {% if metadata["toolchain_pkgs"] %} {% for target_arch, p in metadata["toolchain_pkgs"].items() -%} [metadata.packages.toolchain."{{ target_arch }}"] {{ pkg_metadata(p) }} {% endfor %} {% endif %} ruyisdk-ruyi-1f00e2e/resources/bundled/toolchain.cmake.jinja000066400000000000000000000011631520522431500243010ustar00rootroot00000000000000# Use like: # # cmake ... \ # -DCMAKE_TOOLCHAIN_FILE={{ venv_root }}/toolchain.cmake \ {%- if sysroot %} # -DCMAKE_INSTALL_PREFIX={{ sysroot }} \ {%- endif %} # ... set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR {{ processor }}) set(CMAKE_C_COMPILER {{ cc }}) set(CMAKE_CXX_COMPILER {{ cxx }}) {% if sysroot -%} set(CMAKE_FIND_ROOT_PATH {{ sysroot }}) {% endif %} # search for headers and libraries in the target environment, # search for programs in the host environment set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) ruyisdk-ruyi-1f00e2e/resources/make-ruyi-ico.sh000077500000000000000000000011711520522431500216210ustar00rootroot00000000000000#!/bin/bash # requires icotool, imagemagick & pngcrush to work set -e my_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$my_dir" sizes=( 16 32 48 64 128 ) input_file=ruyi-logo-256.png tmpdir="$(mktemp -d)" cp "$input_file" "$tmpdir/256.png" size_files=() pushd "$tmpdir" > /dev/null for size in "${sizes[@]}"; do convert 256.png -resize "${size}x${size}" "${size}.tmp.png" pngcrush "${size}.tmp.png" "${size}.png" size_files+=( "${size}.png" ) done size_files+=( 256.png ) icotool -c -o "$my_dir/ruyi.ico" "${size_files[@]}" popd > /dev/null rm "$tmpdir"/*.png rmdir "$tmpdir" ruyisdk-ruyi-1f00e2e/resources/po/000077500000000000000000000000001520522431500172255ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/po/zh_CN/000077500000000000000000000000001520522431500202265ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/po/zh_CN/LC_MESSAGES/000077500000000000000000000000001520522431500220135ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/resources/po/zh_CN/LC_MESSAGES/argparse.po000066400000000000000000000106241520522431500241620ustar00rootroot00000000000000# Chinese (Simplified, China) translations for PROJECT. # Copyright (C) 2026 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2026. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2026-05-19 02:52+0800\n" "PO-Revision-Date: 2026-01-17 22:25+0800\n" "Last-Translator: WANG Xuerui \n" "Language: zh_Hans_CN\n" "Language-Team: zh_Hans_CN \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: argparse.py:242 #, python-format msgid "%(heading)s:" msgstr "%(heading)s:" #: argparse.py:321 msgid "usage: " msgstr "用法:" #: argparse.py:749 #, python-format msgid " (default: %(default)s)" msgstr "(默认:%(default)s)" #: argparse.py:811 #, python-format msgid "argument %(argument_name)s: %(message)s" msgstr "参数 %(argument_name)s: %(message)s" #: argparse.py:1186 msgid "show program's version number and exit" msgstr "显示程序的版本号并退出" #: argparse.py:1300 #, python-format msgid "unknown parser %(parser_name)r (choices: %(choices)s)" msgstr "未知的解析器 %(parser_name)r(选择:%(choices)s)" #: argparse.py:1304 #, python-format msgid "command '%(parser_name)s' is deprecated" msgstr "命令“%(parser_name)s”已被弃用" #: argparse.py:1371 #, python-format msgid "argument \"-\" with mode %r" msgstr "%r 模式的参数 \"-\"" #: argparse.py:1380 #, python-format msgid "can't open '%(filename)s': %(error)s" msgstr "无法打开“%(filename)s”:%(error)s" #: argparse.py:1718 #, python-format msgid "conflicting option string: %s" msgid_plural "conflicting option strings: %s" msgstr[0] "冲突的选项字符串:%s" #: argparse.py:1901 msgid "positional arguments" msgstr "位置参数" #: argparse.py:1902 msgid "options" msgstr "选项" #: argparse.py:1917 msgid "show this help message and exit" msgstr "显示该帮助信息并退出" #: argparse.py:1954 msgid "subcommands" msgstr "子命令" #: argparse.py:2005 argparse.py:2568 #, python-format msgid "unrecognized arguments: %s" msgstr "不认识的参数:%s" #: argparse.py:2111 #, python-format msgid "not allowed with argument %s" msgstr "不可与 %s 参数搭配使用" #: argparse.py:2130 #, python-format msgid "ambiguous option: %(option)s could match %(matches)s" msgstr "选项存在歧义:%(option)s 可能匹配上 %(matches)s" #: argparse.py:2162 argparse.py:2194 #, python-format msgid "ignored explicit argument %r" msgstr "忽略了显式参数 %r" #: argparse.py:2214 #, python-format msgid "option '%(option)s' is deprecated" msgstr "选项“%(option)s”已被弃用" #: argparse.py:2246 #, python-format msgid "argument '%(argument_name)s' is deprecated" msgstr "参数“%(argument_name)s”已被弃用" #: argparse.py:2339 #, python-format msgid "the following arguments are required: %s" msgstr "必须传入以下参数:%s" #: argparse.py:2354 #, python-format msgid "one of the arguments %s is required" msgstr "必须传入 %s 其中之一" #: argparse.py:2398 msgid "expected one argument" msgstr "预期单个参数" #: argparse.py:2399 msgid "expected at most one argument" msgstr "预期至多一个参数" #: argparse.py:2400 msgid "expected at least one argument" msgstr "预期至少一个参数" #: argparse.py:2404 #, python-format msgid "expected %s argument" msgid_plural "expected %s arguments" msgstr[0] "预期 %s 个参数" #: argparse.py:2514 #, python-format msgid "unexpected option string: %s" msgstr "非预期的选项字符串:%s" #: argparse.py:2662 #, python-format msgid "invalid %(type)s value: %(value)r" msgstr "无效的 %(type)s 值:%(value)r" #: argparse.py:2680 #, python-format msgid "invalid choice: %(value)r (choose from %(choices)s)" msgstr "无效的选择:%(value)r(从 %(choices)s 中选择)" #: argparse.py:2688 #, python-format msgid "invalid choice: %(value)r, maybe you meant %(closest)r? (choose from %(choices)s)" msgstr "无效的选择:%(value)r,您可能想的是 %(closest)r?(从 %(choices)s 中选择)" #: argparse.py:2780 #, python-format msgid "%(prog)s: error: %(message)s\n" msgstr "%(prog)s:错误:%(message)s\n" #: argparse.py:2784 #, python-format msgid "%(prog)s: warning: %(message)s\n" msgstr "%(prog)s:警告:%(message)s\n" ruyisdk-ruyi-1f00e2e/resources/po/zh_CN/LC_MESSAGES/ruyi.po000066400000000000000000002455111520522431500233530ustar00rootroot00000000000000# Chinese (Simplified, China) translations for ruyi. # Copyright (C) 2026 Institute of Software, Chinese Academy of Sciences (ISCAS) # This file is distributed under the same license as the ruyi project. # WANG Xuerui , 2026. # msgid "" msgstr "" "Project-Id-Version: ruyi 0.49.0-alpha.20260422\n" "Report-Msgid-Bugs-To: https://github.com/ruyisdk/ruyi/issues\n" "POT-Creation-Date: 2026-05-19 14:37+0800\n" "PO-Revision-Date: 2026-01-16 01:05+0800\n" "Last-Translator: WANG Xuerui \n" "Language: zh_Hans_CN\n" "Language-Team: zh_Hans_CN \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: ruyi/__main__.py:57 msgid "refusing to run as super user outside CI without explicit consent" msgstr "拒绝在无明示授权的前提下,在 CI 环境以外以超级用户身份运行" #: ruyi/__main__.py:62 #, python-brace-format msgid "re-run with environment variable [yellow]{env_var}[/] set to one of [yellow]{choices}[/] to signify consent" msgstr "将环境变量 [yellow]{env_var}[/] 设为 [yellow]{choices}[/] 其中之一以明示授权,而后请重新运行" #: ruyi/__main__.py:75 msgid "no argv?" msgstr "没有 argv?" #: ruyi/cli/cmd.py:118 msgid "subcommands" msgstr "子命令" #: ruyi/cli/cmd.py:162 msgid "RuyiSDK Package Manager" msgstr "RuyiSDK 包管理器" #: ruyi/cli/cmd.py:174 ruyi/cli/version_cli.py:15 msgid "Print version information" msgstr "打印版本信息" #: ruyi/cli/cmd.py:179 msgid "Give the output in a machine-friendly format if applicable" msgstr "在适用的情况下,以机器友好格式输出" #: ruyi/cli/cmd.py:223 msgid "(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos" msgstr "(非面向普通用户)用来管理 Ruyi 软件源仓库的子命令" #: ruyi/cli/config_cli.py:17 msgid "Manage Ruyi's config options" msgstr "管理 Ruyi 的配置选项" #: ruyi/cli/config_cli.py:31 msgid "Query the value of a Ruyi config option" msgstr "查询 Ruyi 配置选项的值" #: ruyi/cli/config_cli.py:42 msgid "The Ruyi config option to query" msgstr "要查询的 Ruyi 配置选项" #: ruyi/cli/config_cli.py:70 msgid "Set the value of a Ruyi config option" msgstr "设置 Ruyi 配置选项的值" #: ruyi/cli/config_cli.py:81 msgid "The Ruyi config option to set" msgstr "要设置的 Ruyi 配置选项" #: ruyi/cli/config_cli.py:86 msgid "The value to set the option to" msgstr "要设置的值" #: ruyi/cli/config_cli.py:105 #, python-brace-format msgid "the config [yellow]{key}[/] is protected and not meant to be overridden by users" msgstr "配置项 [yellow]{key}[/] 受保护,不应被用户覆盖" #: ruyi/cli/config_cli.py:118 msgid "Unset a Ruyi config option" msgstr "取消设置 Ruyi 配置选项" #: ruyi/cli/config_cli.py:129 msgid "The Ruyi config option to unset" msgstr "要取消设置的 Ruyi 配置选项" #: ruyi/cli/config_cli.py:148 msgid "Remove a section from the Ruyi config" msgstr "从 Ruyi 配置中移除一个章节" #: ruyi/cli/config_cli.py:159 msgid "The section to remove" msgstr "要移除的章节" #: ruyi/cli/main.py:86 #, python-brace-format msgid "the {ruyi_exe} executable must be named [green]'{expected_name}'[/] to work" msgstr "{ruyi_exe} 可执行文件必须名为 [green]'{expected_name}'[/] 才能工作" #: ruyi/cli/main.py:93 #, python-brace-format msgid "it is now [yellow]'{current_name}'[/]" msgstr "当前为 [yellow]'{current_name}'[/]" #: ruyi/cli/main.py:97 msgid "please rename the command file and retry" msgstr "请给命令文件重命名后重试" #: ruyi/cli/main.py:159 msgid "internal error: CLI entrypoint was added without a telemetry key" msgstr "内部错误:CLI 入口点被添加时未附带遥测键" #: ruyi/cli/oobe.py:14 #, python-brace-format msgid "" "\n" "[bold green]tip[/]: you can enable shell auto-completion for [yellow]ruyi[/] by adding the\n" "following line to your [green]{shrc}[/], if you have not done so already:\n" "\n" " [green]eval \"$(ruyi --output-completion-script={shell})\"[/]\n" "\n" "You can do so by running the following command later:\n" "\n" " [green]echo 'eval \"$(ruyi --output-completion-script={shell})\"' >> {shrc}[/]\n" msgstr "" "\n" "[bold green]提示[/]:您可以为 [yellow]ruyi[/] 启用 shell 自动补全,方法是将以下\n" "行添加到 [green]{shrc}[/] 中(如果您还没有如此操作过):\n" "\n" " [green]eval \"$(ruyi --output-completion-script={shell})\"[/]\n" "\n" "您也可以稍后运行以下命令来完成此操作:\n" "\n" " [green]echo 'eval \"$(ruyi --output-completion-script={shell})\"' >> {shrc}[/]\n" #: ruyi/cli/self_cli.py:16 msgid "" "\n" "[bold]Thanks for hacking with [yellow]Ruyi[/]![/]\n" "\n" "This will uninstall [yellow]Ruyi[/] from your system, and optionally remove\n" "all installed packages and [yellow]Ruyi[/]-managed repository data if the\n" "[green]--purge[/] switch is given on the command line.\n" "\n" "Note that your [yellow]Ruyi[/] virtual environments [bold]will become unusable[/] after\n" "[yellow]Ruyi[/] is uninstalled. You should take care of migrating or cleaning\n" "them yourselves afterwards.\n" msgstr "" "\n" "[bold]感谢您折腾 [yellow]Ruyi[/]![/]\n" "\n" "这将从您的系统中卸载 [yellow]Ruyi[/]。如果在命令行中提供了[green]--purge[/] 开关,\n" "则还将移除所有已安装的软件包和 [yellow]Ruyi[/] 所管理的仓库数据。\n" "\n" "请注意,您的 [yellow]Ruyi[/] 虚拟环境在 [yellow]Ruyi[/] 卸载后[bold]将变得不可用[/]。您需要自行迁移或清理它们。\n" #: ruyi/cli/self_cli.py:35 msgid "Manage this Ruyi installation" msgstr "管理当前的 Ruyi 安装" #: ruyi/cli/self_cli.py:49 msgid "Remove various Ruyi-managed data to reclaim storage" msgstr "移除 Ruyi 管理的各种数据以回收存储空间" #: ruyi/cli/self_cli.py:61 msgid "Do not print out the actions being performed" msgstr "不要打印正在执行的操作" #: ruyi/cli/self_cli.py:66 msgid "Remove all covered data" msgstr "移除所有涵盖的数据" #: ruyi/cli/self_cli.py:71 msgid "Remove all downloaded distfiles if any" msgstr "移除所有已下载的分发文件(如果有)" #: ruyi/cli/self_cli.py:76 msgid "Remove all installed packages if any" msgstr "移除所有已安装的软件包(如果有)" #: ruyi/cli/self_cli.py:81 msgid "Mark all news items as unread" msgstr "将所有新闻条目标记为未读" #: ruyi/cli/self_cli.py:86 msgid "Clear the Ruyi program cache" msgstr "清除 Ruyi 程序缓存" #: ruyi/cli/self_cli.py:91 msgid "Remove the Ruyi repo if located in Ruyi-managed cache directory" msgstr "移除 Ruyi 仓库(如果位于 Ruyi 管理的缓存目录中)" #: ruyi/cli/self_cli.py:96 msgid "Remove all telemetry data recorded if any" msgstr "移除所有已记录的遥测数据(如果有)" #: ruyi/cli/self_cli.py:129 msgid "no data specified for cleaning" msgstr "未指定要清理的数据" #: ruyi/cli/self_cli.py:132 msgid "please check [yellow]ruyi self clean --help[/] for a list of cleanable data" msgstr "请查看 [yellow]ruyi self clean --help[/] 以获取可清理数据的列表" #: ruyi/cli/self_cli.py:158 msgid "Uninstall Ruyi" msgstr "卸载 Ruyi" #: ruyi/cli/self_cli.py:169 msgid "Remove all installed packages and Ruyi-managed remote repo data" msgstr "移除所有已安装的软件包和 Ruyi 管理的远端仓库数据" #: ruyi/cli/self_cli.py:176 msgid "Give consent for uninstallation on CLI; do not ask for confirmation" msgstr "在 CLI 中确认卸载;不要请求确认" #: ruyi/cli/self_cli.py:192 msgid "this [yellow]ruyi[/] is externally managed, for example, by the system package manager, and cannot be uninstalled this way" msgstr "这个 [yellow]ruyi[/] 由外部管理,例如系统包管理器,不能以这种方式卸载" #: ruyi/cli/self_cli.py:195 msgid "please uninstall via the external manager instead" msgstr "请通过外部管理器卸载" #: ruyi/cli/self_cli.py:201 msgid "this [yellow]ruyi[/] is not in standalone form, and cannot be uninstalled this way" msgstr "这个 [yellow]ruyi[/] 不是独立形式,不能以这种方式卸载" #: ruyi/cli/self_cli.py:208 ruyi/device/provision.py:60 msgid "Continue?" msgstr "继续吗?" #: ruyi/cli/self_cli.py:209 msgid "aborting uninstallation" msgstr "中止卸载" #: ruyi/cli/self_cli.py:212 msgid "uninstallation consent given over CLI, proceeding" msgstr "已通过 CLI 同意卸载,继续进行" #: ruyi/cli/self_cli.py:223 msgid "[yellow]ruyi[/] is uninstalled" msgstr "[yellow]ruyi[/] 已被卸载" #: ruyi/cli/self_cli.py:250 msgid "removing installed packages" msgstr "正在移除已安装的软件包" #: ruyi/cli/self_cli.py:259 msgid "removing state data" msgstr "正在移除状态数据" #: ruyi/cli/self_cli.py:263 msgid "removing read status of news items" msgstr "正在移除新闻条目的阅读状态" #: ruyi/cli/self_cli.py:267 msgid "removing all telemetry data" msgstr "正在移除所有遥测数据" #: ruyi/cli/self_cli.py:271 msgid "removing cached data" msgstr "正在移除缓存数据" #: ruyi/cli/self_cli.py:275 msgid "removing downloaded distfiles" msgstr "正在移除已下载的分发文件" #: ruyi/cli/self_cli.py:280 msgid "clearing the Ruyi program cache" msgstr "正在清除 Ruyi 程序缓存" #: ruyi/cli/self_cli.py:299 msgid "not removing the Ruyi repo: it is outside of the Ruyi cache directory" msgstr "不移除 Ruyi 仓库:它位于 Ruyi 缓存目录之外" #: ruyi/cli/self_cli.py:303 msgid "removing the Ruyi repo" msgstr "正在移除 Ruyi 仓库" #: ruyi/cli/self_cli.py:307 msgid "removing the ruyi binary" msgstr "正在移除 ruyi 可执行文件" #: ruyi/cli/user_input.py:14 msgid "Press [green][/] to continue: " msgstr "按 [green][/] 键继续:" #: ruyi/cli/user_input.py:30 msgid "YES" msgstr "是" #: ruyi/cli/user_input.py:30 msgid "NO" msgstr "否" #: ruyi/cli/user_input.py:33 #, python-brace-format msgid "EOF while reading user input, assuming the default choice {yesno}" msgstr "读取用户输入时遇到 EOF,假定为默认选择 {yesno}" #: ruyi/cli/user_input.py:46 ruyi/cli/user_input.py:114 #, python-brace-format msgid "Unrecognized input [yellow]'{user_input}'[/]." msgstr "未识别的输入 [yellow]'{user_input}'[/]。" #: ruyi/cli/user_input.py:50 msgid "Accepted choices: Y/y/yes for YES, N/n/no for NO." msgstr "可接受的选择:Y/y/yes 表示“是”,N/n/no 表示“否”。" #: ruyi/cli/user_input.py:91 #, python-brace-format msgid "(1-{nr_choices}, default {default})" msgstr "(1-{nr_choices},默认为 {default})" #: ruyi/cli/user_input.py:96 #, python-brace-format msgid "(1-{nr_choices})" msgstr "(1-{nr_choices})" #: ruyi/cli/user_input.py:100 #, python-brace-format msgid "Choice? {choices_help} " msgstr "选择?{choices_help} " #: ruyi/cli/user_input.py:120 ruyi/cli/user_input.py:137 #, python-brace-format msgid "Accepted choices: an integer number from 1 to {nr_choices} inclusive." msgstr "可接受的选择:从 1 到 {nr_choices}(含)的整数。" #: ruyi/cli/user_input.py:131 #, python-brace-format msgid "Out-of-range input [yellow]'{user_input}'[/]." msgstr "输入 [yellow]'{user_input}'[/] 超出了范围。" #: ruyi/cli/version_cli.py:32 #, python-brace-format msgid "" "Ruyi {version}\n" "\n" "Running on {host}." msgstr "" "Ruyi {version}\n" "\n" "在 {host} 上运行。" #: ruyi/cli/version_cli.py:39 msgid "This Ruyi installation is externally managed." msgstr "该 Ruyi 安装由外部管理。" #: ruyi/config/__init__.py:141 #, python-brace-format msgid "the config key [yellow]{key}[/] cannot be set from user config; ignoring" msgstr "配置项 [yellow]{key}[/] 不能从用户配置文件被设置;忽略" #: ruyi/config/__init__.py:163 #, python-brace-format msgid "the local repo path '{path}' is not absolute; ignoring" msgstr "本地仓库路径 '{path}' 不是绝对路径;忽略" #: ruyi/config/__init__.py:205 #, python-brace-format msgid "ignoring [[repos]] entry with invalid id: '{id}'" msgstr "忽略具有无效 id 的 [[repos]] 条目:'{id}'" #: ruyi/config/__init__.py:214 #, python-brace-format msgid "ignoring [[repos]] entry with reserved id '{id}'; use [repo] to configure the default repository" msgstr "忽略具有保留 id '{id}' 的 [[repos]] 条目;请使用 [repo] 来配置默认仓库" #: ruyi/config/__init__.py:222 #, python-brace-format msgid "ignoring duplicate [[repos]] entry with id '{id}'" msgstr "忽略具有重复 id '{id}' 的 [[repos]] 条目" #: ruyi/config/__init__.py:233 #, python-brace-format msgid "ignoring [[repos]] entry '{id}': at least one of 'remote' or 'local' must be set" msgstr "忽略 [[repos]] 条目 '{id}':'remote' 和 'local' 至少要设置一个" #: ruyi/config/__init__.py:242 #, python-brace-format msgid "ignoring [[repos]] entry '{id}': the local path '{path}' is not absolute" msgstr "忽略 [[repos]] 条目 '{id}':本地路径 '{path}' 不是绝对路径" #: ruyi/config/errors.py:13 #, python-brace-format msgid "invalid config section: {section}" msgstr "无效的配置章节:{section}" #: ruyi/config/errors.py:25 #, python-brace-format msgid "invalid config key: {key}" msgstr "无效的配置项:{key}" #: ruyi/config/errors.py:45 #, python-brace-format msgid "invalid value type for config key {key}: {actual_type}, expected {expected_type}" msgstr "配置项 {key} 的值类型无效:{actual_type},期望 {expected_type}" #: ruyi/config/errors.py:69 #, python-brace-format msgid "invalid config value for key {key} (type {typ}): {val}" msgstr "配置项 {key} 的值无效(类型 {typ}):{val}" #: ruyi/config/errors.py:87 #, python-brace-format msgid "malformed config file: {path}" msgstr "配置文件格式错误:{path}" #: ruyi/config/errors.py:99 #, python-brace-format msgid "attempt to modify protected global config key: {key}" msgstr "尝试修改受保护的全局配置项:{key}" #: ruyi/device/provision.py:41 msgid "" "\n" "[bold green]RuyiSDK Device Provisioning Wizard[/]\n" "\n" "This is a wizard intended to help you install a system on your device for your\n" "development pleasure, all with ease.\n" "\n" "You will be asked some questions that help RuyiSDK understand your device and\n" "your intended configuration, then packages will be downloaded and flashed onto\n" "the device's storage, that you should somehow make available on this host\n" "system beforehand.\n" "\n" "Note that, as Ruyi does not run as [yellow]root[/], but raw disk access is most likely\n" "required to flash images, you should arrange to allow your user account [yellow]sudo[/]\n" "access to necessary commands such as [yellow]dd[/]. Flashing will fail if the [yellow]sudo[/]\n" "configuration does not allow so.\n" msgstr "" "\n" "[bold green]RuyiSDK 设备安装向导[/]\n" "\n" "这是一个向导,旨在帮助您轻松地在您的设备上安装系统,以便您开展开发工作。\n" "\n" "您将被问及一些问题,好让 RuyiSDK 了解您的设备和您预期的配置,然后一些软件包将被下载并刷写到\n" "设备的存储上。您应当事先以某种方式在此主机系统上提供该存储。\n" "\n" "请注意,由于 Ruyi 不以 [yellow]root[/] 身份运行,但刷写镜像很可能需要底层的磁盘访问权限,\n" "您应当配置您的用户能够以 [yellow]sudo[/] 方式运行 [yellow]dd[/] 等必要命令。\n" "如果您的 [yellow]sudo[/] 配置不允许您这么做,刷写将会失败。\n" #: ruyi/device/provision.py:62 msgid "" "\n" "Exiting. You can restart the wizard whenever prepared." msgstr "" "\n" "正在退出。您可在准备好之后随时重新启动向导。" #: ruyi/device/provision.py:75 msgid "" "\n" "The following devices are currently supported by the wizard. Please pick your device:" msgstr "" "\n" "向导当前支持以下设备。请选择您的设备:" #: ruyi/device/provision.py:93 msgid "" "\n" "The device has the following variants. Please choose the one corresponding to your hardware at hand:" msgstr "" "\n" "该设备有以下变体。请选择与您手头硬件对应的变体:" #: ruyi/device/provision.py:112 msgid "" "\n" "The following system configurations are supported by the device variant you have chosen. Please pick the one you want to put on the device:" msgstr "" "\n" "您选择的设备变体支持以下系统配置。请选择您想要安装到设备上的配置:" #: ruyi/device/provision.py:155 #, python-brace-format msgid "malformed config: device variant '{devid}' asks for no packages but provides no messages either" msgstr "配置格式错误:设备变体 '{devid}' 未要求任何软件包,但也未提供任何消息文案" #: ruyi/device/provision.py:165 ruyi/device/provision.py:182 msgid "" "\n" "Exiting. You may restart the wizard at any time." msgstr "" "\n" "正在退出。您可随时重新启动向导。" #: ruyi/device/provision.py:174 msgid "" "\n" "We are about to download and install the following packages for your device:" msgstr "" "\n" "我们即将为您的设备下载并安装以下软件包:" #: ruyi/device/provision.py:195 msgid "failed to download and install packages" msgstr "下载和安装软件包失败" #: ruyi/device/provision.py:196 msgid "your device was not touched" msgstr "您的设备未受任何操作" #: ruyi/device/provision.py:220 msgid "" "\n" "For initializing this target device, you should plug into this host system the\n" "device's storage (e.g. SD card or NVMe SSD), or a removable disk to be\n" "reformatted as a live medium, and note down the corresponding device file\n" "path(s), e.g. /dev/sdX, /dev/nvmeXnY for whole disks; /dev/sdXY, /dev/nvmeXnYpZ\n" "for partitions. You may consult e.g. [yellow]sudo blkid[/] output for the\n" "information you will need later.\n" msgstr "" "\n" "要初始化此目标设备,您需要将设备的存储(例如 SD 卡或 NVMe SSD)插入此主机系统,或插入一个\n" "可移动磁盘以重新格式化为 live 介质。您还需要记下相应的设备文件路径,例如 /dev/sdX、\n" "/dev/nvmeXnY(对于完整磁盘),或者 /dev/sdXY、/dev/nvmeXnYpZ(对于分区)。您可参考例如\n" "[yellow]sudo blkid[/] 输出等,以获取稍后需要的信息。\n" #: ruyi/device/provision.py:236 #, python-brace-format msgid "Please give the path for the {part_desc}:" msgstr "请提供 {part_desc} 的路径:" #: ruyi/device/provision.py:250 #, python-brace-format msgid "path [cyan]'{path}'[/] is currently mounted at [yellow]'{target}'[/]" msgstr "路径 [cyan]'{path}'[/] 当前挂载在 [yellow]'{target}'[/]" #: ruyi/device/provision.py:258 msgid "rejecting the path for safety; please double-check and retry" msgstr "出于安全考虑,拒绝该路径;请再次检查后重试" #: ruyi/device/provision.py:271 msgid "" "\n" "We have collected enough information for the actual flashing. Now is the last\n" "chance to re-check and confirm everything is fine.\n" "\n" "We are about to:\n" msgstr "" "\n" "我们已收集到足够的信息以进行实际刷写。现在是您重新检查并确认一切正常的最后机会。\n" "\n" "我们即将:\n" #: ruyi/device/provision.py:291 msgid "Proceed with flashing?" msgstr "进行刷写吗?" #: ruyi/device/provision.py:294 msgid "" "\n" "Exiting. The device is not touched and you may re-start the wizard at will." msgstr "" "\n" "正在退出。设备未受任何操作,您可随时重新启动向导。" #: ruyi/device/provision.py:309 msgid "" "\n" "Some flashing steps require the use of fastboot, in which case you should\n" "ensure the target device is showing up in [yellow]fastboot devices[/] output.\n" "Please [bold red]confirm it yourself before continuing[/].\n" msgstr "" "\n" "某些刷写步骤需要使用 fastboot,此时您应该确保目标设备显示在了 [yellow]fastboot devices[/] 输出中。\n" "请[bold red]在继续之前自行确认[/]。\n" #: ruyi/device/provision.py:317 msgid "Is the device identified by fastboot now?" msgstr "设备现在是否被 fastboot 识别到了?" #: ruyi/device/provision.py:321 msgid "" "\n" "Aborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device." msgstr "" "\n" "正在中止。设备未受任何操作。您可在修复 [yellow]fastboot[/] 对设备的识别后重新启动向导。" #: ruyi/device/provision.py:332 msgid "flashing failed, check your device right now" msgstr "刷写失败,请立即检查您的设备" #: ruyi/device/provision.py:337 msgid "" "\n" "It seems the flashing has finished without errors.\n" "\n" "[bold green]Happy hacking![/]\n" msgstr "" "\n" "看起来刷写已顺利完成。\n" "\n" "[bold green]祝您折腾愉快![/]\n" #: ruyi/device/provision.py:352 msgid "target's whole disk" msgstr "目标的整个磁盘" #: ruyi/device/provision.py:354 msgid "removable disk to use as live medium" msgstr "用作 live 介质的可移动磁盘" #: ruyi/device/provision.py:356 #, python-brace-format msgid "target's '{part}' partition" msgstr "目标的 '{part}' 分区" #: ruyi/device/provision.py:528 msgid "By default, we'll install the latest version of each package, but in this case, other choices are possible." msgstr "我们默认将安装每个软件包的最新版本,但在当前情况下,也可以选择其他版本。" #: ruyi/device/provision.py:533 msgid "Would you like to customize package versions?" msgstr "您想自定义软件包版本吗?" #: ruyi/device/provision.py:539 msgid "" "\n" "[bold]Package Version Selection[/]" msgstr "" "\n" "[bold]软件包版本选择[/]" #: ruyi/device/provision.py:548 #, python-brace-format msgid "" "\n" "Package [green]{atom}[/] already has version constraints." msgstr "" "\n" "软件包 [green]{atom}[/] 已有版本约束。" #: ruyi/device/provision.py:555 msgid "Would you like to change them?" msgstr "您想更改它们吗?" #: ruyi/device/provision.py:563 #, python-brace-format msgid "version cannot be overridden for slug atom [green]{atom}[/]" msgstr "slug atom [green]{atom}[/] 的版本无法覆盖" #: ruyi/device/provision.py:581 #, python-brace-format msgid "could not find package [yellow]{pkg}[/] in repository" msgstr "在仓库中找不到软件包 [yellow]{pkg}[/]" #: ruyi/device/provision.py:589 #, python-brace-format msgid "no versions found for package [yellow]{pkg}[/]" msgstr "找不到软件包 [yellow]{pkg}[/] 的任何版本" #: ruyi/device/provision.py:601 #, python-brace-format msgid "Only one version available for [green]{pkg}[/]: [blue]{ver}[/], using it." msgstr "[green]{pkg}[/] 仅有一个可用版本:[blue]{ver}[/],使用它。" #: ruyi/device/provision.py:622 msgid "has known issues" msgstr "有已知问题" #: ruyi/device/provision.py:625 #, python-brace-format msgid "upstream: {upstream_ver}" msgstr "上游:{upstream_ver}" #: ruyi/device/provision.py:636 #, python-brace-format msgid "" "\n" "Select a version for package [green]{pkg}[/]:" msgstr "" "\n" "为软件包 [green]{pkg}[/] 选择一个版本:" #: ruyi/device/provision.py:650 #, python-brace-format msgid "Selected: [blue]{new_atom}[/]" msgstr "已选择:[blue]{new_atom}[/]" #: ruyi/device/provision.py:653 msgid "" "\n" "Package versions to be installed:" msgstr "" "\n" "要安装的软件包版本:" #: ruyi/device/provision.py:659 msgid "" "\n" "How would you like to proceed?" msgstr "" "\n" "您想如何继续?" #: ruyi/device/provision.py:661 msgid "Continue with these versions" msgstr "继续使用这些版本" #: ruyi/device/provision.py:662 msgid "Restart version selection" msgstr "重新选择版本" #: ruyi/device/provision.py:663 msgid "Abort device provisioning" msgstr "中止设备配置" #: ruyi/device/provision.py:670 msgid "" "\n" "Restarting package version selection..." msgstr "" "\n" "正在重新选择软件包版本..." #: ruyi/device/provision_cli.py:16 msgid "Manage devices" msgstr "管理设备" #: ruyi/device/provision_cli.py:27 msgid "Interactively initialize a device for development" msgstr "交互式地初始化一台设备做开发工作" #: ruyi/device/provision_cli.py:41 msgid "" "\n" "\n" "Keyboard interrupt received, exiting." msgstr "" "\n" "\n" "收到键盘中断,正在退出。" #: ruyi/log/__init__.py:223 #, python-brace-format msgid "[bold red]fatal error:[/] {message}" msgstr "[bold red]致命错误:[/]{message}" #: ruyi/log/__init__.py:240 #, python-brace-format msgid "[bold green]info:[/] {message}" msgstr "[bold green]信息:[/]{message}" #: ruyi/log/__init__.py:257 #, python-brace-format msgid "[bold yellow]warn:[/] {message}" msgstr "[bold yellow]警告:[/]{message}" #: ruyi/log/__init__.py:272 msgid "(none)" msgstr "(无)" #: ruyi/mux/runtime.py:35 msgid "the Ruyi toolchain mux is not configured" msgstr "Ruyi 工具链复用器未配置" #: ruyi/mux/runtime.py:36 msgid "check out `ruyi venv` for making a virtual environment" msgstr "要创建虚拟环境,请查阅 `ruyi venv` 相关信息" #: ruyi/mux/runtime.py:65 #, python-brace-format msgid "internal error: no target data for tuple [yellow]{target_tuple}[/]" msgstr "内部错误:元组 [yellow]{target_tuple}[/] 没有目标数据" #: ruyi/mux/runtime.py:92 #, python-brace-format msgid "internal error: no bindir configured for target [yellow]{target_tuple}[/]" msgstr "内部错误:未为目标 [yellow]{target_tuple}[/] 配置 bindir" #: ruyi/mux/runtime.py:103 #, python-brace-format msgid "no configured target found for command [yellow]{basename}[/]" msgstr "未找到命令 [yellow]{basename}[/] 对应的已配置目标" #: ruyi/mux/runtime.py:192 msgid "this virtual environment has no QEMU-like emulator configured" msgstr "此虚拟环境未配置有类似 QEMU 的模拟器" #: ruyi/mux/venv/maker.py:224 msgid "one entry could not be copied" msgstr "有 1 个条目无法复制" #: ruyi/mux/venv/maker.py:226 #, python-brace-format msgid "{count} entries could not be copied" msgstr "有 {count} 个条目无法复制" #: ruyi/mux/venv/maker.py:231 #, python-brace-format msgid "cannot copy sysroot from [yellow]{src}[/]: {reason}" msgstr "无法从 [yellow]{src}[/] 复制 sysroot:{reason}" #: ruyi/mux/venv/maker.py:239 msgid "Ruyi does not elevate privileges when creating virtual environments; use a sysroot readable by the current user, --symlink-sysroot-from-dir, or --project-sysroot-from-rootfs" msgstr "Ruyi 创建虚拟环境时不会提权;请使用当前用户可读的 sysroot、--symlink-sysroot-from-dir,或 --project-sysroot-from-rootfs" #: ruyi/mux/venv/maker.py:246 #, python-brace-format msgid "cannot copy sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}" msgstr "无法将 sysroot 从 [yellow]{src}[/] 复制到 [green]{dest}[/]:{err}" #: ruyi/mux/venv/maker.py:261 #, python-brace-format msgid "cannot project sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}" msgstr "无法将 [yellow]{src}[/] 投影为 [green]{dest}[/] 处的 sysroot:{err}" #: ruyi/mux/venv/maker.py:272 #, python-brace-format msgid "cannot project sysroot from [yellow]{src}[/]: no supported sysroot directories were found" msgstr "无法从 [yellow]{src}[/] 投影 sysroot:未找到受支持的 sysroot 目录" #: ruyi/mux/venv/maker.py:279 #, python-brace-format msgid "projected sysroot from [yellow]{src}[/]: copied {copied_count} entries, skipped {skipped_count} entries" msgstr "已从 [yellow]{src}[/] 投影 sysroot:复制了 {copied_count} 个条目,跳过了 {skipped_count} 个条目" #: ruyi/mux/venv/maker.py:289 msgid "some unreadable or unsupported files were skipped; the projected sysroot may be incomplete" msgstr "已跳过一些不可读或不受支持的文件;投影得到的 sysroot 可能不完整" #: ruyi/mux/venv/maker.py:313 ruyi/mux/venv/maker.py:484 #, python-brace-format msgid "cannot match a toolchain package with [yellow]{atom}[/]" msgstr "无法将 [yellow]{atom}[/] 匹配到工具链软件包" #: ruyi/mux/venv/maker.py:321 ruyi/mux/venv/maker.py:490 #, python-brace-format msgid "the package [yellow]{atom}[/] is not a toolchain" msgstr "软件包 [yellow]{atom}[/] 不是工具链" #: ruyi/mux/venv/maker.py:333 msgid "cannot find the installed directory for the sysroot package" msgstr "找不到 sysroot 软件包的安装目录" #: ruyi/mux/venv/maker.py:340 #, python-brace-format msgid "sysroot is requested but the package [yellow]{atom}[/] does not contain one" msgstr "请求了 sysroot,但软件包 [yellow]{atom}[/] 不含 sysroot" #: ruyi/mux/venv/maker.py:360 msgid "cannot find a GCC include & lib directory in the sysroot package" msgstr "在 sysroot 软件包中找不到 GCC include 和 lib 目录" #: ruyi/mux/venv/maker.py:428 ruyi/mux/venv/maker.py:437 #, python-brace-format msgid "the sysroot directory [yellow]{path}[/] does not exist" msgstr "sysroot 目录 [yellow]{path}[/] 不存在" #: ruyi/mux/venv/maker.py:447 #, python-brace-format msgid "the rootfs directory [yellow]{path}[/] does not exist" msgstr "rootfs 目录 [yellow]{path}[/] 不存在" #: ruyi/mux/venv/maker.py:458 msgid "You have to specify at least one toolchain atom for now, e.g. [yellow]`-t gnu-plct`[/]" msgstr "当前,您必须至少指定一个工具链 atom,例如 [yellow]`-t gnu-plct`[/]" #: ruyi/mux/venv/maker.py:466 #, python-brace-format msgid "profile '{profile}' not found" msgstr "未找到配置文件 '{profile}'" #: ruyi/mux/venv/maker.py:497 ruyi/mux/venv/maker.py:624 #, python-brace-format msgid "the package [yellow]{atom}[/] does not support all necessary features for the profile [yellow]{profile}[/]" msgstr "软件包 [yellow]{atom}[/] 不支持配置文件 [yellow]{profile}[/] 所需的所有特性" #: ruyi/mux/venv/maker.py:504 ruyi/mux/venv/maker.py:630 #, python-brace-format msgid "quirks needed by profile: {humanized_list}" msgstr "配置文件所需的特殊特性:{humanized_list}" #: ruyi/mux/venv/maker.py:509 ruyi/mux/venv/maker.py:635 #, python-brace-format msgid "quirks provided by package: {humanized_list}" msgstr "软件包所提供的特殊特性:{humanized_list}" #: ruyi/mux/venv/maker.py:518 #, python-brace-format msgid "the target tuple [yellow]{target_tuple}[/] is already covered by one of the requested toolchains" msgstr "目标元组 [yellow]{target_tuple}[/] 已被请求的工具链之一涵盖" #: ruyi/mux/venv/maker.py:523 msgid "for now, only toolchains with differing target tuples can co-exist in one virtual environment" msgstr "目前,只有目标元组不同的工具链才能在一个虚拟环境中共存" #: ruyi/mux/venv/maker.py:532 msgid "cannot find the installed directory for the toolchain" msgstr "找不到工具链的安装目录" #: ruyi/mux/venv/maker.py:548 msgid "sysroot is requested but the toolchain package does not include one, and no explicit sysroot source is given" msgstr "请求了 sysroot,但工具链软件包不含 sysroot,且未给出显式的 sysroot 来源" #: ruyi/mux/venv/maker.py:583 msgid "multiple toolchains specified with differing target architecture" msgstr "指定了具有不同目标架构的多个工具链" #: ruyi/mux/venv/maker.py:585 #, python-brace-format msgid "using the target architecture of the first toolchain: [yellow]{arch}[/]" msgstr "将使用第一个工具链的目标架构:[yellow]{arch}[/]" #: ruyi/mux/venv/maker.py:597 #, python-brace-format msgid "cannot match an emulator package with [yellow]{atom}[/]" msgstr "无法将 [yellow]{atom}[/] 匹配到模拟器软件包" #: ruyi/mux/venv/maker.py:603 #, python-brace-format msgid "the package [yellow]{atom}[/] is not an emulator" msgstr "软件包 [yellow]{atom}[/] 不是模拟器" #: ruyi/mux/venv/maker.py:611 #, python-brace-format msgid "the emulator package [yellow]{atom}[/] does not support the target architecture [yellow]{arch}[/]" msgstr "模拟器软件包 [yellow]{atom}[/] 不支持目标架构 [yellow]{arch}[/]" #: ruyi/mux/venv/maker.py:646 msgid "cannot find the installed directory for the emulator" msgstr "找不到模拟器的安装目录" #: ruyi/mux/venv/maker.py:662 #, python-brace-format msgid "cannot match an extra command package with [yellow]{atom}[/]" msgstr "无法将 [yellow]{atom}[/] 匹配到额外命令软件包" #: ruyi/mux/venv/maker.py:671 #, python-brace-format msgid "the package [yellow]{atom}[/] is not a binary-providing package" msgstr "软件包 [yellow]{atom}[/] 未提供二进制文件" #: ruyi/mux/venv/maker.py:680 #, python-brace-format msgid "the package [yellow]{atom}[/] does not provide any command for host [yellow]{host}[/], ignoring" msgstr "软件包 [yellow]{atom}[/] 未为主机 [yellow]{host}[/] 提供任何命令,将被忽略" #: ruyi/mux/venv/maker.py:693 #, python-brace-format msgid "cannot find the installed directory for the package [yellow]{pkg}[/]" msgstr "找不到软件包 [yellow]{pkg}[/] 的安装目录" #: ruyi/mux/venv/maker.py:709 msgid "internal error: resolved command path is outside of the providing package" msgstr "内部错误:解析的命令路径落在了提供者软件包之外" #: ruyi/mux/venv/maker.py:718 #, python-brace-format msgid "Creating a Ruyi virtual environment [cyan]'{name}'[/] at [green]{dest}[/]..." msgstr "正在在 [green]{dest}[/] 创建 Ruyi 虚拟环境 [cyan]'{name}'[/]..." #: ruyi/mux/venv/maker.py:725 #, python-brace-format msgid "Creating a Ruyi virtual environment at [green]{dest}[/]..." msgstr "正在在 [green]{dest}[/] 创建 Ruyi 虚拟环境..." #: ruyi/mux/venv/maker.py:960 #, python-brace-format msgid "extra command {cmd} is already provided by another package, overriding it" msgstr "额外命令 {cmd} 已由另一个软件包提供,将用当前软件包覆盖它" #: ruyi/mux/venv/venv_cli.py:16 msgid "Generate a virtual environment adapted to the chosen toolchain and profile" msgstr "生成适应所选工具链和配置文件的虚拟环境" #: ruyi/mux/venv/venv_cli.py:22 msgid "" "Sysroot provisioning:\n" " By default, Ruyi uses the sysroot bundled with the selected toolchain if one is available.\n" " Use --copy-sysroot-from-pkg to copy the sysroot from another installed toolchain package.\n" " Use --copy-sysroot-from-dir only for a complete sysroot directory readable by the current user; it performs a faithful full-tree copy.\n" " Use --symlink-sysroot-from-dir to point the virtual environment at an existing sysroot directory without copying it.\n" " Use --project-sysroot-from-rootfs for distro rootfs or chroot trees: Ruyi copies common cross-build directories such as include, lib*, usr/include, usr/lib*, usr/share, bin, and sbin, and skips unreadable or unsupported files.\n" " Ruyi never elevates privileges when creating virtual environments. If a rootfs contains private system files such as /etc/shadow, prepare a readable sysroot yourself or use projection mode." msgstr "" "Sysroot 准备方式:\n" " 默认情况下,如果所选工具链内置了 sysroot,Ruyi 会使用该 sysroot。\n" " 使用 --copy-sysroot-from-pkg 可从另一个已安装的工具链软件包复制 sysroot。\n" " 仅当给定目录是当前用户可读的完整 sysroot 时,才使用 --copy-sysroot-from-dir;该选项会忠实复制整棵目录树。\n" " 使用 --symlink-sysroot-from-dir 可让虚拟环境的 sysroot 指向已有 sysroot 目录,而不复制其内容。\n" " 对于发行版 rootfs 或 chroot 目录树,请使用 --project-sysroot-from-rootfs:Ruyi 会复制 include、lib*、usr/include、usr/lib*、usr/share、bin、sbin 等常见交叉构建目录,并跳过不可读或不受支持的文件。\n" " Ruyi 创建虚拟环境时绝不提权。如果 rootfs 含有 /etc/shadow 等私有系统文件,请自行准备可读的 sysroot,或使用投影模式。" #: ruyi/mux/venv/venv_cli.py:30 msgid "Profile to use for the environment" msgstr "环境使用的配置文件" #: ruyi/mux/venv/venv_cli.py:32 msgid "Path to the new virtual environment" msgstr "新虚拟环境的路径" #: ruyi/mux/venv/venv_cli.py:39 msgid "Override the venv's name" msgstr "自定义虚拟环境的名称" #: ruyi/mux/venv/venv_cli.py:46 msgid "Specifier(s) (atoms) of the toolchain package(s) to use" msgstr "要使用的工具链软件包的指示表达式(atoms)" #: ruyi/mux/venv/venv_cli.py:52 msgid "Specifier (atom) of the emulator package to use" msgstr "要使用的模拟器软件包的指示表达式(atom)" #: ruyi/mux/venv/venv_cli.py:59 msgid "Provision a fresh sysroot inside the new virtual environment (default)" msgstr "在新虚拟环境内准备一个全新的 sysroot(默认)" #: ruyi/mux/venv/venv_cli.py:65 msgid "Do not include a sysroot inside the new virtual environment" msgstr "不在新虚拟环境内准备任何 sysroot" #: ruyi/mux/venv/venv_cli.py:72 msgid "Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable" msgstr "要使用的 sysroot 软件包的指示表达式(atom),如工具链软件包也内置了 sysroot 则优先于它" #: ruyi/mux/venv/venv_cli.py:77 msgid "Copy the sysroot from the given directory into the virtual environment" msgstr "将给定目录中的 sysroot 复制到虚拟环境中" #: ruyi/mux/venv/venv_cli.py:82 msgid "Symlink the virtual environment's sysroot to the given existing directory" msgstr "将虚拟环境的 sysroot 符号链接到给定的现有目录" #: ruyi/mux/venv/venv_cli.py:87 msgid "Project a build sysroot from the given distro rootfs directory" msgstr "从给定的发行版 rootfs 目录投影构建用 sysroot" #: ruyi/mux/venv/venv_cli.py:93 msgid "Specifier(s) (atoms) of extra package(s) to add commands to the new virtual environment" msgstr "要向新虚拟环境添加额外命令,这些命令的提供者软件包的指示表达式(atoms)" #: ruyi/mux/venv/venv_cli.py:110 msgid "at most one of --copy-sysroot-from-pkg, --copy-sysroot-from-dir, --symlink-sysroot-from-dir, and --project-sysroot-from-rootfs may be specified" msgstr "至多只能指定 --copy-sysroot-from-pkg、--copy-sysroot-from-dir、--symlink-sysroot-from-dir 和 --project-sysroot-from-rootfs 中的一个" #: ruyi/mux/venv/venv_cli.py:116 msgid "--without-sysroot cannot be combined with a sysroot source option" msgstr "--without-sysroot 不能与任何 sysroot 来源选项同时使用" #: ruyi/pluginhost/plugin_cli.py:15 msgid "Run a plugin-defined command" msgstr "运行一个由插件定义的命令" #: ruyi/pluginhost/plugin_cli.py:23 msgid "Command name" msgstr "命令名称" #: ruyi/pluginhost/plugin_cli.py:30 msgid "Arguments to pass to the plugin command" msgstr "要传递给插件命令的参数" #: ruyi/ruyipkg/admin_checksum.py:23 #, python-brace-format msgid "invalid restrict kinds given: {restrict}" msgstr "给出了无效的 restrict 类型:{restrict}" #: ruyi/ruyipkg/admin_cli.py:16 msgid "Check package manifests and metadata repositories" msgstr "检查软件包清单文件和元数据仓库" #: ruyi/ruyipkg/admin_cli.py:27 msgid "Path to a package manifest to check (repeatable)" msgstr "要检查的软件包清单文件的路径(可重复)" #: ruyi/ruyipkg/admin_cli.py:34 msgid "Path to a metadata repository root to check" msgstr "要检查的元数据仓库根路径" #: ruyi/ruyipkg/admin_cli.py:41 msgid "Check to run (repeatable; defaults to all checks)" msgstr "要运行的检查(可重复;默认为所有检查)" #: ruyi/ruyipkg/admin_cli.py:49 msgid "Only check packages matching trailing ruyi list filters; valid only with --repo" msgstr "仅检查与后接的 ruyi list 过滤器匹配的软件包;仅能配合 --repo 指定" #: ruyi/ruyipkg/admin_cli.py:65 msgid "--only-packages is only valid with --repo" msgstr "--only-packages 仅能配合 --repo 指定" #: ruyi/ruyipkg/admin_cli.py:84 msgid "Generate a checksum section for a manifest file for the distfiles given" msgstr "为给定的分发文件生成清单文件的校验和章节" #: ruyi/ruyipkg/admin_cli.py:94 msgid "Format of checksum section to generate in" msgstr "要生成的校验和章节的格式" #: ruyi/ruyipkg/admin_cli.py:101 msgid "the 'restrict' field to use for all mentioned distfiles, separated with comma" msgstr "所有提及的分发文件使用的 'restrict' 字段,用逗号分隔" #: ruyi/ruyipkg/admin_cli.py:108 msgid "Path to the distfile(s) to checksum" msgstr "要计算校验和的分发文件的路径" #: ruyi/ruyipkg/admin_cli.py:127 msgid "Format the given package manifests into canonical TOML representation" msgstr "将给定的软件包清单文件格式化为规范的 TOML 表示" #: ruyi/ruyipkg/admin_cli.py:135 msgid "Path to the distfile(s) to generate manifest for" msgstr "要为其生成清单文件的分发文件的路径" #: ruyi/ruyipkg/admin_cli.py:158 msgid "Build a package from a recipe file" msgstr "从一个配方文件构建软件包" #: ruyi/ruyipkg/admin_cli.py:165 msgid "Path to the recipe .star file" msgstr "配方 .star 文件的路径" #: ruyi/ruyipkg/admin_cli.py:173 msgid "Set a user variable for the recipe (repeatable)" msgstr "为构建配方设置用户变量(可重复)" #: ruyi/ruyipkg/admin_cli.py:182 msgid "Select a specific scheduled build by name (repeatable); by default all scheduled builds are executed" msgstr "按名称选择特定的计划构建任务(可重复);默认情况下执行所有计划构建任务" #: ruyi/ruyipkg/admin_cli.py:189 msgid "Print the build plan without executing it" msgstr "打印构建计划而不执行它" #: ruyi/ruyipkg/admin_cli.py:195 msgid "Override the recipe project's output directory" msgstr "覆盖构建配方项目的输出目录" #: ruyi/ruyipkg/admin_cli.py:220 #, python-brace-format msgid "invalid --var spec {spec!r}: expected KEY=VALUE" msgstr "无效的 --var 规范 {spec!r}:预期格式为 KEY=VALUE" #: ruyi/ruyipkg/admin_cli.py:227 #, python-brace-format msgid "invalid --var spec {spec!r}: empty key" msgstr "无效的 --var 规范 {spec!r}:键为空" #: ruyi/ruyipkg/admin_cli.py:249 #, python-brace-format msgid "build {name!r} completed: {n} artifact(s)" msgstr "构建任务 {name!r} 完成:{n} 个构建产物" #: ruyi/ruyipkg/augmented_pkg.py:29 msgid "latest" msgstr "最新" #: ruyi/ruyipkg/augmented_pkg.py:31 msgid "latest-prerelease" msgstr "最新预发布" #: ruyi/ruyipkg/augmented_pkg.py:33 msgid "[red]no binary for current host[/]" msgstr "[red]无适用当前主机的二进制文件[/]" #: ruyi/device/provision.py:620 ruyi/ruyipkg/augmented_pkg.py:35 msgid "prerelease" msgstr "预发布" #: ruyi/ruyipkg/augmented_pkg.py:37 msgid "[yellow]has known issue[/]" msgstr "[yellow]有已知问题[/]" #: ruyi/ruyipkg/augmented_pkg.py:39 msgid "[green]downloaded[/]" msgstr "[green]已下载[/]" #: ruyi/ruyipkg/augmented_pkg.py:41 msgid "[green]installed[/]" msgstr "[green]已安装[/]" #: ruyi/ruyipkg/checksum.py:11 #, python-brace-format msgid "checksum algorithm {kind} not supported" msgstr "校验和算法 {kind} 不被支持" #: ruyi/ruyipkg/checksum.py:25 #, python-brace-format msgid "wrong {kind} checksum: want {want}, got {got}" msgstr "错误的 {kind} 校验和:期望 {want},得到 {got}" #: ruyi/ruyipkg/composite_repo.py:87 ruyi/ruyipkg/composite_repo.py:96 #, python-brace-format msgid "syncing repo '{id}'" msgstr "正在同步仓库 '{id}'" #: ruyi/ruyipkg/composite_repo.py:101 ruyi/ruyipkg/update_cli.py:43 #, python-brace-format msgid "no active repo with id '{id}'" msgstr "没有 ID 为 '{id}' 的活动仓库" #: ruyi/ruyipkg/composite_repo.py:113 #, python-brace-format msgid "repo '{id}' declares id '{on_disk_id}' in its config.toml; expected '{id}'" msgstr "仓库 '{id}' 在其 config.toml 中声明了 id '{on_disk_id}';预期为 '{id}'" #: ruyi/ruyipkg/distfile.py:17 msgid "" "\n" "Downloads can fail for a multitude of reasons, most of which should not and\n" "cannot be handled by [yellow]Ruyi[/]. For your convenience though, please check if any\n" "of the following common failure modes apply to you, and take actions\n" "accordingly if one of them turns out to be the case:\n" "\n" "* Basic connectivity problems\n" " - is [yellow]the gateway[/] reachable?\n" " - is [yellow]common websites[/] reachable?\n" " - is there any [yellow]DNS pollution[/]?\n" "* Organizational and/or ISP restrictions\n" " - is there a [yellow]firewall[/] preventing Ruyi traffic?\n" " - is your [yellow]ISP blocking access[/] to the source website?\n" "* Volatile upstream\n" " - is the recorded [yellow]link dead[/]? (Please raise a Ruyi issue for a fix!)\n" msgstr "" "\n" "下载可能因各种原因失败,其中大多数原因不应也无法由 [yellow]Ruyi[/] 处理。不过为了您的方便,\n" "请检查以下常见失败模式是否适用于您。如果您的情况属于其中之一,请采取相应措施:\n" "\n" "* 基本连接问题\n" " - [yellow]网关[/]是否可访问?\n" " - [yellow]常用网站[/]是否可访问?\n" " - 是否存在 [yellow]DNS 污染[/]?\n" "* 您的组织 和/或 互联网服务提供商(ISP)限制\n" " - 是否有[yellow]防火墙[/]阻止 Ruyi 流量?\n" " - 您的 [yellow]ISP 是否阻止访问[/]源网站?\n" "* 上游不稳定\n" " - 软件包内记录的[yellow]链接是否失效[/]?(请向 Ruyi 提起工单以获取修复!)\n" #: ruyi/ruyipkg/distfile.py:93 #, python-brace-format msgid "malformed package fetch instructions: the param named '{param}' is reserved and cannot be overridden by packages" msgstr "软件包下载方法文案的格式错误:名为 '{param}' 的参数是保留的,不能被软件包覆盖" #: ruyi/ruyipkg/distfile.py:98 msgid "malformed package fetch instructions" msgstr "软件包下载方法文案的格式错误" #: ruyi/ruyipkg/distfile.py:139 #, python-brace-format msgid "file {file} is corrupt: size too big ({actual_size} > {expected_size}); deleting" msgstr "文件 {file} 已损坏:大小过大({actual_size} > {expected_size});正在移除" #: ruyi/ruyipkg/distfile.py:157 #, python-brace-format msgid "file {file} is corrupt: {reason}; deleting" msgstr "文件 {file} 已损坏:{reason};正在移除" #: ruyi/ruyipkg/distfile.py:180 #, python-brace-format msgid "the file [yellow]'{file}'[/] cannot be automatically fetched" msgstr "文件 [yellow]'{file}'[/] 无法被自动获取" #: ruyi/ruyipkg/distfile.py:185 msgid "instructions on fetching this file:" msgstr "获取此文件的方法说明:" #: ruyi/ruyipkg/distfile.py:209 #, python-brace-format msgid "failed to fetch distfile: {file} failed integrity checks" msgstr "获取分发文件失败:{file} 未通过完整性检查" #: ruyi/ruyipkg/entity.py:68 #, python-brace-format msgid "no schema found for entity type: {entity_type}" msgstr "未找到实体类型的格式定义:{entity_type}" #: ruyi/ruyipkg/entity.py:81 #, python-brace-format msgid "failed to compile schema for {entity_type}: {reason}" msgstr "编译 {entity_type} 的格式定义失败:{reason}" #: ruyi/ruyipkg/entity_cli.py:17 msgid "Interact with entities defined in the repositories" msgstr "与仓库中定义的实体进行交互" #: ruyi/ruyipkg/entity_cli.py:27 msgid "Describe an entity" msgstr "描述一个实体" #: ruyi/ruyipkg/entity_cli.py:34 msgid "Reference to the entity to describe in the form of ':'" msgstr "以 ':' 形式描述实体的引用" #: ruyi/ruyipkg/entity_cli.py:46 #, python-brace-format msgid "entity [yellow]{ref}[/] not found" msgstr "未找到实体 [yellow]{ref}[/]" #: ruyi/ruyipkg/entity_cli.py:50 #, python-brace-format msgid "Entity [bold]{entity}[/] ([green]{display_name}[/])\n" msgstr "实体 [bold]{entity}[/]([green]{display_name}[/])\n" #: ruyi/ruyipkg/entity_cli.py:58 msgid " Direct forward relationships:" msgstr " 直接正向关系:" #: ruyi/ruyipkg/entity_cli.py:62 msgid " Direct forward relationships: [gray]none[/]" msgstr " 直接正向关系:[gray]无[/]" #: ruyi/ruyipkg/entity_cli.py:66 msgid " Direct reverse relationships:" msgstr " 直接反向关系:" #: ruyi/ruyipkg/entity_cli.py:70 msgid " Direct reverse relationships: [gray]none[/]" msgstr " 直接反向关系:[gray]无[/]" #: ruyi/ruyipkg/entity_cli.py:72 msgid " All indirectly related entities:" msgstr " 所有间接相关的实体:" #: ruyi/ruyipkg/entity_cli.py:90 msgid "List entities" msgstr "列出实体" #: ruyi/ruyipkg/entity_cli.py:101 msgid "List entities of this type. Can be passed multiple times to list multiple types." msgstr "列出此类型的实体。可以多次传递该参数以列出多种类型。" #: ruyi/ruyipkg/entity_provider.py:207 #, python-brace-format msgid "failed to access entity schemas directory {dir}: {reason}" msgstr "访问实体格式定义目录 {dir} 失败:{reason}" #: ruyi/ruyipkg/entity_provider.py:262 #, python-brace-format msgid "failed to load entity from {path}: {reason}" msgstr "从 {path} 加载实体失败:{reason}" #: ruyi/ruyipkg/fetcher.py:45 #, python-brace-format msgid "retrying download ({current} of {total} times)" msgstr "正在重试下载(第 {current} 次,共 {total} 次)" #: ruyi/ruyipkg/fetcher.py:57 #, python-brace-format msgid "downloading {url} to {dest}" msgstr "正在将 {url} 下载到 {dest}" #: ruyi/ruyipkg/fetcher.py:66 #, python-brace-format msgid "failed to fetch '{dest}': all source URLs have failed" msgstr "获取 '{dest}' 失败:所有源 URL 均失败" #: ruyi/ruyipkg/fetcher.py:94 ruyi/ruyipkg/fetcher.py:125 msgid "no fetcher is available on the system" msgstr "系统上没有可用的下载器" #: ruyi/ruyipkg/fetcher.py:105 #, python-brace-format msgid "unknown fetcher '{name}'" msgstr "未知的下载器 '{name}'" #: ruyi/ruyipkg/fetcher.py:111 #, python-brace-format msgid "the requested fetcher '{name}' is unavailable on the system" msgstr "请求的下载器 '{name}' 在当前系统不可用" #: ruyi/ruyipkg/fetcher.py:175 ruyi/ruyipkg/fetcher.py:218 #, python-brace-format msgid "failed to fetch distfile: command '{cmd}' returned {retcode}" msgstr "获取分发文件失败:命令 '{cmd}' 返回 {retcode}" #: ruyi/ruyipkg/install.py:48 ruyi/ruyipkg/install.py:188 #, python-brace-format msgid "atom {atom} matches no package in the repository" msgstr "atom {atom} 在仓库中未匹配到任何软件包" #: ruyi/ruyipkg/install.py:56 ruyi/ruyipkg/install.py:195 msgid "package has known issue(s)" msgstr "软件包有已知问题" #: ruyi/ruyipkg/install.py:106 #, python-brace-format msgid "don't know how to extract package [green]{pkg}[/]" msgstr "不知道如何解压缩软件包 [green]{pkg}[/]" #: ruyi/ruyipkg/install.py:115 #, python-brace-format msgid "cannot handle package [green]{pkg}[/]: package is both binary and source" msgstr "无法处理软件包 [green]{pkg}[/]:软件包既是二进制又是源码" #: ruyi/ruyipkg/install.py:130 #, python-brace-format msgid "package [green]{pkg}[/] declares no distfile for host {host}" msgstr "软件包 [green]{pkg}[/] 未为主机 {host} 声明分发文件" #: ruyi/ruyipkg/install.py:150 ruyi/ruyipkg/install.py:382 #: ruyi/ruyipkg/install.py:509 #, python-brace-format msgid "extracting [green]{distfile}[/] for package [green]{pkg}[/]" msgstr "正在为软件包 [green]{pkg}[/] 解压缩 [green]{distfile}[/]" #: ruyi/ruyipkg/install.py:162 #, python-brace-format msgid "package [green]{pkg}[/] has been extracted to {dest_dir}" msgstr "软件包 [green]{pkg}[/] 已被解压缩到 {dest_dir}" #: ruyi/ruyipkg/install.py:245 ruyi/ruyipkg/install.py:595 #, python-brace-format msgid "don't know how to handle non-binary package [green]{pkg}[/]" msgstr "不知道如何处理非二进制软件包 [green]{pkg}[/]" #: ruyi/ruyipkg/install.py:285 ruyi/ruyipkg/install.py:422 #, python-brace-format msgid "skipping already installed package [green]{pkg}[/]" msgstr "跳过已安装的软件包 [green]{pkg}[/]" #: ruyi/ruyipkg/install.py:293 ruyi/ruyipkg/install.py:430 #, python-brace-format msgid "package [green]{pkg}[/] seems already installed; purging and re-installing due to [yellow]--reinstall[/]" msgstr "软件包 [green]{pkg}[/] 似乎已安装;由于传入了 [yellow]--reinstall[/],将移除并重新安装它" #: ruyi/ruyipkg/install.py:334 #, python-brace-format msgid "package [green]{pkg}[/]{repo_tag} installed to [yellow]{install_root}[/]" msgstr "软件包 [green]{pkg}[/]{repo_tag} 已安装到 [yellow]{install_root}[/]" #: ruyi/ruyipkg/install.py:362 #, python-brace-format msgid "package [green]{pkg}[/] declares no binary for host {host}" msgstr "软件包 [green]{pkg}[/] 未为主机 {host} 声明二进制文件" #: ruyi/ruyipkg/install.py:377 ruyi/ruyipkg/install.py:504 msgid "skipping installation because [yellow]--fetch-only[/] is given" msgstr "由于传入了 [yellow]--fetch-only[/],跳过安装" #: ruyi/ruyipkg/install.py:468 #, python-brace-format msgid "package [green]{pkg}[/] installed to [yellow]{install_root}[/]" msgstr "软件包 [green]{pkg}[/] 已安装到 [yellow]{install_root}[/]" #: ruyi/ruyipkg/install.py:492 #, python-brace-format msgid "package [green]{pkg}[/] declares no blob distfile" msgstr "软件包 [green]{pkg}[/] 未声明 blob 分发文件" #: ruyi/ruyipkg/install.py:538 #, python-brace-format msgid "atom [yellow]{atom}[/] is non-existent or not installed" msgstr "atom [yellow]{atom}[/] 不存在或未安装" #: ruyi/ruyipkg/install.py:546 msgid "no packages to uninstall" msgstr "没有要卸载的软件包" #: ruyi/ruyipkg/install.py:549 msgid "the following packages will be uninstalled:" msgstr "以下软件包将被卸载:" #: ruyi/ruyipkg/install.py:552 #, python-brace-format msgid " - [green]{category}/{name}[/] ({version})" msgstr " - [green]{category}/{name}[/]({version})" #: ruyi/device/provision.py:180 ruyi/ruyipkg/install.py:560 msgid "Proceed?" msgstr "继续吗?" #: ruyi/ruyipkg/install.py:561 msgid "uninstallation aborted" msgstr "卸载已中止" #: ruyi/ruyipkg/install.py:629 ruyi/ruyipkg/install.py:691 #, python-brace-format msgid "skipping not-installed package [green]{pkg}[/]" msgstr "跳过未安装的软件包 [green]{pkg}[/]" #: ruyi/ruyipkg/install.py:639 ruyi/ruyipkg/install.py:701 #, python-brace-format msgid "package [green]{pkg}[/] is not tracked as installed, but its directory [yellow]{install_root}[/] exists." msgstr "软件包 [green]{pkg}[/] 未被记录为已安装,但其目录 [yellow]{install_root}[/] 存在。" #: ruyi/ruyipkg/install.py:642 ruyi/ruyipkg/install.py:704 msgid "Please remove it manually if you are sure it's safe to do so." msgstr "如果您确定这样做是安全的,请手动移除它。" #: ruyi/ruyipkg/install.py:645 ruyi/ruyipkg/install.py:707 msgid "If you believe this is a bug, please file an issue at [yellow]https://github.com/ruyisdk/ruyi/issues[/]." msgstr "如果您认为这是一个 bug,请在 [yellow]https://github.com/ruyisdk/ruyi/issues[/] 提交工单。" #: ruyi/ruyipkg/install.py:650 ruyi/ruyipkg/install.py:712 #, python-brace-format msgid "uninstalling package [green]{pkg}[/]" msgstr "正在卸载软件包 [green]{pkg}[/]" #: ruyi/ruyipkg/install.py:663 ruyi/ruyipkg/install.py:725 #, python-brace-format msgid "package [green]{pkg}[/] uninstalled" msgstr "软件包 [green]{pkg}[/] 已被卸载" #: ruyi/ruyipkg/install_cli.py:18 msgid "Fetch package(s) then extract to current directory" msgstr "获取软件包然后解压缩到当前目录" #: ruyi/ruyipkg/install_cli.py:26 msgid "Specifier (atom) of the package(s) to extract" msgstr "要解压缩的软件包的指示表达式(atom)" #: ruyi/ruyipkg/install_cli.py:37 msgid "Destination directory to extract to (default: current directory)" msgstr "解压缩的目标目录(默认:当前目录)" #: ruyi/ruyipkg/install_cli.py:43 msgid "Extract files directly into DESTDIR instead of package-named subdirectories" msgstr "直接将文件解压缩到 DESTDIR 而不是以软件包命名的子目录" #: ruyi/ruyipkg/install_cli.py:50 ruyi/ruyipkg/install_cli.py:104 msgid "Fetch distribution files only without installing" msgstr "仅获取分发文件而不安装" #: ruyi/ruyipkg/install_cli.py:56 ruyi/ruyipkg/install_cli.py:110 #: ruyi/ruyipkg/install_cli.py:156 msgid "Override the host architecture (normally not needed)" msgstr "覆盖主机架构(通常不需要)" #: ruyi/ruyipkg/install_cli.py:87 msgid "Install package from configured repository" msgstr "从已配置的仓库安装软件包" #: ruyi/ruyipkg/install_cli.py:95 msgid "Specifier (atom) of the package to install" msgstr "要安装的软件包的指示表达式(atom)" #: ruyi/ruyipkg/install_cli.py:115 msgid "Force re-installation of already installed packages" msgstr "强制重新安装已安装的软件包" #: ruyi/ruyipkg/install_cli.py:142 msgid "Uninstall installed packages" msgstr "卸载已安装的软件包" #: ruyi/ruyipkg/install_cli.py:150 msgid "Specifier (atom) of the package to uninstall" msgstr "要卸载的软件包的指示表达式(atom)" #: ruyi/ruyipkg/install_cli.py:163 msgid "Assume yes to all prompts" msgstr "对所有问题都回答“是”" #: ruyi/ruyipkg/list.py:26 msgid "no filter specified for list operation" msgstr "未为 list 操作指定过滤器" #: ruyi/ruyipkg/list.py:29 msgid "for the old behavior of listing all packages, try [yellow]ruyi list --all[/]" msgstr "如果您意图唤起“列出所有软件包”这一旧版行为,请尝试 [yellow]ruyi list --all[/]" #: ruyi/ruyipkg/list.py:57 msgid "List of available packages:\n" msgstr "可用软件包列表:\n" #: ruyi/ruyipkg/list.py:97 #, python-brace-format msgid "* Slug: [yellow]{slug}[/]" msgstr "* Slug:[yellow]{slug}[/]" #: ruyi/ruyipkg/list.py:99 msgid "* Slug: (none)" msgstr "* Slug:(无)" #: ruyi/ruyipkg/list.py:100 #, python-brace-format msgid "* Package kind: {kind}" msgstr "* 软件包类型:{kind}" #: ruyi/ruyipkg/list.py:101 #, python-brace-format msgid "* Vendor: {vendor}" msgstr "* 供应商:{vendor}" #: ruyi/ruyipkg/list.py:104 #, python-brace-format msgid "* Upstream version number: {version}" msgstr "* 上游版本号:{version}" #: ruyi/ruyipkg/list.py:107 msgid "* Upstream version number: (undeclared)" msgstr "* 上游版本号:(未声明)" #: ruyi/ruyipkg/list.py:112 msgid "" "\n" "Package has known issue(s):\n" msgstr "" "\n" "软件包有已知问题:\n" #: ruyi/ruyipkg/list.py:117 #, python-brace-format msgid "Package declares {count} distfile(s):\n" msgstr "软件包声明了 {count} 个分发文件:\n" #: ruyi/ruyipkg/list.py:120 #, python-brace-format msgid " - Size: [yellow]{size}[/] bytes" msgstr " - 大小:[yellow]{size}[/] 字节" #: ruyi/ruyipkg/list.py:125 msgid "" "\n" "### Binary artifacts\n" msgstr "" "\n" "### 二进制制品\n" #: ruyi/ruyipkg/list.py:127 #, python-brace-format msgid "* Host [green]{host}[/]:" msgstr "* 主机 [green]{host}[/]:" #: ruyi/ruyipkg/list.py:129 #, python-brace-format msgid " - Distfiles: {distfiles}" msgstr " - 分发文件:{distfiles}" #: ruyi/ruyipkg/list.py:132 msgid " - Available command(s):" msgstr " - 可用命令:" #: ruyi/ruyipkg/list.py:137 msgid "" "\n" "### Toolchain metadata\n" msgstr "" "\n" "### 工具链元数据\n" #: ruyi/ruyipkg/list.py:138 #, python-brace-format msgid "* Target: [bold green]{target}[/]" msgstr "* 目标:[bold green]{target}[/]" #: ruyi/ruyipkg/list.py:139 #, python-brace-format msgid "* Quirks: {quirks}" msgstr "* 特殊特性:{quirks}" #: ruyi/ruyipkg/list.py:140 msgid "* Components:" msgstr "* 组件:" #: ruyi/ruyipkg/list_cli.py:19 msgid "List available packages in configured repository" msgstr "列出已配置仓库中的可用软件包" #: ruyi/ruyipkg/list_cli.py:27 msgid "Also show details for every package" msgstr "也显示每个软件包的详细信息" #: ruyi/ruyipkg/list_cli.py:35 msgid "Match and show all packages" msgstr "匹配、显示所有软件包" #: ruyi/ruyipkg/list_cli.py:45 msgid "Match packages that are installed (y/true/1) or not installed (n/false/0)" msgstr "匹配已安装(y/true/1)或未安装(n/false/0)的软件包" #: ruyi/ruyipkg/list_cli.py:54 msgid "Match packages from categories whose names contain the given string" msgstr "匹配属于特定类别的软件包,其中类别的名称包含给定字符串" #: ruyi/ruyipkg/list_cli.py:62 msgid "Match packages from the given category" msgstr "匹配来自给定类别的软件包" #: ruyi/ruyipkg/list_cli.py:69 msgid "Match packages whose names contain the given string" msgstr "匹配名称包含给定字符串的软件包" #: ruyi/ruyipkg/list_cli.py:78 msgid "Match packages related to the given entity" msgstr "匹配与给定实体相关的软件包" #: ruyi/ruyipkg/migration.py:35 #, python-brace-format msgid "migrating repo directory from [yellow]{old}[/] to [yellow]{new}[/]" msgstr "正在将仓库目录从 [yellow]{old}[/] 迁移到 [yellow]{new}[/]" #: ruyi/ruyipkg/migration.py:47 msgid "repo directory migration complete" msgstr "仓库目录迁移完成" #. i18n NOTE: used as news item table title #: ruyi/ruyipkg/news.py:19 msgid "No." msgstr "序号" #. i18n NOTE: used as news item table title #: ruyi/ruyipkg/news.py:21 msgid "ID" msgstr "ID" #. i18n NOTE: used as news item table title #: ruyi/ruyipkg/news.py:23 msgid "Title" msgstr "标题" #: ruyi/ruyipkg/news.py:48 #, python-brace-format msgid "" "\n" "There are {count} new news item(s):\n" msgstr "" "\n" "有 {count} 条新的新闻条目:\n" #: ruyi/ruyipkg/news.py:53 msgid "" "\n" "You can read them with [yellow]ruyi news read[/]." msgstr "" "\n" "您可以使用 [yellow]ruyi news read[/] 阅读它们。" #: ruyi/ruyipkg/news.py:59 msgid "" "\n" "All news items have been read. To see a list of them, run [yellow]ruyi news list[/].\n" msgstr "" "\n" "所有新闻条目均已读。如要查看新闻列表,请执行 [yellow]ruyi news list[/]。\n" #: ruyi/ruyipkg/news.py:78 msgid "[bold green]News items:[/]\n" msgstr "[bold green]新闻条目:[/]\n" #: ruyi/ruyipkg/news.py:80 msgid " (no unread item)" msgstr " (无未读条目)" #: ruyi/ruyipkg/news.py:80 msgid " (no item)" msgstr " (无条目)" #: ruyi/ruyipkg/news.py:111 msgid "No news to display." msgstr "没有要显示的新闻。" #: ruyi/ruyipkg/news.py:137 #, python-brace-format msgid "there is no news item with ordinal {ord}" msgstr "没有序号为 {ord} 的新闻条目" #: ruyi/ruyipkg/news.py:144 #, python-brace-format msgid "there is no news item with ID '{id}'" msgstr "没有 ID 为 '{id}' 的新闻条目" #: ruyi/ruyipkg/news_cli.py:18 msgid "List and read news items from configured repository" msgstr "列出并阅读已配置仓库中的新闻条目" #: ruyi/ruyipkg/news_cli.py:40 msgid "List news items" msgstr "列出新闻条目" #: ruyi/ruyipkg/news_cli.py:47 msgid "List unread news items only" msgstr "仅列出未读的新闻条目" #: ruyi/ruyipkg/news_cli.py:64 msgid "Read news items" msgstr "阅读新闻条目" #: ruyi/ruyipkg/news_cli.py:66 msgid "Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified." msgstr "将新闻条目输出到控制台并标记为已读。如果未指定条目,默认阅读所有未读条目。" #: ruyi/ruyipkg/news_cli.py:75 msgid "Do not output anything and only mark as read" msgstr "不输出任何内容,仅标记为已读" #: ruyi/ruyipkg/news_cli.py:81 msgid "Ordinal or ID of the news item(s) to read" msgstr "要阅读的新闻条目的序号或 ID" #: ruyi/ruyipkg/profile_cli.py:15 msgid "List all available profiles" msgstr "列出所有可用配置文件" #: ruyi/ruyipkg/profile_cli.py:30 #, python-brace-format msgid "{profile_id} (arch: [green]{arch}[/])" msgstr "{profile_id}(架构:[green]{arch}[/])" #: ruyi/ruyipkg/profile_cli.py:39 #, python-brace-format msgid "{profile_id} (arch: [green]{arch}[/], needs quirks: [yellow]{need_quirks}[/])" msgstr "{profile_id}(架构:[green]{arch}[/],需要特殊特性:[yellow]{need_quirks}[/])" #: ruyi/ruyipkg/repo.py:255 #, python-brace-format msgid "unrecognized dist URL scheme: {scheme}" msgstr "未识别的分发 URL 协议:{scheme}" #: ruyi/ruyipkg/repo.py:413 #, python-brace-format msgid "the package repository does not exist at [yellow]{root}[/]" msgstr "软件包仓库不存在于 [yellow]{root}[/]" #: ruyi/ruyipkg/repo.py:418 #, python-brace-format msgid "cloning from [cyan link={remote}]{remote}[/]" msgstr "正在从 [cyan link={remote}]{remote}[/] 克隆" #: ruyi/ruyipkg/repo.py:444 #, python-brace-format msgid "skipping sync for local-only repo '{id}'" msgstr "为设置为仅本地的仓库 '{id}' 跳过同步" #: ruyi/ruyipkg/repo.py:452 msgid "updating the package repository" msgstr "正在更新软件包仓库" #: ruyi/ruyipkg/repo.py:469 msgid "package repository is updated" msgstr "软件包仓库已更新" #: ruyi/ruyipkg/repo.py:753 #, python-brace-format msgid "UnicodeDecodeError: {path}" msgstr "Unicode 解码错误:{path}" #: ruyi/ruyipkg/repo.py:789 #, python-brace-format msgid "unexpected return type of cmd plugin '{plugin_id}': {type} is not int." msgstr "命令插件 '{plugin_id}' 的返回类型非预期:{type} 不是 int。" #: ruyi/ruyipkg/repo.py:795 msgid "forcing return code to 1; the plugin should be fixed" msgstr "强制返回码为 1;该插件需要被修复" #: ruyi/ruyipkg/repo_cli.py:17 msgid "Manage configured package repositories" msgstr "管理已配置的软件包仓库" #: ruyi/ruyipkg/repo_cli.py:31 msgid "List configured package repositories" msgstr "列出已配置的软件包仓库" #: ruyi/ruyipkg/repo_cli.py:75 msgid "Add a package repository" msgstr "添加一个软件包仓库" #: ruyi/ruyipkg/repo_cli.py:79 msgid "unique repository identifier" msgstr "唯一的仓库标识符" #: ruyi/ruyipkg/repo_cli.py:81 msgid "git remote URL" msgstr "Git 远端 URL" #: ruyi/ruyipkg/repo_cli.py:84 msgid "git branch to track" msgstr "要跟踪的 Git 分支" #: ruyi/ruyipkg/repo_cli.py:90 msgid "priority (higher = overrides lower)" msgstr "优先级(高值 = 覆盖低值)" #: ruyi/ruyipkg/repo_cli.py:96 msgid "local path to use instead of or alongside remote" msgstr "本地路径,用于替代远端路径,或与远端路径一起使用" #: ruyi/ruyipkg/repo_cli.py:99 msgid "human-readable name for the repo" msgstr "仓库的可读名称" #: ruyi/ruyipkg/repo_cli.py:122 #, python-brace-format msgid "invalid repo id '{id}'" msgstr "无效的仓库 ID '{id}'" #: ruyi/ruyipkg/repo_cli.py:128 #, python-brace-format msgid "'{id}' is reserved; use [repo] config to configure the default repository" msgstr "'{id}' 是保留的;请使用 [repo] 配置来配置默认仓库" #: ruyi/ruyipkg/repo_cli.py:136 msgid "at least one of URL or --local must be provided" msgstr "URL 或 --local 至少需要提供一个" #: ruyi/ruyipkg/repo_cli.py:140 #, python-brace-format msgid "local path '{path}' must be absolute" msgstr "本地路径 '{path}' 必须是绝对路径" #: ruyi/ruyipkg/repo_cli.py:146 #, python-brace-format msgid "a repo with id '{id}' already exists" msgstr "已有一个 ID 为 '{id}' 的软件包仓库了" #: ruyi/ruyipkg/repo_cli.py:166 #, python-brace-format msgid "repo '{id}' added; run 'ruyi update' to sync" msgstr "已添加软件包仓库 '{id}';运行 'ruyi update' 以同步" #: ruyi/ruyipkg/repo_cli.py:173 msgid "Remove a package repository" msgstr "移除一个软件包仓库" #: ruyi/ruyipkg/repo_cli.py:177 msgid "repository identifier to remove" msgstr "要移除的仓库的标识符" #: ruyi/ruyipkg/repo_cli.py:186 msgid "also remove cached repo data from disk" msgstr "同时从磁盘中移除缓存的仓库数据" #: ruyi/ruyipkg/repo_cli.py:202 #, python-brace-format msgid "cannot remove the default repo '{id}'; use 'repo disable' instead" msgstr "无法移除默认仓库 '{id}';请使用 'repo disable' 来禁用它" #: ruyi/ruyipkg/repo_cli.py:214 #, python-brace-format msgid "cannot remove system-provided repo '{id}'; use 'repo disable' instead" msgstr "无法移除由系统提供的仓库 '{id}';请使用 'repo disable' 来禁用它" #: ruyi/ruyipkg/repo_cli.py:224 ruyi/ruyipkg/repo_cli.py:263 #: ruyi/ruyipkg/repo_cli.py:296 ruyi/ruyipkg/repo_cli.py:331 #, python-brace-format msgid "no repo with id '{id}' found in user config" msgstr "在用户配置中未找到 ID 为 '{id}' 的软件包仓库" #: ruyi/ruyipkg/repo_cli.py:233 #, python-brace-format msgid "purged cached data at '{path}'" msgstr "已清除 '{path}' 的缓存数据" #: ruyi/ruyipkg/repo_cli.py:235 #, python-brace-format msgid "repo '{id}' removed" msgstr "已移除软件包仓库 '{id}'" #: ruyi/ruyipkg/repo_cli.py:242 msgid "Enable a package repository" msgstr "启用一个软件包仓库" #: ruyi/ruyipkg/repo_cli.py:246 msgid "repository identifier to enable" msgstr "要启用的仓库的标识符" #: ruyi/ruyipkg/repo_cli.py:268 #, python-brace-format msgid "repo '{id}' enabled" msgstr "已启用软件包仓库 '{id}'" #: ruyi/ruyipkg/repo_cli.py:275 msgid "Disable a package repository" msgstr "禁用一个软件包仓库" #: ruyi/ruyipkg/repo_cli.py:279 msgid "repository identifier to disable" msgstr "要禁用的仓库的标识符" #: ruyi/ruyipkg/repo_cli.py:301 #, python-brace-format msgid "repo '{id}' disabled" msgstr "已禁用软件包仓库 '{id}'" #: ruyi/ruyipkg/repo_cli.py:308 msgid "Set the priority of a package repository" msgstr "为一个软件包仓库设置优先级" #: ruyi/ruyipkg/repo_cli.py:312 msgid "repository identifier" msgstr "软件包仓库标识符" #: ruyi/ruyipkg/repo_cli.py:317 msgid "new priority value" msgstr "新的优先级取值" #: ruyi/ruyipkg/repo_cli.py:337 #, python-brace-format msgid "repo '{id}' priority set to {priority}" msgstr "软件包仓库 '{id}' 的优先级已设置为 {priority}" #: ruyi/ruyipkg/unpack.py:197 #, python-brace-format msgid "untar failed: command {cmd} returned {retcode}" msgstr "tar 解包失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:216 #, python-brace-format msgid "unzip failed: command {cmd} returned {retcode}" msgstr "unzip 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:240 #, python-brace-format msgid "gunzip failed: command {cmd} returned {retcode}" msgstr "gunzip 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:264 #, python-brace-format msgid "bzip2 failed: command {cmd} returned {retcode}" msgstr "bzip2 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:284 #, python-brace-format msgid "lz4 failed: command {cmd} returned {retcode}" msgstr "lz4 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:308 #, python-brace-format msgid "xz failed: command {cmd} returned {retcode}" msgstr "xz 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:328 #, python-brace-format msgid "zstd failed: command {cmd} returned {retcode}" msgstr "zstd 失败:命令 {cmd} 返回 {retcode}" #: ruyi/ruyipkg/unpack.py:355 #, python-brace-format msgid "file '{filename}' does not appear to be a deb" msgstr "文件 '{filename}' 似乎不是一个 deb" #: ruyi/ruyipkg/unpack_method.py:38 #, python-brace-format msgid "don't know how to unpack file {filename}" msgstr "不知道如何解包文件 {filename}" #: ruyi/ruyipkg/update_cli.py:15 msgid "Update RuyiSDK repo and packages" msgstr "更新 RuyiSDK 仓库和软件包" #: ruyi/ruyipkg/update_cli.py:23 msgid "only sync the repo with this ID" msgstr "仅同步 ID 为此值的仓库" #: ruyi/ruyipkg/update_cli.py:55 msgid "" "\n" "Newer versions are available for some of your installed packages:\n" msgstr "" "\n" "您的某些已安装软件包有更新的版本可用:\n" #: ruyi/ruyipkg/update_cli.py:65 #, python-brace-format msgid "package '{category}/{name}' was installed from repo '{repo}' but the latest version is in a different repo" msgstr "软件包 '{category}/{name}' 是从仓库 '{repo}' 安装的,但最新版本在另一个仓库中" #: ruyi/ruyipkg/update_cli.py:76 msgid "" "\n" "Re-run [yellow]ruyi install[/] to upgrade, and don't forget to re-create any affected\n" "virtual environments." msgstr "" "\n" "重新运行 [yellow]ruyi install[/] 以升级,不要忘记重新创建任何受影响的虚拟环境。" #: ruyi/telemetry/provider.py:23 msgid "" "\n" "RuyiSDK collects minimal usage data in the form of just a version number of\n" "the running [yellow]ruyi[/], to help us improve the product. With your consent,\n" "RuyiSDK may also collect additional non-tracking usage data to be sent\n" "periodically. The data will be recorded and processed by RuyiSDK team-managed\n" "servers located in the Chinese mainland.\n" "\n" "[green]By default, nothing leaves your machine[/], and you can also turn off usage data\n" "collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and\n" "upload more usage data. You can change this setting at any time by running\n" "[yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/].\n" "\n" "We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK\n" "team can better understand adoption. If you choose to opt out, this will be the\n" "only data to be ever uploaded, without any tracking ID being generated or kept.\n" "Thank you for helping us build a better experience!\n" msgstr "" "\n" "RuyiSDK 仅以运行中的 [yellow]ruyi[/] 版本号的形式最小化地收集使用数据,以帮助我们改进产品。经您同意,\n" "RuyiSDK 也可以收集并定期发送额外的、无法用来追踪用户身份的使用数据。数据将由位于中国大陆的、\n" "由 RuyiSDK 团队管理的服务器记录和处理。\n" "\n" "[green]默认情况下,任何内容都不会离开您的计算机[/],您也可以完全关闭使用数据收集。\n" "只有在您明确许可的情况下,[yellow]ruyi[/] 才能收集和上传更多使用数据。您随时可以通过运行\n" "[yellow]ruyi telemetry consent[/]、[yellow]ruyi telemetry local[/] 或 [yellow]ruyi telemetry optout[/] 更改设置。\n" "\n" "我们还将一次性从本台 [yellow]ruyi[/] 实例发送单条报告,以便 RuyiSDK 团队更好地了解产品用户增长情况。\n" "如果您选择不同意,这将是唯一被上传的数据,我们不会生成或保留任何跟踪用的 ID。\n" "感谢您帮助我们构建更好的体验!\n" #: ruyi/telemetry/provider.py:42 msgid "Do you agree to have usage data periodically uploaded?" msgstr "您是否同意定期上传使用数据?" #: ruyi/telemetry/provider.py:44 msgid "" "\n" "Do you want to opt out of telemetry entirely?" msgstr "" "\n" "您是否要完全禁用遥测?" #: ruyi/telemetry/provider.py:46 msgid "malformed telemetry state: unable to determine upload weekday, nothing will be uploaded" msgstr "遥测状态格式错误:无法确定上传日,将不会上传任何数据" #: ruyi/telemetry/provider.py:121 msgid "telemetry data uploading is now enabled" msgstr "现已启用遥测数据上传" #: ruyi/telemetry/provider.py:124 msgid "you can opt out at any time by running [yellow]ruyi telemetry optout[/]" msgstr "您可以随时通过运行 [yellow]ruyi telemetry optout[/] 退出遥测" #: ruyi/telemetry/provider.py:128 msgid "telemetry mode is now set to local collection only" msgstr "遥测模式现已设置为仅本地收集" #: ruyi/telemetry/provider.py:131 msgid "you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]" msgstr "您可以随时通过运行 [yellow]ruyi telemetry consent[/] 重新启用遥测数据上传" #: ruyi/telemetry/provider.py:135 msgid "or opt out at any time by running [yellow]ruyi telemetry optout[/]" msgstr "或随时通过运行 [yellow]ruyi telemetry optout[/] 退出遥测" #: ruyi/telemetry/provider.py:138 msgid "telemetry data collection is now disabled" msgstr "现已禁用遥测数据收集" #: ruyi/telemetry/provider.py:141 msgid "you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]" msgstr "您可以随时通过运行 [yellow]ruyi telemetry consent[/] 重新启用遥测数据上传" #: ruyi/telemetry/provider.py:333 #, python-brace-format msgid "scope {scope}: usage information has already been uploaded today at {last_upload_time_str}" msgstr "{scope} 范畴:使用信息已于今天 {last_upload_time_str} 上传" #: ruyi/telemetry/provider.py:342 #, python-brace-format msgid "scope {scope}: usage information has already been uploaded sometime today" msgstr "{scope} 范畴:使用信息已于今天上传" #: ruyi/telemetry/provider.py:350 #, python-brace-format msgid "scope {scope}: the next upload will happen [bold green]today[/] if not already" msgstr "{scope} 范畴:下次上传将在[bold green]今天[/]进行(如果尚未上传)" #: ruyi/telemetry/provider.py:357 msgid "the next upload will happen anytime [yellow]ruyi[/] is executed:" msgstr "下次上传将在执行 [yellow]ruyi[/] 时进行:" #: ruyi/telemetry/provider.py:361 #, python-brace-format msgid " - between [bold green]{time_start}[/] and [bold green]{time_end}[/]" msgstr " - 在 [bold green]{time_start}[/] 和 [bold green]{time_end}[/] 之间" #: ruyi/telemetry/provider.py:367 msgid " - or if the last upload is more than a week ago" msgstr " - 或者如果上次上传是一周之前" #: ruyi/telemetry/provider.py:374 msgid "telemetry mode is [green]off[/]: nothing is collected or uploaded after the first run" msgstr "遥测模式为 [green]off[/]:首次运行后,不会收集或上传任何内容" #: ruyi/telemetry/provider.py:394 msgid "telemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requested" msgstr "遥测模式为 [green]local[/]:仅本地使用收集,除非明确请求否则不会上传" #: ruyi/telemetry/provider.py:406 msgid "telemetry mode is [green]on[/]: usage data is collected and periodically uploaded" msgstr "遥测模式为 [green]on[/]:使用数据会被收集并定期上传" #: ruyi/telemetry/provider.py:411 #, python-brace-format msgid "non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {weekday}[/]" msgstr "无法用来追踪用户身份的使用信息将于[bold green]每{weekday}[/]上传到 RuyiSDK 管理的服务器" #: ruyi/telemetry/provider.py:419 #, python-brace-format msgid "this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {weekday}[/]" msgstr "本 [yellow]ruyi[/] 安装的遥测模式被设置为 [yellow]on[/],[bold]将于[bold green]每{weekday}[/]上传无法用来追踪用户身份的使用信息到 RuyiSDK 管理的服务器[/]" #: ruyi/telemetry/provider.py:428 msgid "in order to hide this banner:" msgstr "要隐藏此横幅:" #: ruyi/telemetry/provider.py:429 msgid " - opt out with [yellow]ruyi telemetry optout[/]" msgstr " - 使用 [yellow]ruyi telemetry optout[/] 退出遥测" #: ruyi/telemetry/provider.py:431 msgid " - or give consent with [yellow]ruyi telemetry consent[/]" msgstr " - 或使用 [yellow]ruyi telemetry consent[/] 明确同意" #: ruyi/telemetry/telemetry_cli.py:19 msgid "Manage your telemetry preferences" msgstr "管理您的遥测偏好设置" #: ruyi/telemetry/telemetry_cli.py:51 msgid "Give consent to telemetry data uploads" msgstr "同意上传遥测数据" #: ruyi/telemetry/telemetry_cli.py:69 msgid "Set telemetry mode to local collection only" msgstr "将遥测模式设置为仅本地收集" #: ruyi/telemetry/telemetry_cli.py:87 msgid "Opt out of telemetry data collection" msgstr "退出遥测数据收集" #: ruyi/telemetry/telemetry_cli.py:104 msgid "Print the current telemetry mode" msgstr "打印当前遥测模式" #: ruyi/telemetry/telemetry_cli.py:112 msgid "Enable verbose output" msgstr "启用详细输出" #: ruyi/telemetry/telemetry_cli.py:124 msgid "telemetry mode is [green]off[/]: no further data will be collected" msgstr "遥测模式为 [green]off[/]:不会收集更多数据" #: ruyi/telemetry/telemetry_cli.py:135 msgid "Upload collected telemetry data now" msgstr "立即上传已收集的遥测数据" #: ruyi/utils/git.py:81 msgid "transferring objects" msgstr "正在传输对象" #: ruyi/utils/git.py:85 msgid "processing deltas" msgstr "正在处理 deltas" #: ruyi/utils/git.py:118 #, python-brace-format msgid "URL of remote '[yellow]{remote}[/]' does not match expected URL" msgstr "远端 '[yellow]{remote}[/]' 的 URL 与预期 URL 不匹配" #: ruyi/utils/git.py:122 #, python-brace-format msgid "repository: [yellow]{path}[/]" msgstr "仓库: [yellow]{path}[/]" #: ruyi/utils/git.py:123 #, python-brace-format msgid "expected remote URL: [yellow]{url}[/]" msgstr "预期远端 URL: [yellow]{url}[/]" #: ruyi/utils/git.py:124 #, python-brace-format msgid "actual remote URL: [yellow]{url}[/]" msgstr "实际远端 URL: [yellow]{url}[/]" #: ruyi/utils/git.py:125 msgid "please [bold red]fix the repo settings manually[/]" msgstr "请[bold red]手动修复仓库设置[/]" #: ruyi/utils/git.py:141 #, python-brace-format msgid "failed to fetch from remote URL {url}: {reason}" msgstr "从远端 URL {url} 获取失败:{reason}" #: ruyi/utils/git.py:176 msgid "cannot fast-forward repo to newly fetched state" msgstr "无法将仓库快进到新获取的状态" #: ruyi/utils/git.py:177 msgid "manual intervention is required to avoid data loss" msgstr "需要手动干预以避免数据丢失" #: ruyi/utils/prereqs.py:62 #, python-brace-format msgid "The command(s) {cmds} cannot be found in PATH, which [yellow]ruyi[/] requires" msgstr "在 PATH 中找不到命令 {cmds},而 [yellow]ruyi[/] 需要它们" #: ruyi/utils/prereqs.py:66 msgid "please install and retry" msgstr "请安装并重试" #: ruyi/utils/prereqs.py:72 msgid "please install them and press [green]Enter[/] to retry, or [green]Ctrl+C[/] to exit" msgstr "请安装它们并按 [green]Enter[/] 重试,或按 [green]Ctrl+C[/] 退出" #: ruyi/utils/prereqs.py:78 msgid "exiting due to EOF" msgstr "由于 EOF 退出" #: ruyi/utils/prereqs.py:81 msgid "exiting due to keyboard interrupt" msgstr "由于键盘中断退出" #: ruyi/utils/ssl_patch.py:39 msgid "failed to probe system libcrypto" msgstr "探测系统 libcrypto 失败" #: ruyi/version.py:9 msgid "" "Copyright (C) Institute of Software, Chinese Academy of Sciences (ISCAS).\n" "All rights reserved.\n" "License: Apache-2.0 \n" msgstr "" "版权所有 (C) 中国科学院软件研究所 (ISCAS)。所有权利保留。\n" "许可证:Apache-2.0 \n" #: ruyi/version.py:18 msgid "" "This distribution of ruyi contains code licensed under the Mozilla Public\n" "License 2.0 (https://mozilla.org/MPL/2.0/). You can get the respective\n" "project's sources from the project's official website:\n" "\n" "* certifi: https://github.com/certifi/python-certifi\n" msgstr "" "此 ruyi 发行版包含以 Mozilla Public License 2.0 (https://mozilla.org/MPL/2.0/)\n" "授权的代码。您可以从相应项目的官方网站获取源代码:\n" "\n" "* certifi: https://github.com/certifi/python-certifi\n" #~ msgid "" #~ "\n" #~ " Sysroot provisioning:\n" #~ " By default, Ruyi uses the sysroot bundled with the selected toolchain if one is available.\n" #~ " Use --copy-sysroot-from-pkg to copy the sysroot from another installed toolchain package.\n" #~ " Use --copy-sysroot-from-dir only for a complete sysroot directory readable by the current user; it performs a faithful full-tree copy.\n" #~ " Use --symlink-sysroot-from-dir to point the virtual environment at an existing sysroot directory without copying it.\n" #~ " Use --project-sysroot-from-rootfs for distro rootfs or chroot trees: Ruyi copies common cross-build directories such as include, lib*, usr/include, usr/lib*, usr/share, bin, and sbin, and skips unreadable or unsupported files.\n" #~ " Ruyi never elevates privileges when creating virtual environments. If a rootfs contains private system files such as /etc/shadow, prepare a readable sysroot yourself or use projection mode.\n" #~ " " #~ msgstr "" ruyisdk-ruyi-1f00e2e/resources/release-notes-header-template.md000066400000000000000000000026111520522431500247360ustar00rootroot00000000000000 此处提供的是 `ruyi` 的单文件二进制发行版,适用于短平快的实验或部署场景。这些二进制也可以在 [RuyiSDK 镜像站][release-mirror]下载到。 我们更推荐通过 PyPI 安装 `ruyi`: `pip install ruyi` 或者您选用的 Python 包管理工具的等价命令。 也请您查阅[官方文档站][docs-zh]([English][docs-en])以了解使用方法。 Here are the one-file binary distribution for `ruyi`, suitable for quick experimentation or deployment. The binaries [are also available][release-mirror] at the RuyiSDK mirror site. We recommend installing `ruyi` via PyPI instead: `pip install ruyi` or the equivalent with any Python package manager of your choice. Please also consult [the official documentation site][docs-en] ([中文][docs-zh]) for usage instructions. > [!NOTE] > 对于 `ruyi` 的单文件二进制发行版,您必须将下载的文件重命名为一字不差的 `ruyi` 才能正常使用。 > > For one-file binary distributions of `ruyi`, you will have to rename the downloaded binary to exactly `ruyi` before using. [release-mirror]: @RELEASE_MIRROR_URL@ [docs-en]: https://ruyisdk.org/en/docs/intro/ [docs-zh]: https://ruyisdk.org/docs/intro/ ruyisdk-ruyi-1f00e2e/resources/ruyi-logo-256.png000066400000000000000000000430121520522431500215550ustar00rootroot00000000000000PNG  IHDR\rf pHYs??ddtEXtSoftwarewww.inkscape.org<&tEXtTitleRuyiSDK Logo with white backdropBTAtEXtAuthorInstitute of Software, Chinese Academy of Sciences (ISCAS)EIDATx]wEv߾w}ݷoׄ#0怊9#"bB E s␇0Ý^5sq;}c;u~8}wPJ`N99m?[.v|xD_8:~㿶axwǧɝ)[7tL{`=/_xgT~m:7hߍs,ǻ:Sװi;`=}_r|*,9>Ǝ¾G A*|+}ˎmL̺pf\վhDoO>W]ݴ+W^Y]-|y XoԫoS\e axǿLufEcq5e =ћS?/_兌O}MƮ׿W?Zvwd߻x/~gђUǮc?V^#[DvCՄU"Q ,t<;,Uz[ٶ;8+|~mԳG ;xPWώ3CF/S7:3_pmgSUE8'nuݯBeF`[ȅzIozsW o) 3i협xɯjӯ RcL oDmz?LNyMH}|h+!vX( ?E^F~ɬUU<َ8 լy?գ5dڼuOZy{j5og4⩍N+UY)`vX(tGfюC4YLV 6ճ~NYzG_ `!`ǑB;ʼ_ÜmIt{LP6+/VM`^<.sWv5i{TSmY7~~ q<9̮Lرe m4,]N g=oAjZȫoxY'qck^$4PwH9pex?nhRIG}hǠ|9*Jp7?4y]=Ay>$ e7[iϻXوj[چY9mtE;sl}4^Tp!.W1ThǤ\z )F9nZۚu;Mi"8tuhj[~=jiU2{>ۿ~ޮ[uX=l=H6n-}UȎM Ysn)>L47C_=VKن^ y2a1ЦXuH5ЄsAvlZEYR6:_̷~;2}kϓ f}9>\ x]47A2@@|>ڎQ l!wa .Ҁ^WY۱ڻ}f 1(zs- @Ͽ)}"VP@V8Etq Oݕ?gZR 1/E@~t]rS71XLRfǪlD} qd&/ęGPCZ,/M0(/ )I $;M8ЎY ;hC 0`{8;^Kb^U\* #A˿Sq:pmi!Ҋ:P;9^FLcKm`1=ٺ0)ʶ$!G+O:h?q[_?~A6?ȹNnVuݽ@uwr?bΪL>V5c@& 66S-rn$򪪋{rύ]/8^wC T:jQ2 Q Tێa );%efmtƪ93깩B˪ ]J<8Fx *.ɅF]$JT8`pt?xb[e+nX=3 pZGR ِ PBҎc 8JM8RzlUOpqb= o`Up3ڝ"Ӎ׺H]cDzTF6f ͡#RP5noI5~'vМ{4J! 1༎]yt֫_/>7D4slT7uGh+v,[H'1 J9, 0:~*O91c7'[Ra1fo3^ )UU1h{ XÎ$ DR}݆b'_:tvWhWw&v8W5! ]B0>C̽EH;W&uӧGqg,kXU#d@//ȫT̥lm͋(E]~̤]%qQT3>-ڃݾ"ՔrWOz"a6PzP~AE#QN@ Hǁ8v@*v6,眦m%hTuGw8*:ܰul#x%icEw;C}c͌)Ց*Gai[$u`1 K+WUM^NRE^aXW/ޤ;cu7WK8\\e*Ods @0Y 5vc4,1Kzl_;8倭:0y S*,VGfJ09hy. [(JrM X,dGء-U&(Tu@/0Cكfh* 1HjӖ5Ɉʒj/?w1aK˷ȸ0&XoٚR14ٗH> !F/$@Lנ(*z@{ߚ7 gi7HWA VR$#Ҡ;&3Vr|=Z}KERX椣BG[9]@gϗ֏7@#X!2%XYWXR *@a!vV@<f"X E21XyiFt Υ3??Ȣ-<&سLEtTQgÎV1gpIS[[UF{9թC Yp\l4CkTKʼn_óq~+P:@z~/[Z7͚^2XzZv̸T!ʍ3Gd:8+g_k'=RG(WXH=зˊӭd1s3?/ )c?taQ* z,A~e vb&J:%E=Ozl ϧ ;5@ĉB^ }HL4&A9nƺ0YڂA"t D3bL8-Ҙ1M}=K]nw.~ WFP9#VdTo@en5HAq!OtI6$hiMǷ&aG f?YnO/2Coc g2o@k-0/;1&~%|׀ߦ,He"L tݞ<^5VTT*u`(a͎ /Djw v]~ X=6iwIqhԩHͰ:@cKm;I-߭/<LпOej ؀/X_ۗʮ! f2N{%Mӊ"QyebeGH]zs7OљjV#aj!{w[HIȺ=v1!]9330牴&*QUB ԰ fړ\\䲥lLEL&*1FD9A3ѥ[%b{RI9zf}@*A1և&k6 z1&5ZPЁ쭈mFwK;f?DXp䈔>]-onp@ڍ,Sdb:_qۭ1 W"UeBg= 9B[ OskF]?e1үʭcp30Ke ,oЧ}{ɞhi|wV V|'?c*f@}%hC܁B%,;7RrhI~T},o5c.^rKVp~Vݟ7Tߨs]ٙqs(R 仰C*K8[z#-%,g 'hx@i&dG5^ @ y\;5-?-&n#hN ܚKJ4 ڬ̙D:cċ` VBKx>brq'3isO/h՜ziM~_\` =cR / bsƜՁxl™( @{NljOOx@<zP X]ĵw ᧿xKIs{JɌ-" f)=wu 6_>wPR]@l'R%&L5/|lyDXULU.ķN>*$vAa/~{&?rncPX̻s#2>k.57݋Ud4W5ŭ|elAH`/ f4-4vA_[S~:U 2+-MLV׏ 37  _Q w <1ʂpN~CL8?!t롮l,{?L$4KdװL(,|Ub\qϙa1 s iCveYb[B:lpQspV8T@D *p9 42нl!y0IgILͶ3D}y+6ʆa`T ƃzGB +$6$ItU88EA09αROw 7Zh ?It vRqHҎ/5PRkZTVDM>c9lU,Pw2E#c[x=sh=epҙROl%<{p92l8}#tea 1MgoJH:G~\\Io4[| JJv2^5Ch1RutbMV 3h*\d2v\ ml@LBg*V.K0EJzGj2 U~oiiv5 z=Am\>;4Z!Xd ui8ZGѝ{q!ÔpZ=uyY=5;|0nv0F/#1:ba躳s#pȼ@~oQY|n0PBf^j3D}uJ8f<%HA{hѱ'sƙ_Y'TkPF\P (F-5Sk6L]F3*&4qVs:/&$,x;7T"Fh΁$C6:r> ~R^>E/ =Ǭ9-f~Lv12h &C;`6C"e#%">\|e2 ,} Mx^Zj8]@ΰO׀"4PAf H-K% 4gv74}zK6鎠q&CBY/x vH0Y 89AUVϴNd $Ld v2]Hr,m/(ށh2=\7@"Lً#UEPƽ{Z@&yEu x&S7f :Ђ) r`6X+|u( h搂0k]zv-&5qA.8TAE87I;v ZM0&PMa*rPSh;UJ%[-}e\Y: F$gr%"k2`؋G;}yס+/8NbVt>W,tJ+V ;Y~`K˷N̒_ GD POl~~8ia+)vl~e.!G[@~q@dtGY}4^?Ԁq!/@ԛ}TV J-`$`${,b5樯[Pk7G͋nPgd\FMX4 ]:eS鳏HJbiqn}&ӬR#"8MBzSk f_lT]b4P?53SvnD'J)PWp9njltIΌQ>v5@w[}&/u؅ P[97L8krl_e^cg |NEjt Ag|kd0RM梠~#˸ EKz3Aw  4nUt6$G5i`x&^1 簣`9 O ˨lQ ~=PM^h^s 0U9|F7oݣ~*Q~t|BCMhRq @U)6 o|yel";P6lqV i7À`/1HA\rR tdL6 YXQCĢ#o7DV(@:r:g~0 LP ?ey.I޻\ h1CwF蘐\8!ý4mP(l4:8NhȌ HA (`W$(0T7#O|bvt&1$ Pg&]_|.f>c^@|4,;: hjCS51~K? fAؚ6f&zJ ho4QKT m&rmV`Ro#Z58W9 8VuP˽ Ztf*k8< |i xt)yk/ w%&ۙ.øԬktk3RةAN'0Rך5vУAbY2K,ey.pNϼߎ kC0L*E^؀~ cՇ^ޤL&dᒦʁAn\`< Mƚ .NNZtk>K{ CCoh1JfQz+>6 70 F$ݦQ6pv7M/!]^:uujꕱTXROn7KZ IMף=yL g`[`[3tt@hИD& x%аv u꒡I(J @Rq][#\Z|(G:yYВ qFAeIX7uCvD^Aj&]y;?|#$m#qyʢ,;2.?}_PNw]( VLX~~A?B LV.~ǟM |`Mf$!c`u6"@3>;r8*EM<;ϒvU[y0+;uͿYy3{kk?y\Q^v3,㹁3"VyI - 5yO}OfpyGajWez냟>xۦkPnt{L;R XI֠&Jz?Aҭ&0@8c!F!2i8bL_%B/u6`ނ?B#O$e8|Nڀg/p}X(bSrSf_7vo.tVyܬvP1]ʳ|+s$4][lZRCP5ZJ vd_#B; Fu`wİ(0zAQ| X<Ύ AT0Kn28YqL]83V`M[15ALwPɵn=w45 ؆y yow/!1x7ũT& tm@`fRI^p8&dyn/޴mhȌ!q+5 8ȉmSy`-OB`z~nKdX`p/}a/Q=O!ӛ𣵘E>ޜ}+4`i߇R }묾<ڟ9W =W)7гb<- b.~Dܸ3pܘȅ,<(`*A,iO D Ф4cnd*.~8wlOɥ7wXP Fi:0JԥCBd.TV=$|4%wH@,4' c`Y8)NYO/n_# 0d*񚇞鯵k{uu8;Z|3zB.t HB&Cks߿qxv\"lﯣ?4\g{@*V |W`15aXTK5kwV[96ތ/3Ct_Q:"RPǁ7;` [w|A(up~l pZ񃭘K ։tfY9t+Vt! 5/3=_:N?cr+2j7nڶ#d@F2ttt%~SK:37a'V&>HHb B/0Nĕ*4tNz fcf Tui1+Nid wDLm%<@w L<ךN7\)-tY9ؗ˺ v4X~ٚ:N/7$^m)-tMot&c7?`/3L`l3 ??1癟3ƛܛ|Z R{kc>sTŴ[eF1x JLJ &0$(L25̭}X2f2)8 o}v GIƿGΰGwV0u30+`%fa|*RZql)VJz~ٞh,/ٙآK[gEץDkYcOwx8`e TbtxEzY` %q!3{^~a-!AA TD8vm/w0 >O3֘ fy-gYVfpt3Et@nΞ~eTk}-I%e(]*b cr17sCr "KI۰rlxxf% ; t `ZrJ/Z؄Ѕ74l0c`(Zy u{+s2K@'Y-kYtBk>k񔛋goc. Z3i2[|cWHcQ=>jX)Z㥧~G8[m .h~ptx"ɫJ=dM6dqusldC4}~a#F/K"<^ݗe^w6-R=:`~@͙6: D#PX_i?TW1y/gsu&_TkDFQ%C@% &v-Rp/rc7;Pz*H1F~Gz-# ^OSk;ڎk*FJeZ4 *]w?<)W[^ɽߥ*;>kp\@ɛ>X1ɐt.EXjǖL#RכwDK}]1RԍL%r 8G Is6^ԌࣃPdq<^0bb`['aVs b]YVD`%vr] iil#sbx}[;8z 4,]CE9e]#{ 0=—Bҁ0 Z`?Wg5ώ tKeďTm]#?/tSM~wG'-|nG/$AXjuk*&Uae^(AΔGUo==Nuuwtum҃whԩ)G:[6J[GzyHj[o"1 se@tKE#HK*cƁDJ=w=߻5'f_CУ矃4syBMw+5A}~|>czǁ71Peu~ݙ|VhUWbU)/QP AoA_6/O< %_**8>rL2E]{,r> wW ǵb z0TʄJl#(>铕|M +7ut>t+(%c_/:I4MJ**uv 1}HbeN`?Ԅ"ILAI_?2Uu2xдY6(ĹT 4w7C @W<17FV"L>kZSg[>*80r8ZW2ka}ca铵OSR-U!~ɔ/k8yt7u ֹux]ZE9Md<*ixjthY@>T)L@Uyz-rmx,2+f]U/Jˊ1wHϟbT-H)=aC(p^{ϴch JIq&d\'e o|x-7ekkkcn+^Ϊ]2A#qX̰/DUF6M}̛bAmP@!Z@ _q"H z͈E.ub=힔9asXDC͗b8'+3=C`1 $pVRl?T̟?X{ZYs7J;$W.ӖUu3 6q["FC# Th)qgTlSnğ‹]1VGl B}P_ Px㐉I+L"OlM.hZHePQ*e;[g딤!K]+ xGW=&Z@z`p]=Ql`Bqqr_@ՓnE:+zݩk@Ǭt}AJ-"NAɝb$e&(=h۫:92_:~Fyo`5@89JBvfsZV<~eLG\knך "?A Ѩj=XѹrOyj2t΂3}X4oF:I;r Ǒ-Q9(.kcS]C _TsXWtDf>,BK -Ն u9&)_Y_ D3a/ h&8Sz.jB@n/RUTY.F$>+qT@w>6ihefD•*P^;;@SPüm("R-{_UpJȞH~fǪlDں^U8{-+b?tg{o3ctLBdcTէAMœΟ ⥛@r< BQ!Rͽw&+`%NZ|]Hdn0#(>3U}4` ?>g_մ1F. GT RuIMH)~wYB& ؾT_8n{SVla,`c'-SRا4~;MGn;ak 4 t.e-tb@~q7@m;T*G%'袷ūދf-nw)Y 0ҹ?1j<7yzZLW l>JL-"E`  J5kڡdg9"\I?dM8"E^0Sin@^@׺Q==) @#]w(i[>failY* r:wS_y'^b` .2AvppUi7?׶W[&ij(2JL}%aUom^~o:f#,5 m[0<϶w A C=>{/)-6d@@=m8sYu;H]mD+>FTɬU^6& /I~D?%t9Qmܼ;~ێwHagc '* o @AW9/3D[u=I 2't6UwSFԬk<:p[_{0r:)w'BGf}>7UMRT'jP4*JDG|#4':i#aY8l@`'ʊ>NLL,L9>_Ely>>6pLn4hyG.:3P]?-X;P>j:w<~T6T2-X/뇏Y eC; ]L,X(>HQ|Η-I4qiђӔXPt:EEf<':ewm҃@hC;. D,X3sF鸩^6 gq DM$\]_75[b!0UGM|Hԥ5 u~"ݑ6 V>ŌN~ݺy1T~ u)CB>2eE 20^yG)OxOX- )@liHy/~{/|YBk j )N@kl#i-znSwRWkL{ni[)^fXE4gYk^6$_orblD3Df_ ~vB1{}qbSP]iJbTD }cUWGxlE4}*N^TUl^Xd~[cUeWĞB1obպSD˥`LLl̨ZKCfd[%ճwWGD3ϱ[LѯkOQxҲYJFX%ؿB1ĞD3׾պFD$ݢfXyC1ϰ]Nʦ|piZȢtgDh$eE4xlnaԸz%XIbTB1h[вSDozFedUF5SCĞdVnacUcUcUcUcUcUcUcUcTG7ymG6Ҷͭ[LHe̥l_ѴO?_PȥŠdVÝşŠŠŠŠşĞĞӷviSD|xkPn!VGίi\_PȥŠcU{ohZȥ˩˩˩ϱĿÝVFJ:N>պkY )P@ּxym_PȥŠdV@/~+XI^P^O^OYILLIf̫B1¼ּE4wk_PȥŠeWH8QAͭeWH7C.ռE4׽bTaS`QȥşeWH8uh}ԸRBN>qX3D޿A0aSƢǣbTG7znxֻP@O?ڿ! QJ@պSDP@ͬnaҶRBN=ҵ[LŠȥ]NI8& bF$Šj\F5vgYջD3pdz½I9)FþG6n`{oJ:K:C2}j]t(j\MB0ѳM<_PɦſջzC2xbSxlI8гc. ѴD3ơA0ϰXIK;O>m_պk^XI}qbT5)ذ~rUFȤC2reĝL;eWÜE4Wm`TDymI9J:SDP@F5YJɦB1ӷiwC1~ӶּѴ[LP@¼$S׽eWE5]O}q|naM=K;#ƢwuizmԹU &jH?????????????????(@ @:=FKFtUE<67>HXoKFoO>5/07ARjKFoO>6/17BSkKFoO>6/17BSkKFoO>6./7BSkKFoO>6BE7BSkKFoO>56BSkKFoO>CdVaRDBSkKFoO?hZy#y#dVBSkKFoOȤG6w x x w D3ƠSkKFoNym=+|(4!G7SCSCH74"|(=+wkRjKFv~s16$sfѱҲtg7%0{osKFM=-|pt.J:ĿKFؿ<*A0Ըz?/7AB8/:qֺC29'ֻJM>-I889y?4K;<*RN~WH<*y,o|-i>,TD~NA~~*̪-|<17828m1nέ~*B9ּsQB:({&hZ-DBK;-j];):yŠN>~+9'_QF5~+ٿJZ;h}5k=ý-f*Oz׽RC})WHɦYISC/N`v?3VFVFY+S"/G6ͭгqd}*~cQ6AbQ4!I1"~r|(obĞG7~*-{&Ҳ?Xe|F5ն~+ֺsq*w}){YJ})ZKϰ.3<3//Ȥ)Z*^ğ})vԸ;)<*ȥÝ//5./1==+^ 7%cUּ5#M=ȦF6DJDFH7ƢP7e8 |i[9'B0J9KC;j|ؿ,l^3!ľUFίQ@½SCTDؾ[L̫5m.]ob=,պ~+0uSDѴ}*бή})})ϰ1-?q\9'|pfXA0WHx! u׾~,гgY:(ή})׾3 Cg};ϱ~+ֻ3!yȥ,~+ҵG6<*<+<+<+<+<+<+<+<+<+<+=+4".j]9'1Ȥ5-7ﴄy9'˩~+̫-~+Ѵzzxxxxxxwwxx¼Ȥ})}qϱ/ſ-:6ScUP@u;)̫-~+в~-QAl_K;=RfRhRBcUgYN=̫-~+ѴD3w 9'RCRBRBRBSCXIi[ơm`9'})dVҶ0ÝOhdUgqL;l_]NWH̫-~+ѴB0v =,eWeXeWeXeW_PN>7%~,?.fY}qԹ¼>-bTX^nNplN>j\_PUE̫-~+в׽̫fX/B1ǣ5#ZKUblOm]YJ\MpcF5̫-~+ѴqdgYgYeWeWeWeWfXob}Ȧơym9'v z%hZnawGs^Z^DqdF52 ̫-~+Ҵ]ORBRBRBRBRBRCQAI98%~*8%ZK`RZK\M-dV4HrF&0׾~*ʨ̫-~+вԸob1A0˪Ý~+ym,3( ,ơD3eW̫-~+ѴvwkvjvjvjvjvjxkxɧýQA0ȥ~})ɧeG. L<^O.½̫-~+ҵ.3!D3D3D3D3D3C2;),6#t`Q0պXID3/`M:-þ=+^P̫-~+ҵ1ýxl-QAK;I9ֻ~,_@.>=,eWğ|(̫-~+ҵ0ŠѴ8%UEԸ~+YJP@*e? J񿖍})ȥsf~+Ü̫-~+ҵ0Šί~+_QI9›.,v+O]N9'fX}*~̫-~+ҵ0Ši[@/Ǥ,.ơ:^ )@/I8vj})XIؿ̫-~+ҵ0Šή~+ؾ/ơ<*wIB.Oؿ:(G7ɦA0L<̬-~+ҵ0Š0ơ7%I8reW1H[WfC27$şή-~,г1›2Ü9'}J:pc|!%!\ofaS})\Mպչ~+׾/ơ:(v׾~+в2 A0{on!&"PoMğD3})}q/š>,ym]OM=y4!Ը~+Ը2 5",V%ğ̬B1sfhZE4ɧ})Ƣ:(l^zn9'в~+պ-Epc@/ί})ɦ]N7%׽[L6$9'sfsf>-3yҵ~*ȤUFD3J95"ӷO?0˪sf17%ym>UEK:1dVeW~*@.i[}qsfM=~+G7Ӷ})~r0. +ҵ~,xŠ~+aRԸreL;?.E5bSğ}*obԸ.~rjw~r~+Ƣǣ4!>,ҵWH})xkA0N=cU.Š`Q~+:(dV{qdF5}*G6ɧG6=,=)fX}*|p|pN>9'4"6$E4j\ʨƢ8&B1½^2z.B1ϱZK})_Qf&ؿ`R-6$_P}ǣͭʨm`A0}*H8ŠQnſyRC8&0./4"H7qdί("w=E}Y#?????( HM{zzz+$i8$AQ$2y$%($r5$II$5q$(z+$i8$AR$2y$%($r5$JI$5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y$&($r5%JI%5q$(z+$i8%AR$3y"Ya"q5%JI%5q$(z+$i8%AR$3yX`r5%JI%5q$(z+$i8%AR$3˩ǣ5%JI%5q$(z+$i8%AR$1<+8%ؿ4$JI%5q$(z+$i8%AO&FM=x" x! H7I&GI%5q$(z+$i8%AQAx" y#y#x"I9I%5q$(z+$i8#AϯC1x" y#z$z$y#x" >-˪H#5q$(z+$i7@}q/x" y#z$z$z$z$y#x"-wkD4q$(z+$iC1y#y#z$z$z$z$z$z$z$z$y#y">,{q$(z+$iWH2y#x" y#y"x! x! x x x x x! x! y"y#x"y#2WGq$(z+$hx@/z$x! x! x x! ,7%I8_Pj\rerej\_QJ98&-x"x x! x! z$<+~ro$(z**=,y"x! x! y#6#`QԹպaS7%y#x! x! y":)~,'z0ֻQBz$x! x! {'N>QA|(x! x! z$N>Ѵ/z4!x! x" z$P@ƢɧSD{&x" x! 0~zsf{'x"x! 8&lN<7446:Id<*x! y"{&l^zcUy#y"z$XHW3&$%%%%%%%%$%/I\Mz$y"y"\MzaRx"y"|(wkC'%%$'1:DMNF<4)$%%%9x}q})y"x" ZLzj]y#y"~+}M'%%)?lvG,%&%?,y"x"cUvz$y"})0%%*Q`/%%*i~+y"z$zm0]ʨ-y"{&sh(&&CU(&%P|{'y"}*ş`04mA0x! x"fX]&&*jgOCAJ^}1&%Fl^y"x! =+m4,sthx"x! F5d&&/O/%$$%%%$$*@y:&%KK:x! x! j]p("˪m`=+})x x x! y"y#y"3!E%&rW&%/j<%%=+&26$x" }*ί8=m$ \D4|(x x! x! w z%.{&y#x! pb{&%LS%&Fb)%9j%%Ywjx" x! fXf ")F] u;)y#x! x! x" .K;znǣÜ0x".׽9&.c%&Uy*%B<&+Ŀ1x! 5"4###$<6C1y"x! x! {&E4yؿ›z$x! UF~%%a(%Os'%_&%Z[Lx! y#I)##$C6FaR{&x! x! |'TDƢdVx y#D%.8%8T%*=%2z$x _QV)##(aHUơ=+x! x"y#K;Ƣ4"x" /þ+%Pq%'1%Kr%%2x! :(L%$#>VRz~,x! x! 2vWH4!z%y#x" G6x$%8%Be$),%TM%8rew x" iwL$$'qK7k^y#y"z%fXw7%x! x! x! z$4!0y#w {9%>c$-=%?]$-x w {or 8g'$&l7m`z$y"|'xl˪F5y"x" x! })QAdVx! z$ğ0%TC%:Z$1z$%ɦ{&x eW $$Iy)$%o }rz$y"|(w|p,x! x! |'VG̫^Ox })ͮ+$j7$Hr$)$$ҵ~+x WHX)##.)$' a}*y"{&|p`Rz$y"x"@/œUFx ~+Ӷ($u4$T$%&$z׾.x P@B$$%b~($+a)׽4"x" y"m`SCx! y"{&dVQAx .׽%${2$X$$($s0x J9g($#To$$8*TDx! x! SCP@x! y"~+zO?w -׾$#|1#W#"'#q0w H7.$#RU##SDyz$x" 9'þ[Lx! x"/_P3 ?.þ76C6f66:6~B13 XI1$#]:$%C 5#x"{&rey"y"-/$$t($7 Lk]x! x! [M})y"|(*$*[##kKպ.x".Ը;)x! y"qdfXuiίVGUFğ[L\MвԸ^O[LgZ\Mymq$#=1$15l_x! x! k]j]x! x! K;n`y#x w 9'׾v v wkznv v ĝx! v n`.v F6L##km##l54"x! /׾ѳ,y"-ίؿ.y"z$y#w nax! w xl|pw x! Ğz$w pc1x H8.$.1#6{x! x! ^P_Px! x! l_ή}(y#z$z$x! \Mx! w xlobw z$Ğx"w rf1x H8m##`b#$@N>x! |'ȥպ-x" 2 ľ:(x! z$y#x! zx! w xlA0x! ,պxw w }q1x H84$/(#M?~ֽ-x! >-tgx! x! l_Ý0y#y"XIx! w xl[Ly#x! L<[Mx! y"1x L;m##uA#0~ x! x na=,x! ~+ѳeWx z%Šx! w xlպF5A/A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0>,/y"x" ~,ɧ3 x" -׽ؿ.x UF-#@n"# `Rx {%ơ˪|(x! K;k^w {&Ǥx! w xlή{&w x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x! x 1vjy"x! L<ͭ})w j\L#)'"` E>,x! 2xkx x! }rk^w {%Ǥx! w xlҵ9'3!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!4!5#@.k^ջή/y"z$x! x ~""z4#@Diؿ.x! G6PAx! })ήk^w {%Ǥx! w xl׽?-x"x! K:aRw }*ѳ*#QG#1hƢz%x dV7%x! 6#k^w {%Ǥx! w xlǤ<*x"y"0ϱ5#x C28#9f"(w w |pּ-x! J:k^w {%Ǥx! w xlj];)C2ϰdV~+x"x"~+sy"x! sK#/}"# znw x! ɦ{&x aSk^w {%Ǥx! w xl^Ow x"x! |'^Ouiththththththththvj~r|ίպsfj]]N@/}*x! y"x! 4!ý5#x! 7%c"(#"} m`w z#Ğx! w tgk^w {%Ǥx! w xl0y"z$z$y#x! w w w w w w w w w w w w x! })2E4m`ήaS/w x! x |(XIֽZKx! y#vu"#&"rfXx {&ɦw w ~rk^w {%Ǥx! w xlĿ/y"z$z$y#y"y#y#y#y#y#y#y#y#y#y#x! w w x x! x! w z%:(k^Ƣήpc@/dVʩqdy#x! O?"!("kcUw |(̫~w w wk^w {%Ǥx! w xlWHw y"x" z%naĞÜÜÜÜÜÜÜܛxl^OA0/y"x! x! x ~*YJğl_{%x! 9'׾""*"fhZw {'ʨw w tk^w {%Ǥx! w xl_P6$<+u¼[K1x" x! x! }*dVҶPAy#x! 4"̫~"")"gnaw z$Šx w vik^w {%Ǥx! w xlu?.{%y#x! 6#l^/x! x! <*ήu"#'"n ymw x! ǣ{%x eVk^w {%Ǥx! w xlŠÝĞĞÝÜÜÜÜÜÜÜÜÜÜğɦввşÝob0y#y#y"{'k^ջeWy"z$XHd"(#"{ w w tԸ,x! M¼ʨ0y"y"uhO#((#Z" {x! x uh:(x! -ջk^w {%Ǥx! w xlֻԹԹԹԹԹԹԹԹԹԹԹԹԹԹּm`}*x"x! =+Ѵ})y"}*Ğ.#>v"# ~Ӷ~+x! C2l^x! x! sfk^w {%Ǥx! w xlԹ4"~,,,,,,,,,,,,,,.17$K:tg˪Ý8&x" x! 9'ӷsfy"x! @/r#"pE#-AH7x! |(̬в~*x" 6$k^w {%Ǥx! w xlӶ~+y"y"x! x x x x x x x x x x x x x! x! x! x |(F5պA0x"x! @/I9x! x"uh6$-*#HHtx! x! hZWHx! x"thk^w {%Ǥx! w xlӷ~,y"/O?RCRCRCRCRCRCRCRCRCRCRCQAH78&~+x! x! x! z$QAԹ?.x" x! WH̫~+x"1s$#\i##¼0x! 2˪})y"0Ըk^w {%Ǥx! w xlӷ~,x TEҶuD3z%x" x! 3!ѳ3 y"y#wbTx! x! ob1$,3$24cUx! x! thaSx! x! RCk^w {%Ǥx! w xlӷ~,x XI:(x" x" ~+{&x"4!¼ջ.x! 4"S##fr##d>ϱ~+x"2 þ6$x"z#|pk^w {%Ǥx! w xlӷ~,x XITEy"y",ơ\Mx! x! h[^Ox! x"z%$84$- LbSx! x! dV{&y"~+šk^w {%Ǥx! w xlӷ~,x XI]Ny"x! :(ջ/x" 0ؿǣ{&x! N>,$)a##aU ֻ0x"|(ĞgYx" y"2ʩk^w {%Ǥx! w xlӷ~,x XIM=x" x! bTgZx! x! ym>,x! /3$$j*$2H{oy"x! ?-P@x! x"4"ɦk^w {%Ǥx! w xlӷ~,x XIԸ1x"~*ίб}*x! B1hZx y"S"#U?$${RI9x! x! ]NF5x! x"0k^w {%Ǥx! w xlӷ~,x XIvjx" x! bTH7x! }*ѳy#w j\r*N\##K +ѳ1y"z$xlH7x! y"})sfk^w {%Ǥx! w xlӷ~,x XI2x! 4"vjw x! |/x! J:x&$25f{&y"|(TEy"y"y#J:̫k^w {%Ǥx! w xlӷ~,x XI\Nx! z$Ýơz%x dV9'x! 8&)$(q qdy#y"~+na|(x! x! ~+dVk^w {%Ǥx! w xlӷ~,x XIx! w wkؿ.x! J:J:x 0Ŀ-$%qaRy"y"~+}=+x! x"w K;k^w {%Ǥx! w xlӷ~,x XIͬ|(x ]N6$x! :([Lx ,ԸA,*4l}#b)8^Py"y"|'sgpc/v F6k]w {%Ǥx! w ymӷ~,x XIؿ.x O?:(x! 7$dVw })ί)#%%$#YbuFJcUz$y"y#VGּl_w {%ǣx! w xlԹ,x VGĿ0x K:<*x! 5#fXw |(̫:$&&&&%%[Xwj|(x"x! 8&tgw y#ÜÝy#w qe/x L<ֻ-x RB9'x! 7%_Px }*б&%&&&&&#bgT5"x! x" {%WGίsw w ʨ|'x cU4"x! ;)Ȥ{&x dV4"x! <+UFx -պ#%&&&&&#WfLֻQBz$x" x! ~+bTϱw w v׾.x! K;E5x! -ּsx w vԸ,x! P@D3x! 1*%&&&&&"pZ4~7%x! x"x! }*XIǤ{&x fX8&x! 6$m`w y"}N=x! |(̫y#w l^5#x! ;)P#%&&&$.Erf2 x! x! v t/x! I8TEx! |'˩ǣ{&x! I9в~+x! >,k]x y"ҵ~+x! QA@"#$",( Zſxl<*7%˪<+x! 2ux! x! reC2x! {&]Nx! x! wj>-x! .ؿx! w tgwJCaq'aSx z$œԸ,x! =+y#x" ?-|(x"4"Š{&x! M=_Px z%ş;=y"x eWO?x! z$J:x! x" [LԸ7%x"y"sUFx! y"6$x! 3!1 >2x! 5#z$x! D3ϱ0x"y#dVԹ?.x" x! QAȥ})x! 9'y#x! [Lb+o]Ox y#~G6x! y#|p})y"y"SDš7%x"x! >,ýI8x! x"}qP@x! z$@ 3hơ{&x! B1ş|(y".̬|}*x" x" 5"~rѴ_Q|(x"x! >,ӷtgy#x! A0ȥ|(x! >-yAZL,SDbSk]nagY^OK;8&.y"x x! x! y#:)|p"׾}rE4~*x! x x! x! x w w w w x x! x! x! w y#1SDb ?ҵ}^OA/4!-}){&z%|'}*/7%I9m_& MչήɦǢ˩б4 z$z$z$z$z$z$A0+(((((((.~]E6,(0;IgR((((((((`L;z$z$z$z$z$z$G6x9eĝz$z$z$z$z$z$})+(((((((Ff2(((((((((((((((@z((((((((`.z$z$z$z$z$z$xen1z$z$z$z$z$z$ơ/(((((((nn.(((((((((((((((((((((=2(((((((qҴz$z$z$z$z$z$|'oSnaz$z$z$z$z$z$eW4(((((((X((((((((((((((((((((((((((-|9(((((((znz$z$z$z$z$z$WG@wſz$z$z$z$z$z$4"B(((((()^((((((((((((((((((((((((((((((-B(((((((?-z$z$z$z$z$z$ϰwӶA0z$z$z$z$z$z$˩i(((((((,(((((((((((((((((((((((((((((((((@B((((((0պz$z$z$z$z$z$9'ſhZA0{%z$z$z$z$z$z$z$z$z$UE(((((((Q(((((((((((+U{qD((((((((((()8((((((JdVz$z$z$z$z$z$ŠQA|'z$z$z$z$z$z$z$z$z$z$z$z$z$|',(((((({2(((((((((;j.(((((((((U0((((((y,z$z$z$z$z$;)-Qo}q4"z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$|pV((((((W*((((((((^A((((((((B(((((()z$z$z$z$z$z$Ȥs&&&7y3>,z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$|'((((((7((((((((Z;(((((((6|((((((K-z$z$z$z$z$>,'&&&&&=9ԸSDz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$uh6((((()(((((((9)((((((7N((((((|z$z$z$z$z$z$ּc&&&&&&&&UQήG6z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z${&((((((v(((((((b;((((((>+(((((2,z$z$z$z$z$eW&&&&&&&&&&BN8&z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$5#dVѳ{&z$z$z$z$z$l_0(((((5+((((((P((((((O((((((~rz$z$z$z$z$|'=&&&&&&&&&&&:в<*z$z$z$z$z$z$z$z$z$z$z$z$z$-i[̬ʨz$z$z$z$z$z$ſ((((((6((((((Y((((((w;(((((5z$z$z$z$z$z$&&&&&&&&&&&&&=#OVFz$z$z$z$z$z$z$z$z$z$z$z$5#wkֻ\Mz$z$z$z$z$<*1(((((KU((((((P((((((((((((L;z$z$z$z$z$I8}2&&&&&&&&&&&&SVvj{&z$z$z$z$z$z$z$z$z$z$z$ZKҴ{&z$z$z$z$z$((((((((((((?(((((9?(((((;Üz$z$z$z$z$z$d'&&&&&&&&&&'tý:(z$z$z$z$z$z$z$z$z$z$0|p̬z$z$z$z$z$z$K(((((E0(((((Y,(((((v((((((|'z$z$z$z$z$ơ/&&&&&&&&&&: ~r{%z$z$z$z$z$z$z$z$z$0pbz$z$z$z$z$M=((((((r(((((3((((()9(((((q^Pz$z$z$z$z$^P5&&&&&&&&&'~F5z$z$z$z$z$z$z$z$z$-~4"z$z$z$z$z$(((((/*(((((J(((((g(((((/›z$z$z$z$z$})-&&&&&&&&&D&Ȥ}(z$z$z$z$z$z$z$z${%znǣfX.z$z$z$z$z$z$;(((((ro(((((N((((((((((((z$z$z$z$z$z$w(&&&&&&&&,%vz$z$z$z$z$z$z$z$z$F5B1z$z$z$z$z$z$z$z$z$}(((((((+(((((U(((((xJ(((((x3 z$z$z$z$z$›J&&&&&&&&&} cTz$z$z$z$z$z$z$z$~*ʨWG{%z$z$z$z$z$z$z$z$z$z$z$WG(((((5(((((H(((((0(((((@i[z$z$z$z$z$l_*&&&&&&&&aJ9z$z$z$z$z$z$z$z$K:3!z$z$z$z$z$z$z$z$z$z$z$z$z$z$f(((((iE(((((;(((((((((((şz$z$z$z$z$=+E8M&&&&&&&&F 9&z$z$z$z$z$z$z$z$vjx-z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$ϰ3((((((((((+(((((j0(((((ýz$z$z$z$z$z$q&&+}|&&&&&&&&@ :(z$z$z$z$z$z$z$~+ŠŠ/z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$(((((((((((Y(((((3`(((((z$z$z$z$z$z$&&&&1-&&&&&&&>:(z$z$z$z$z$z$z$?.ҵ?-z$z$z$z$z$z$z$z$z$z$z$z${%>,z$z$z$z$z$z$(((((.f(((((/((((((((((a,z$z$z$z$z$Թ&&&&&&=@&&&&&&&:P<*z$z$z$z$z$z$z$K:`Rz$z$z$z$z$z$z$z$z$z$z${%UEĞ˪z$z$z$z$z$9'(((((L<(((((\((((((((((@L;z$z$z$z$z$ĝ&&&&&&&&iK&&&&&&&9V'=+z$z$z$z$z$z$z$YJѳ3 z$z$z$z$z$z$z$z$z$z$/tz$z$z$z$z$XI|(((((o(((((((((((((((()k^z$z$z$z$z$u/&&&&&&&&2[&&&&&&&E+ O?z$z$z$z$z$z$z$j\pbz$z$z$z$z$z$z$z$z$z$:(şvz$z$z$z$z$hZh((((((((((3(((((\(((((({oz$z$z$z$z$pbW&&&&&&&&&&yl&&&&&&&Vnaz$z$z$z$z$z$z$l_G6z$z$z$z$z$z$z$z$z$3!qdz$z$z$z$z$sf[((((((((((N(((((I,((((({z$z$z$z$z$fX4&&&&&&&&&Hh&&&&&&&i[z$z$z$z$z$z$z$`Rľ3 z$z$z$z$z$z$z$z$|'{j\z$z$z$z$z$}qO((((((((((Z(((((=6(((((z$z$z$z$z$]N)&&&&&&&&4a&&&&&&&XԸ{%z$z$z$z$z$z$UEȤ|'z$z$z$z$z$z$z$z$O?cTz$z$z$z$z$~B((((((((((d(((((1A(((((Üz$z$z$z$z$SDT&&&&&&&&)[&&&&&&'-z$z$z$z$z$z$K:z$z$z$z$z$z$z$z$}(]Nz$z$z$z$z$8((((((((((i(((((*M(((((ʧz$z$z$z$z$J9)&&&&&&&&J&&&&&&6NXIz$z$z$z$z$z$3!z$z$z$z$z$z$z$z$=+ZKz$z$z$z$z$6((((((((((i(((((*Q(((((̬z$z$z$z$z$F5D&&&&&&&&7&&&&&&URz$z$z$z$z$z$|'ſz$z$z$z$z$z$z$z$eWZKz$z$z$z$z$6((((((((((i(((((*Q(((((̬z$z$z$z$z$F5b&&&&&&&&)&&&&&&~+z$z$z$z$z$z$›z$z$z$z$z$z$z$z$wZKz$z$z$z$z$6((((((((((i(((((*Q(((((̬z$z$z$z$z$F5&&&&&&&&&&&&&&)!L;z$z$z$z$z$z$fXӶ{&z$z$z$z$z$z${%ZKz$z$z$z$z$6((((((((((i(((((*Q(((((̬z$z$z$z$z$F5'&&&&&&(j&&&&&&Q"z$z$z$z$z$z$<*~+z$z$z$z$z$z$|'ѳZKz$z$z$z$z$6((((((((((i(((((*Q(((((̬z$z$z$z$z$F5)&&&&&&-:&&&&&&/-z$z$z$z$z$z$׾4"z$z$z$z$z$z${&ϰ)&&&&&&8&&&&&&11wkz$z$z$z$z$z$qdXHz$z$z$z$z$z${%ɧ'&&&&&&Yy&&&&&&mB{&z$z$z$z$z$6$z$z$z$z$z$z$z$›&&&&&&&8&&&&&&?]Nz$z$z$z$z$z$вľ{&z$z$z$z$z$z$viz&&&&&&'&&&&&&c%ؿz$z$z$z$z$z$RC=+z$z$z$z$z$z$VFzvjm`m`m`m`m`|p|p|p|p|py|p|p|p|p|p|p|p|p|p|pT&&&&&&@Q&&&&&&&YJz$z$z$z$z$z${z$z$z$z$z$z$6$RCz$z$z$z$z$XH2z$z$z$z$z$z$z$z$z$z$3 F5z$z$z$z$z$˩̬z$z$z$z$z$E58&&&&&&'&&&&&_ z$z$z$z$z$z$`R}(z$z$z$z$z$z$ӷ0z$z$z$z$z$z$z$4"2z$z$z$z$z$z$z$z$z$z$3 F5z$z$z$z$z$˩̬z$z$z$z$z$E5&&&&&&)j&&&&&&opcz$z$z$z$z${%SDz$z$z$z$z$z$znE4z$z$z$z$z$z$z$z$z$N>2z$z$z$z$z$z$z$z$z$z$3 F5z$z$z$z$z$˩̬z$z$z$z$z$E5y&&&&&&V'&&&&&ll}(z$z$z$z$z$k^ҵz$z$z$z$z$z$?-Ҵz$z$z$z$z$z$z$z$z$z$z$ؿ2z$z$z$z$z$z$z$z$z$z$3 F5z$z$z$z$z$˩̬z$z$z$z$z$E5@&&&&&&b&&&&&)7~z$z$z$z$z$z$G6z$z$z$z$z$z$Ըxlz$z$z$z$z$z$z$z$z$z$z$w2z$z$z$z$z$z$z$z$z$z$3 E5z$z$z$z$z$̫̬z$z$z$z$z$E5&&&&&&G&&&&&&53 z$z$z$z$z$M=бz$z$z$z$z$z$^ObSz$z$z$z$z$z$z$z$z$z$z$k^2z$z$z$z$z$ؿz$z$z$z$z$:(=+z$z$z$z$z$̬ͭz$z$z$z$z$E5_&&&&&&W&&&&&<׽z$z$z$z$z$z$˪D3z$z$z$z$z$|'viz$z$z$z$z$z$z$z$z$z$z$~r2z$z$z$z$z$Ğz$z$z$z$z$P@1z$z$z$z$z$Ҵ̬z$z$z$z$z$E5)&&&&&I&&&&&&3sfz$z$z$z$z$3 Ըz$z$z$z$z$z$viơz$z$z$z$z$z$z$z$z$z$z$̬2z$z$z$z$z$SDz$z$z$z$z$j]z$z$z$z$z$z$ؿ̬z$z$z$z$z$E5s&&&&&&4&&&&&k0/z$z$z$z$z${_Qz$z$z$z$z$|'0z$z$z$z$z$z$z$z$z$4"2z$z$z$z$z$вz$z$z$z$z$z$ɦz$z$z$z$z$z$̬z$z$z$z$z$E5)&&&&&`}&&&&&/Թz$z$z$z$z$z${&z$z$z$z$z$obѳ{%z$z$z$z$z$z$z${%ּ2z$z$z$z$z$-z$z$z$z$z${&z$z$z$z$z${%̬z$z$z$z$z$I8r&&&&&&&&&&&&&naz$z$z$z$z$<*}z$z$z$z$z$z$ͭ.z$z$z$z$z$-Ӷ2z$z$z$z$z$wk|'z$z$z$z$z$z$reXIz$z$z$z$z$C2˪z$z$z$z$z$RB&&&&&&E&&&&&x$h/z$z$z$z$z$w7%z$z$z$z$z$P@›z$z$z$z$z$TD2z$z$z$z$z${o3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 {&z$z$z$z$z$z$z$z$~*{%z$z$z$z$z$tgơz$z$z$z$z$[LQ&&&&&@&&&&&6dz$z$z$z$z$z$z$z$z$z$z$z$ȥĞz$z$z$z$z$TD2z$z$z$z$z$qdz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$˩z$z$z$z$z$z$бz$z$z$z$z$qd&&&&&&&&&&&&Ğz$z$z$z$z$6${z$z$z$z$z$-Ğz$z$z$z$z$TD2z$z$z$z$z$qdz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z${/z$z$z$z$z$})}qz$z$z$z$z$~,&&&&&1&&&&&i[z$z$z$z$z$i[A0z$z$z$z$z$k^Ğz$z$z$z$z$TD2z$z$z$z$z$qdz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$|'ĝ}qz$z$z$z$z$z$na`Rz$z$z$z$z$˪h&&&&&Bk&&&&&iR6$z$z$z$z$z$›z$z$z$z$z$z$ͭĞz$z$z$z$z$TD2z$z$z$z$z$qdz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$TD{&z$z$z$z$z$z$6$z$z$z$z$z$&&&&&&&&&&&9Oz$z$z$z$z$z$ơz$z$z$z$z${&Ğz$z$z$z$z$TD2z$z$z$z$z$qdz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$6$wkؿ;)z$z$z$z$z$z$XHz$z$z$z$z$~+(&&&&&&&&&&&̬z$z$z$z$z$~+rez$z$z$z$z$UEĞz$z$z$z$z$TD2z$z$z$z$z$ſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſQAz$z$z$z$z$z${%ɦz$z$z$z$z$\MQ&&&&&m*&&&&&{z$z$z$z$z$XHB1z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$XHz$z$z$z$z$z$z$tgdUz$z$z$z$z$&&&&&BQ&&&&&aSz$z$z$z$z$}qz$z$z$z$z$z$ԹĞz$z$z$z$z$TD2z$z$z$z$z$I8z$z$z$z$z$z$z$:(|'z$z$z$z$z$&&&&&'~&&&&&j%?-z$z$z$z$z$›ؿz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$ɧ.z$z$z$z$z$z$z$|'z$z$z$z$z$M=&&&&&&&&&&&N"C|'z$z$z$z$z$Թơz$z$z$z$z$9'Ğz$z$z$z$z$TD2z$z$z$z$z$^P|'z$z$E4ĞRCz$z$z$z$z$z$z$z$z$ɧ9'z$z$z$z$z$ǣ6&&&&&&&&&&2?`z$z$z$z$z$z$yz$z$z$z$z$\MĞz$z$z$z$z$TD2z$z$z$z$z${%z$z$z$z$z$z$VFĞL;z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$/^&&&&&&&&&&&]zz$z$z$z$z$z$hZz$z$z$z$z$wkĞz$z$z$z$z$TD2z$z$z$z$z$˩z$z$z$z$z$z$z$z$z$\MӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶ׽ήʧʧʧʧʧʧʧơm`B1z$z$z$z$z$z$z$z$z$z$z${%-z$z$z$z$z$|&&&&&k&&&&&&xֻz$z$z$z$z$4"K:z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$5#z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z${%?-`RvȤſ¼z@/z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$-Ӷl_z$z$z$z$z$.&&&&&Q4&&&&&̬z$z$z$z$z$?.4"z$z$z$z$z$ήĞz$z$z$z$z$TD2z$z$z$z$z$չz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$|'P@~v8&z$z$z$z$z$z$z$z$z$z$z$z$z$z$RBӷz$z$z$z$z$z$&&&&&8E&&&&&›z$z$z$z$z$J9~+z$z$z$z$z$ؿĞz$z$z$z$z$TD2z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$}(\M̫ҴWGz$z$z$z$z$z$z$z$z$z$3 Š-z$z$z$z$z$A0&&&&&*N&&&&&z$z$z$z$z$TDz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$,pcؿpc{&z$z$z$z$z$3!=+z$z$z$z$z${&&&&&&&W&&&&&z$z$z$z$z$^Oz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$вz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$<*Šm`{&-eWѳO?z$z$z$z$z$z$y&&&&&&_&&&&&z$z$z$z$z$hZz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$-z$z$z$z$z$z$z$z$z$z$;)A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0>,3 .z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$7%B1z$z$z$z$z$z$UE&&&&&&h&&&&&z$z$z$z$z$j\z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$z$z$z$z$z$z$z$z$z$SDѳk^D3z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$3 6$z$z$z$z$z$z$;)&&&&&&l&&&&&z$z$z$z$z$bSz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$sfz$z$z$z$z$z$z$F5ּ|E5z$z$z$z$z$z$z$z$z$z$z$z$z$D3ý}(z$z$z$z$z$z$2&&&&&&f&&&&&z$z$z$z$z$YJz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$ʨ@/z$z$z$/z›K:z$z$z$z$z$z$z$z$z$z$z$z$m`G6z$z$z$z$z$z$z$1&&&&&(`&&&&&Ǣz$z$z$z$z$QA{%z$z$z$z$z$ſĞz$z$z$z$z$TD2z$z$z$z$z$v/z$z$z$z$z$z$z$z$z$z$.̬k^{&z$z$z$z$z$z$z$4"&&&&&0X&&&&&̫z$z$z$z$z$H71z$z$z$z$z$бĞz$z$z$z$z$TD2z$z$z$z$z$›<*z$z$z$z$z$z$z$z$z$z$uhYJz$z$z$z$z$z$z$z$z$E4&&&&&;G&&&&&Ӷz$z$z$z$z$6$H7z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$Š9&z$z$z$z$z$z$z$z$z$G6չ-z$z$z$z$z$z$z$hZ&&&&&G8&&&&&yz$z$z$z$z${%_Qz$z$z$z$z$wĞz$z$z$z$z$TD2z$z$z$z$z$A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0L;VF`Rm`uǣľӶwkE4A0A0A0A0A0A0A0A0A09'z$z$z$z$z$z$z$z$z$z$-в9'z$z$z$z$-Ǣz&&&&&c)&&&&&t\z$z$z$z$z$z$xlz$z$z$z$z$dVĞz$z$z$z$z$TD2z$z$z$z$z$|z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$0eWĞήYJ{%z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z${%Ü@/z$z$dVY&&&&&&&&&&&\9}(z$z$z$z$z$ſz$z$z$z$z$=+Ğz$z$z$z$z$TD2z$z$z$z$z$|z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$7%v3 z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$}l_ſ9&&&&&&&&&&)C?.z$z$z$z$z$˪׽z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$|z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$F55#z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$uh&&&&&&&&&&&@)ZKz$z$z$z$z$xz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$|z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$0-z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$na&&&&&&&&&&&]uz$z$z$z$z$ZK2z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$|z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$-~k^z$z$z$z$z$z$.N>})z$z$z$z$z$z$z$t&&&&&7_&&&&&̫z$z$z$z$z$0`Rz$z$z$z$z$]NĞz$z$z$z$z$TD2z$z$z$z$z$ӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶӶвʧwbS@/{&z$z$z$z$z$z$z$z$z$z$z$z$z$z$;)ҵӶԹſ,z$z$z$z$z$z$z$U&&&&&m7&&&&&z$z$z$z$z$z$z$z$z$z$z$~+Ğz$z$z$z$z$TD2z$z$z$z$z$`R-z$z$z$z$z$z$z$z$z$z$z$z$^P/z$z$z$z$z$z${%ʨ)&&&&&&&&&&&I~*z$z$z$z$z$ϰz$z$z$z$z$z$ؿĞz$z$z$z$z$TD2z$z$z$z$z$ԹhZ})z$z$z$z$z$z$z$z$z$z$3 ֻ3!z$z$z$z$z$z$|'&&&&&&&&&&&,ZRBz$z$z$z$z$tg2z$z$z$z$z$vjĞz$z$z$z$z$TD2z$z$z$z$z$›9&z$z$z$z$z$z$z$z$z${%z1z$z$z$z$z$z$:(s&&&&&9z&&&&&W!z$z$z$z$z$9&qdz$z$z$z$z$2Ğz$z$z$z$z$TD2z$z$z$z$z$ǣ=+z$z$z$z$z$z$z$z$z$YJý|'z$z$z$z$z$z$aS/&&&&&t>&&&&&z$z$z$z$z$z$պz$z$z$z$z$z$ּĞz$z$z$z$z$TD2z$z$z$z$z$~+z$z$z$z$z$z$z$z$I8˪z$z$z$z$z$z$z$&&&&&&&&&&&&h-z$z$z$z$z$/z$z$z$z$z$bSĞz$z$z$z$z$TD2z$z$z$z$z$׽ȥί]Nz$z$z$z$z$z$z$z$?.}z$z$z$z$z$z$|'b&&&&&9&&&&&.s&dUz$z$z$z$z$P@sfz$z$z$z$z${&Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$1_Q|'z$z$z$z$z$z$z$;)cTz$z$z$z$z$z$N>(&&&&&Q&&&&&c/ơz$z$z$z$z$z$z$z$z$z$z$z$zĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$=+;)z$z$z$z$z$z$z$F54"z$z$z$z$z$z$ơz&&&&&&&&&&&&|'z$z$z$z$z$K:z$z$z$z$z$-Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$WGM=z$z$z$z$z$z$z$RCչz$z$z$z$z$z$0+&&&&&K&&&&&)6^Pz$z$z$z$z$B1Ȥz$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$^PXIz$z$z$z$z$z$z$pbqdz$z$z$z$z$z$re&&&&&&D&&&&&\Kͭz$z$z$z$z$z$6$z$z$z$z$z$.Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$}(^Pz$z$z$z$z$z$z$ɧ:(z$z$z$z$z${%/&&&&&A&&&&&& -z$z$z$z$z$eWz$z$z$z$z$z$wkĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$8&^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^P^PYJO?:(z$z$z$z$z$z$z$z$z$z$z$z$z$z$I8P@z$z$z$z$z$z$~+ͭz$z$z$z$z$z$l_p&&&&&&f&&&&&/;rez$z$z$z$z${%5#z$z$z$z$z${%Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naşfX-z$z$z$z$z$z$z$z$z$z$-вC2z$z$z$z$z$z$E5J9z$z$z$z$z${&'&&&&&9'&&&&&yDz$z$z$z$z$z$|p›z$z$z$z$z$z$P@Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naί[Lz$z$z$z$z$z$z$z$z${%-z$z$z$z$z$z$ؿz$z$z$z$z$z$sfK&&&&&&p&&&&&&kXIz$z$z$z$z$,D3z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naaSz$z$z$z$z$z$z$z$z${ͭz$z$z$z$z$z$,ZKz$z$z$z$z$|'&&&&&&L)&&&&&W Թz$z$z$z$z$z$|p׾z$z$z$z$z$z$}(Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naֻ?-z$z$z$z$z$z$z$z$viz$z$z$z$z$z$dVz$z$z$z$z$z$*&&&&&&y&&&&&&#B1z$z$z$z$z$|'eWz$z$z$z$z$z$I8Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$napcz$z$z$z$z$z$z$z$ʧ8&z$z$z$z$z$z$M=z$z$z$z$z$;)A&&&&&&q+&&&&&E2̫z$z$z$z$z$z$k^3 z$z$z$z$z$z$pbĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naǣ|'z$z$z$z$z$z$}(ϰz$z$z$z$z$z$_Qɧz$z$z$z$z$z$ſn&&&&&&2d&&&&&&?I8z$z$z$z$z${%ͭz$z$z$z$z$z$z$ĞĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naպ,z$z$z$z$z$z$>,ZKz$z$z$z$z$z$/z$z$z$z$z$uh&&&&&&&&&&&&&E9ּz$z$z$z$z$z$E5gYz$z$z$z$z$z$|'պĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na}(z$z$z$z$z$z$m`z$z$z$z$z$z$gY}qz$z$z$z$z$.'&&&&&&y@&&&&&&XWGz$z$z$z$z$z$@/z$z$z$z$z$z$~+Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naή{%z$z$z$z$z$z$¼^Pz$z$z$z$z$|'z$z$z$z$z$z$չ,&&&&&&E&&&&&&W3{&z$z$z$z$z$})~+z$z$z$z$z$z$3 Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naxz$z$z$z$z$z$C2z$z$z$z$z$z$›9'z$z$z$z$z$v2&&&&&&,,&&&&&(P~rz$z$z$z$z$z$O?ͭz$z$z$z$z$z$z$1Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naQAz$z$z$z$z$z$ȥN>z$z$z$z$z$L;wkz$z$z$z$z$I8l&&&&&&(J&&&&&&}.;)z$z$z$z$z$z$vz$z$z$z$z$z$z$.Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na|'z$z$z$z$z$?.şz$z$z$z$z$z$Ӷz$z$z$z$z${%@&&&&&&y&&&&&&;Eؿz$z$z$z$z$z$z$չymz$z$z$z$z$z$z$|'ɦĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$nawz$z$z$z$z$z$ί|'z$z$z$z$z$|'z$z$z$z$z$ѳ]&&&&&&&&&&&& naz$z$z$z$z$z$3 fXz$z$z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na-z$z$z$z$z$[LbSz$z$z$z$z$^PXHz$z$z$z$z$~r-&&&n1&&&&&&r T@/z$z$z$z$z$z$H7j]z$z$z$z$z$z$z$z$\MĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naxz$z$z$z$z${%şz$z$z$z$z$-{z$z$z$z$z$[LoK&&&&&&?z}(z$z$z$z$z$z$^Ppbz$z$z$z$z$z$z$z$7%Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na{%z$z$z$z$z$›z$z$z$z$z$z$бz$z$z$z$z$9'[&&&&&&) ›z$z$z$z$z$z$z$xl}qz$z$z$z$z$z$z$z${%viĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naTDz$z$z$z$z$`R7%z$z$z$z$z$Üz$z$z$z$z$z$m&&&&&&&4beWz$z$z$z$z$z$z$Ğ|'z$z$z$z$z$z$z$z$6$ήĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naĞz$z$z$z$z$-gYz$z$z$z$z$|p{&z$z$z$z$z$&&&&&&&hLýĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naz$z$z$z$z$z$z$z$z$z$z$ZK>,z$z$z$z$z$ϰ&&&&&&&L ;)z$z$z$z$z$z$z$QAz$z$z$z$z$z$z$z$z$z$cTĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na5#z$z$z$z$z$˪̫z$z$z$z$z$9&XIz$z$z$z$z$j^q+&&&&&&:',/z$z$z$z$z$z$z$|{{%z$z$z$z$z$z$z$z$z$]NĞz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naZKz$z$z$z$z$~z$z$z$z$z$z$k^z$z$z$z$z$@&&&&&&&W&&&&&&0Ug¼~*z$z$z$z$z$z$z$l_D3z$z$z$z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$na|pz$z$z$z$z$j\z$z$z$z$z$z$~rz$z$z$z$z$wr&&&&&&&&&&)&&&&&-~*z$z$z$z$z$z$z$TD}(z$z$z$z$z$z$z$Ğz$z$z$z$z$TD2z$z$z$z$z$yz$z$z$z$z$naz$z$z$z$z$\Mz$z$z$z$z$z$z$z$z$z$z$wkj&&&&&&&&&&&&&`&&&+ؿ~*z$z$z$z$z$z$z$@/j]{%z$z$z$z$M=Ğz$z$z$z$z$TD0z$z$z$z$z$yz$z$z$z$z$naơz$z$z$z$z$QAz$z$z$z$z$z$z$z$z$z$z$l_&&&&&&&&&&&&&&)]s ؿ~*z$z$z$z$z$z$z$~*ǢZKz$z$L<Ğz$z$z$z$z$TD-z$z$z$z$z$ľzz$z$z$z$z$l_̫z$z$z$z$z$F5|'z$z$z$z$z$z$z$z$z$z$bS2&&&&&&&&&&&&&&&W4"z$z$z$z$z$z$z$z$k^ơz$z$z$z$z$SD3!z$z$z$z$z$׾z$z$z$z$z$dVɦz$z$z$z$z$L;{%z$z$z$z$z$z$z$z$z$z$fX&&&&&&&&&&&&&&&&&0$E5z$z$z$z$z$z$z$z$9'Ըͭz$z$z$z$z$I8>,z$z$z$z$z$ѳz$z$z$z$z$ZKz$z$z$z$z$XHz$z$z$z$z$z$xz$z$z$z$z$re]&&&&&&&&&&&&&&&&&@ YJz$z$z$z$z$z$z$z$z$eWֻz$z$z$z$z$9'I8z$z$z$z$z$ʨǢz$z$z$z$z$I8~z$z$z$z$z$cTz$z$z$z$z$z$xlz$z$z$z$z$}q;&&&&&&&&&&&&&&&&&rD}qz$z$z$z$z$z$z$z$z$2ơz$z$z$z$z$~+TDz$z$z$z$z$›պz$z$z$z$z$~*hZz$z$z$z$z${oz$z$z$z$z$z$m`z$z$z$z$z$~)&&&&&&&&&&&&&&&&&^Bչ2z$z$z$z$z$z$z$z$z$>,ʧz$z$z$z$z$z$fXz$z$z$z$z$}qz$z$z$z$z$z$E5z$z$z$z$z$Üֻz$z$z$z$z$~*bSz$z$z$z$z$5&&&&&&&&&&&&&&&&&l=N>z$z$z$z$z$z$z$z$z$z$P@׾z$z$z$z$z$z$|z$z$z$z$z$]N|'z$z$z$z$z$ί{%z$z$z$z$z$׾z$z$z$z$z$I8H7z$z$z$z$z$ơQ&&&&&&&&&&&&&&&&&1 }(z$z$z$z$z$z$z$z$z$z$E5ɧ{&z$z$z$z$z$ʨz$z$z$z$z$:(O?z$z$z$z$z$wkӶz$z$z$z$z${%xlz$z$z$z$z$j]~*z$z$z$z$z$&&&&&&&&&&&&&&&&&~UEz$z$z$z$z$z$z$z$z$z$z$5#B1z$z$z$z$z$ǣz$z$z$z$z${%}z$z$z$z$z$9'uhz$z$z$z$z$H7RBz$z$z$z$z$z$z$z$z$z$z$)&&&&&&&&&&&&&&&@KǢ1z$z$z$z$z$z$z$z$z$z$z$fXgYz$z$z$z$z$}q{%z$z$z$z$z$ӷؿz$z$z$z$z$z$.z$z$z$z$z$u|'z$z$z$z$z$ϰؿz$z$z$z$z$~+y&&&&&&&&&&&&&&&-z~*z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$XIG6z$z$z$z$z$s1z$z$z$z$z$l_ͭz$z$z$z$z$z$ּz$z$z$z$z$z$ĝz$z$z$z$z$J9K&&&&&&&&&&&&&tM uh{&z$z$z$z$z$z$z$z$ּϰz$z$z$z$z$3 sz$z$z$z$z$F5znz$z$z$z$z$|'G6z$z$z$z$z$=+xz$z$z$z$z$B1k^z$z$z$z$z$k^J&&&&&&&&&&&l Dym~*z$z$z$z$z$}(z$z$z$z$z$z$ӷz$z$z$z$z$z$z$z$z$z$z$z$viҴz$z$z$z$z$z$>,z$z$z$z$z${o4"z$z$z$z$z$z)&&&&&&&36$z$z$|'Ȥ1z$z$z$z$z$|'z$z$z$z$z$P@z$z$z$z$z${&>,z$z$z$z$z$})z$z$z$z$z$z$Ҵz$z$z$z$z$z$Q8)<^2Kչ˪i[z$z$z$z$z$]N_Qz$z$z$z$z$;)Ըz$z$z$z$z$z$N>z$z$z$z$z$z$znwkz$z$z$z$z$-ĝz$z$z$z$z$0oȤz$z$z$z$z$~*ίz$z$z$z$z$z$ؿJ9z$z$z$z$z$z$~{&z$z$z$z$z$})/z$z$z$z$z$sfcTz$z$z$z$z$dVz$z$z$z$z$z$պ.z$z$z$z$z$WG{%z$z$z$z$z${%ּ9&z$z$z$z$z$z$z$z$z$z$z$z$ý}(z$z$z$z$z$ĝz(?.z$z$z$z$z$gYviz$z$z$z$z$z$sfz$z$z$z$z$z$-J9z$z$z$z$z$z$>,4"z$z$z$z$z$;)ơz$z$z$z$z$z$Dyz$z$z$z$z$|'{%z$z$z$z$z$]N:(z$z$z$z$z$z$3!UEz$z$z$z$z$z${&ľz$z$z$z$z$z$›RBz$z$z$z$z$L<~ yz$z$z$z$z$z$›fXz$z$z$z$z$z$ӷ|'z$z$z$z$z$z$-¼Lz$z$z$z$z$z$7%8&z$z$z$z$z$z$z$z$|'wȥ3!z$z$z$z$z$z$z$z$|'˩XIz$z$z$z$z$z$.1z$z$z$z$z$z$?-K:z$z$z$z$z$z$z$z$z$-wŠ<*z$z$z$z$z$z$z$z$z$2׽hZz$z$z$z$z$z$z$ί չ{%z$z$z$z$z$z$H7znz$z$z$z$z$z$z$z$z$z$})pbؿ{6$z$z$z$z$z$z$z$z$z$z$RBymz$z$z$z$z$z$z$xlP>{z$z$z$z$z$z$z$O?б/z$z$z$z$z$z$z$z$z$z$z$9&wؿK:z$z$z$z$z$z$z$z$z$z$z${%y|pz$z$z$z$z$z$z$O?|m`z$z$z$z$z$z$z$C2j]z$z$z$z$z$z$z$z$z$z$z$z$z$|'TDxȥήdU.z$z$z$z$z$z$z$z$z$z$z$z$z$J9j\z$z$z$z$z$z$z$C2_Qz$z$z$z$z$z$z$8&B1z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$}(3 @/6$-z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$.ÜZKz$z$z$z$z$z$z$9&1SDz$z$z$z$z$z$z$/׽ǣ?-z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$-{J9z$z$z$z$z$z$z$1c4K:z$z$z$z$z$z$z${%yͭE5z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$2ϰ.z$z$z$z$z$z$z$0KYJz$z$z$z$z$z$z$z$P@w4"z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z${&^Pήwz$z$z$z$z$z$z$z$8&gj\z$z$z$z$z$z$z$z$0Ȥҵwk?.z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$2hZǢE5z$z$z$z$z$z$z$z$B1 }qz$z$z$z$z$z$z$z$z$O?̬u_Q?-{%z$z$z$z$z$z$z$z$z$z$4"VFxlĝpcz$z$z$z$z$z$z$z$z$N>Ğ})z$z$z$z$z$z$z$z${&ym׽ӶԹſĝ0z$z$z$z$z$z$z$z$z$reB1z$z$z$z$z$z$z$z$z$})tgŠ:(z$z$z$z$z$z$z$z$z$~+Šm`z$z$z$z$z$z$z$z$z$z$|'sf3!z$z$z$z$z$z$z$z$z$z$G6`˩3!z$z$z$z$z$z$z$z$z$z$z$M=ĝвbS|'z$z$z$z$z$z$z$z$z$z$|'x;ym{%z$z$z$z$z$z$z$z$z$z$z$|'aSǣϰtg/z$z$z$z$z$z$z$z$z$z$z$z$VF'O?z$z$z$z$z$z$z$z$z$z$z$z$z$z$8&obȥб~rE5z$z$z$z$z$z$z$z$z$z$z$z$z$z$5#˪k ͭG6z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$3 SDtguǢʧɦyxl]N;)z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$4"<T׽SDz$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$6$#ýgY})z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$RCɦ^UʧWG{%z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$;)waS-z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$|'SDA+aS>,{%z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$z$5#WGz׾o<Ӷ~ruhk^^PSDN>P@^OhZqd|p̬q3o E bool: # this is way too simplistic but works on *nix systems which is all we # support currently if hasattr(os, "getuid"): return os.getuid() == 0 return False def _is_allowed_to_run_as_root() -> bool: if is_env_var_truthy(os.environ, ENV_FORCE_ALLOW_ROOT): return True if is_running_in_ci(os.environ): # CI environments are usually considered to be controlled, and safe # for root usage. return True if probe_for_container_runtime(os.environ) != "unknown": # So are container environments. return True return False def entrypoint() -> None: gm = EnvGlobalModeProvider(os.environ, sys.argv) ADAPTER.init_from_env(os.environ) ADAPTER.hook() # NOTE: import of `ruyi.log` takes ~90ms on my machine, so initialization # of logging is deferred as late as possible if _is_running_as_root() and not _is_allowed_to_run_as_root(): from ruyi.log import RuyiConsoleLogger logger = RuyiConsoleLogger(gm) logger.F(_("refusing to run as super user outside CI without explicit consent")) choices = ", ".join(f"'{x}'" for x in TRUTHY_ENV_VAR_VALUES) logger.I( _( "re-run with environment variable [yellow]{env_var}[/] set to one of [yellow]{choices}[/] to signify consent" ).format( env_var=ENV_FORCE_ALLOW_ROOT, choices=choices, ) ) sys.exit(1) if not sys.argv: from ruyi.log import RuyiConsoleLogger logger = RuyiConsoleLogger(gm) logger.F(_("no argv?")) sys.exit(1) if gm.is_packaged and ruyi.__compiled__.standalone: # If we're running from a bundle, our bundled libssl may remember a # different path for loading certificates than appropriate for the # current system, in which case the pygit2 import will fail. To avoid # this we have to patch ssl.get_default_verify_paths with additional # logic. # # this must happen before pygit2 is imported from ruyi.utils import ssl_patch del ssl_patch from ruyi.utils.nuitka import get_nuitka_self_exe, get_argv0 # note down our own executable path, for identity-checking in mux, if not # we're not already Nuitka-compiled # # we assume the one-file build if Nuitka is detected; sys.argv[0] does NOT # work if it's just `ruyi` so we have to check our parent process in that case sys.argv[0] = get_argv0() if gm.is_packaged: self_exe = get_nuitka_self_exe() else: self_exe = str(pathlib.Path(sys.argv[0]).resolve()) gm.record_self_exe(sys.argv[0], __file__, self_exe) from ruyi.config import GlobalConfig from ruyi.cli.main import main from ruyi.log import RuyiConsoleLogger logger = RuyiConsoleLogger(gm) gc = GlobalConfig.load_from_config(gm, logger) sys.exit(main(gm, gc, sys.argv)) if __name__ == "__main__": entrypoint() ruyisdk-ruyi-1f00e2e/ruyi/cli/000077500000000000000000000000001520522431500163345ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/cli/__init__.py000066400000000000000000000001641520522431500204460ustar00rootroot00000000000000from typing import Final # Should be all-lower for is_called_as_ruyi to work RUYI_ENTRYPOINT_NAME: Final = "ruyi" ruyisdk-ruyi-1f00e2e/ruyi/cli/builtin_commands.py000066400000000000000000000012521520522431500222350ustar00rootroot00000000000000from ..device import provision_cli as provision_cli from ..mux.venv import venv_cli as venv_cli from ..pluginhost import plugin_cli as plugin_cli from ..ruyipkg import admin_cli as admin_cli from ..ruyipkg import entity_cli as entity_cli from ..ruyipkg import install_cli as install_cli from ..ruyipkg import list_cli as list_cli from ..ruyipkg import news_cli as news_cli from ..ruyipkg import profile_cli as profile_cli from ..ruyipkg import repo_cli as repo_cli from ..ruyipkg import update_cli as update_cli from ..telemetry import telemetry_cli as telemetry_cli from . import self_cli as self_cli from . import config_cli as config_cli from . import version_cli as version_cli ruyisdk-ruyi-1f00e2e/ruyi/cli/cmd.py000066400000000000000000000151511520522431500174540ustar00rootroot00000000000000import argparse from typing import Callable, IO, Protocol, TYPE_CHECKING from ..i18n import _ from . import RUYI_ENTRYPOINT_NAME if TYPE_CHECKING: from ..config import GlobalConfig from .completion import ArgumentParser CLIEntrypoint = Callable[["GlobalConfig", argparse.Namespace], int] class _PrintHelp(Protocol): def print_help(self, file: IO[str] | None = None) -> None: ... def _wrap_help(x: _PrintHelp) -> "CLIEntrypoint": def _wrapped_(gc: "GlobalConfig", args: argparse.Namespace) -> int: x.print_help() return 0 return _wrapped_ class BaseCommand: parsers: "list[type[BaseCommand]]" = [] cmd: str | None _tele_key: str | None has_subcommands: bool is_experimental: bool is_subcommand_required: bool has_main: bool aliases: list[str] description: str | None prog: str | None help: str | None def __init_subclass__( cls, cmd: str | None, has_subcommands: bool = False, is_subcommand_required: bool = False, is_experimental: bool = False, has_main: bool | None = None, aliases: list[str] | None = None, description: str | None = None, prog: str | None = None, help: str | None = None, **kwargs: object, ) -> None: cls.cmd = cmd if cmd is None: cls._tele_key = None else: parent_cls = cls.mro()[1] parent_raw_tele_key = getattr(parent_cls, "_tele_key", None) if parent_raw_tele_key is None: cls._tele_key = cmd else: cls._tele_key = f"{parent_raw_tele_key} {cmd}" cls.has_subcommands = has_subcommands cls.is_subcommand_required = is_subcommand_required cls.is_experimental = is_experimental cls.has_main = has_main if has_main is not None else not has_subcommands # argparse params cls.aliases = aliases or [] cls.description = description cls.prog = prog cls.help = help cls.parsers.append(cls) super().__init_subclass__(**kwargs) @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: """Configure arguments for this parser.""" pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: """Entrypoint of this command.""" raise NotImplementedError @classmethod def is_root(cls) -> bool: return cls.cmd is None @classmethod def _build_tele_key(cls) -> str: return "" if cls._tele_key is None else cls._tele_key @classmethod def build_argparse(cls, gc: "GlobalConfig") -> "ArgumentParser": from .completion import ArgumentParser p = ArgumentParser(prog=cls.prog, description=cls.description) cls.configure_args(gc, p) cls._populate_defaults(p) cls._maybe_build_subcommands(gc, p) return p @classmethod def _maybe_build_subcommands( cls, gc: "GlobalConfig", p: "ArgumentParser", ) -> None: if not cls.has_subcommands: return sp = p.add_subparsers( title=_("subcommands"), required=cls.is_subcommand_required, ) for subcmd_cls in cls.parsers: if subcmd_cls.mro()[1] is not cls: # do not recurse onto self or non-direct subclasses continue if subcmd_cls.is_experimental and not gc.is_experimental: # skip configuring experimental commands if not enabled in # the environment continue subcmd_cls._configure_subcommand(gc, sp) @classmethod def _configure_subcommand( cls, gc: "GlobalConfig", sp: "argparse._SubParsersAction[ArgumentParser]", ) -> argparse.ArgumentParser: assert cls.cmd is not None p = sp.add_parser( cls.cmd, aliases=cls.aliases, help=cls.help, ) cls.configure_args(gc, p) cls._populate_defaults(p) cls._maybe_build_subcommands(gc, p) return p @classmethod def _populate_defaults(cls, p: "ArgumentParser") -> None: if cls.has_main: p.set_defaults(func=cls.main, tele_key=cls._build_tele_key()) else: p.set_defaults(func=_wrap_help(p), tele_key=cls._build_tele_key()) class RootCommand( BaseCommand, cmd=None, has_subcommands=True, has_main=True, prog=RUYI_ENTRYPOINT_NAME, description=_("RuyiSDK Package Manager"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: from .version_cli import cli_version p.add_argument( "-V", "--version", action="store_const", dest="func", const=cli_version, help=_("Print version information"), ) p.add_argument( "--porcelain", action="store_true", help=_("Give the output in a machine-friendly format if applicable"), ) # https://github.com/python/cpython/issues/67037 prevents the registration # of undocumented subcommands, so a preferred usage of # "ruyi completion-script --shell=bash" is not possible right now. p.add_argument( "--output-completion-script", action="store", type=str, dest="completion_script", default=None, help=argparse.SUPPRESS, ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: sh: str | None = args.completion_script if not sh: args._parser.print_help() # pylint: disable=protected-access return 0 # the rest are implementation of "--output-completion-script" from .completion import SUPPORTED_SHELLS if sh not in SUPPORTED_SHELLS: raise ValueError(f"Unsupported shell: {sh}") import sys from ..resource_bundle import get_resource_str script = get_resource_str("_ruyi_completion") assert script is not None, "should never happen; completion script not found" sys.stdout.write(script) return 0 # Repo admin commands class AdminCommand( RootCommand, cmd="admin", has_subcommands=True, # https://github.com/python/cpython/issues/67037 # help=argparse.SUPPRESS, help=_("(NOT FOR REGULAR USERS) Subcommands for managing Ruyi repos"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass ruyisdk-ruyi-1f00e2e/ruyi/cli/completer.py000066400000000000000000000026221520522431500207020ustar00rootroot00000000000000""" helper functions for CLI completions """ import argparse from typing import Protocol, Any, TYPE_CHECKING if TYPE_CHECKING: # A "lie" for type checking purposes. This is a known and wont fix issue for mypy. # Mypy would think the fallback import needs to be the same type as the first import. # See: https://github.com/python/mypy/issues/1153 from argcomplete.completers import BaseCompleter else: try: from argcomplete.completers import BaseCompleter except ImportError: # Fallback for environments where argcomplete is less than 2.0.0 class BaseCompleter(object): def __call__( self, *, prefix: str, action: argparse.Action, parser: argparse.ArgumentParser, parsed_args: argparse.Namespace, ) -> None: raise NotImplementedError( "This method should be implemented by a subclass." ) class NoneCompleter(BaseCompleter): def __call__( self, *, prefix: str, action: argparse.Action, parser: argparse.ArgumentParser, parsed_args: argparse.Namespace, ) -> None: return None class DynamicCompleter(Protocol): def __call__( self, prefix: str, parsed_args: object, **kwargs: Any, ) -> list[str]: ... ruyisdk-ruyi-1f00e2e/ruyi/cli/completion.py000066400000000000000000000015111520522431500210550ustar00rootroot00000000000000""" helper functions for CLI completions see https://github.com/kislyuk/argcomplete/issues/443 for why this is needed """ import argparse from typing import Any, Callable, Final, Optional, Sequence, cast SUPPORTED_SHELLS: Final[set[str]] = {"bash", "zsh"} class ArgcompleteAction(argparse.Action): completer: Optional[Callable[[str, object], list[str]]] def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: str | Sequence[Any] | None, option_string: str | None = None, ) -> None: raise NotImplementedError(".__call__() not defined") class ArgumentParser(argparse.ArgumentParser): def add_argument(self, *args: Any, **kwargs: Any) -> ArgcompleteAction: return cast(ArgcompleteAction, super().add_argument(*args, **kwargs)) ruyisdk-ruyi-1f00e2e/ruyi/cli/config_cli.py000066400000000000000000000100701520522431500210000ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..i18n import _ from .cmd import RootCommand if TYPE_CHECKING: from .completion import ArgumentParser from .. import config # Config management commands class ConfigCommand( RootCommand, cmd="config", has_subcommands=True, help=_("Manage Ruyi's config options"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: pass class ConfigGetCommand( ConfigCommand, cmd="get", help=_("Query the value of a Ruyi config option"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "key", type=str, help=_("The Ruyi config option to query"), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: from ..config.errors import InvalidConfigKeyError from ..config.schema import encode_value from ..utils.toml import NoneValue key: str = args.key try: val = cfg.get_by_key(key) except InvalidConfigKeyError: return 1 try: encoded_val = encode_value(val) except NoneValue: return 1 cfg.logger.stdout(encoded_val) return 0 class ConfigSetCommand( ConfigCommand, cmd="set", help=_("Set the value of a Ruyi config option"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "key", type=str, help=_("The Ruyi config option to set"), ) p.add_argument( "value", type=str, help=_("The value to set the option to"), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor from ..config.errors import ProtectedGlobalConfigError from ..config.schema import decode_value key: str = args.key val: str = args.value pyval = decode_value(key, val) with ConfigEditor.work_on_user_local_config(cfg) as ed: try: ed.set_value(key, pyval) except ProtectedGlobalConfigError: cfg.logger.F( _( "the config [yellow]{key}[/] is protected and not meant to be overridden by users" ).format(key=key) ) return 2 ed.stage() return 0 class ConfigUnsetCommand( ConfigCommand, cmd="unset", help=_("Unset a Ruyi config option"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "key", type=str, help=_("The Ruyi config option to unset"), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor key: str = args.key with ConfigEditor.work_on_user_local_config(cfg) as ed: ed.unset_value(key) ed.stage() return 0 class ConfigRemoveSectionCommand( ConfigCommand, cmd="remove-section", help=_("Remove a section from the Ruyi config"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "section", type=str, help=_("The section to remove"), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor section: str = args.section with ConfigEditor.work_on_user_local_config(cfg) as ed: ed.remove_section(section) ed.stage() return 0 ruyisdk-ruyi-1f00e2e/ruyi/cli/main.py000066400000000000000000000137761520522431500176500ustar00rootroot00000000000000import atexit import os import sys from typing import Final, TYPE_CHECKING from ..config import GlobalConfig from ..i18n import _ from ..telemetry.scope import TelemetryScope from ..utils.global_mode import ( GlobalModeProvider, is_cli_completion_script_requested, ) from ..version import RUYI_SEMVER from . import RUYI_ENTRYPOINT_NAME from .oobe import OOBE ALLOWED_RUYI_ENTRYPOINT_NAMES: Final = ( RUYI_ENTRYPOINT_NAME, f"{RUYI_ENTRYPOINT_NAME}.exe", f"{RUYI_ENTRYPOINT_NAME}.bin", # Nuitka one-file program cache "__main__.py", ) VERSION_QUERY_FLAGS: Final = frozenset(("-V", "--version")) VERSION_QUERY_SUBCOMMAND: Final = "version" def is_called_as_ruyi(argv0: str) -> bool: return os.path.basename(argv0).lower() in ALLOWED_RUYI_ENTRYPOINT_NAMES def should_prompt_for_renaming(argv0: str) -> bool: # We need to allow things like "ruyi-qemu" through, to not break our mux. # Only consider filenames starting with both our name *and* version to be # un-renamed onefile artifacts that warrant a rename prompt. likely_artifact_name_prefix = f"{RUYI_ENTRYPOINT_NAME}-{RUYI_SEMVER}." return os.path.basename(argv0).lower().startswith(likely_artifact_name_prefix) def is_version_query(argv: list[str]) -> bool: # Scan flags broadly for issue #453, while only accepting the subcommand # spelling in command position so positional values named "version" do not # suppress OOBE/telemetry for unrelated commands. for arg in argv[1:]: if arg in VERSION_QUERY_FLAGS: return True for arg in argv[1:]: if arg == "--porcelain": continue return arg == VERSION_QUERY_SUBCOMMAND return False def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int: logger = gc.logger is_completion_script_invocation = is_cli_completion_script_requested(argv) is_ruyi_invocation = is_called_as_ruyi(gm.argv0) # Version queries must be side-effect-free: issue #453 showed that OOBE # could otherwise prompt before printing the version and consume first-run # telemetry state. skip_telemetry = is_ruyi_invocation and is_version_query(argv) # do not init telemetry or OOBE on shell completion invocations, because # our output isn't meant for humans in that case, and a "real" invocation # will likely follow shortly after if ( not gm.is_cli_autocomplete and not is_completion_script_invocation and not skip_telemetry ): oobe = OOBE(gc) tm = gc.telemetry tm.check_first_run_status() tm.init_installation(False) atexit.register(tm.flush) oobe.handlers.append(tm.oobe_prompt) oobe.maybe_prompt() if not is_ruyi_invocation: if should_prompt_for_renaming(gm.argv0): logger.F( _( "the {ruyi_exe} executable must be named [green]'{expected_name}'[/] to work" ).format( ruyi_exe=RUYI_ENTRYPOINT_NAME, expected_name=RUYI_ENTRYPOINT_NAME, ) ) logger.I( _("it is now [yellow]'{current_name}'[/]").format( current_name=gm.argv0, ) ) logger.I(_("please rename the command file and retry")) return 1 from ..mux.runtime import mux_main # record an invocation and the command name being proxied to gc.telemetry.record( TelemetryScope(None), "cli:mux-invocation-v1", target=os.path.basename(gm.argv0), ) return mux_main(gm, gc, argv) import ruyi from .cmd import RootCommand from . import builtin_commands del builtin_commands if TYPE_CHECKING: from .cmd import CLIEntrypoint p = RootCommand.build_argparse(gc) # We have to ensure argcomplete is only requested when it's supposed to, # as the argcomplete import is very costly in terms of startup time, and # that the package name completer requires the whole repo to be synced # (which may not be the case for an out-of-the-box experience). if gm.is_cli_autocomplete: import argcomplete from .completer import NoneCompleter argcomplete.autocomplete( p, always_complete_options=True, default_completer=NoneCompleter(), ) args = p.parse_args(argv[1:]) # for getting access to the argparse parser in the CLI entrypoint args._parser = p # pylint: disable=protected-access gm.is_porcelain = args.porcelain nuitka_info = "not compiled" if hasattr(ruyi, "__compiled__"): nuitka_info = f"__compiled__ = {ruyi.__compiled__}" logger.D( f"__main__.__file__ = {gm.main_file}, sys.executable = {sys.executable}, {nuitka_info}" ) logger.D(f"argv[0] = {gm.argv0}, self_exe = {gm.self_exe}") logger.D(f"args={args}") func: "CLIEntrypoint" = args.func # record every invocation's subcommand for better insight into usage # frequencies try: telemetry_key: str = args.tele_key except AttributeError: logger.F(_("internal error: CLI entrypoint was added without a telemetry key")) return 1 # Special-case the `--output-completion-script` argument; treat it as if # "ruyi completion-script" were called. completion_script: str | None = getattr(args, "completion_script", None) if is_completion_script_invocation and completion_script is not None: return func(gc, args) if not skip_telemetry: tm = gc.telemetry tm.print_telemetry_notice() # Do not record `ruyi telemetry --cron-upload` invocations. skip_recording_invocation = telemetry_key == "telemetry" and getattr( args, "cron_upload", False, ) if not skip_recording_invocation: tm.record( TelemetryScope(None), "cli:invocation-v1", key=telemetry_key, ) return func(gc, args) ruyisdk-ruyi-1f00e2e/ruyi/cli/oobe.py000066400000000000000000000050141520522431500176320ustar00rootroot00000000000000"""First-run (Out-of-the-box) experience for ``ruyi``.""" import os import sys from typing import Callable, Final, TYPE_CHECKING from ..i18n import _, d_ if TYPE_CHECKING: from ..config import GlobalConfig SHELL_AUTO_COMPLETION_TIP: Final = d_( """ [bold green]tip[/]: you can enable shell auto-completion for [yellow]ruyi[/] by adding the following line to your [green]{shrc}[/], if you have not done so already: [green]eval "$(ruyi --output-completion-script={shell})"[/] You can do so by running the following command later: [green]echo 'eval "$(ruyi --output-completion-script={shell})"' >> {shrc}[/] """ ) class OOBE: """Out-of-the-box experience (OOBE) handler for RuyiSDK CLI.""" def __init__(self, gc: "GlobalConfig") -> None: self._gc = gc self.handlers: list[Callable[[], None]] = [ self._builtin_shell_completion_tip, ] def is_first_run(self) -> bool: # We now always have our first-run indicator because of the minimal # telemetry mode. return self._gc.telemetry.is_first_run def should_prompt(self) -> bool: from ..utils.global_mode import is_env_var_truthy if not sys.stdin.isatty() or not sys.stdout.isatty(): # This is of higher priority than even the debug override, because # we don't want to mess up non-interactive sessions even in case of # debugging. return False if is_env_var_truthy(os.environ, "RUYI_DEBUG_FORCE_FIRST_RUN"): return True return self.is_first_run() def maybe_prompt(self) -> None: if not self.should_prompt(): return logger = self._gc.logger logger.I( "Welcome to RuyiSDK! This appears to be your first run of [yellow]ruyi[/].", ) for handler in self.handlers: handler() def _builtin_shell_completion_tip(self) -> None: from ..utils.node_info import probe_for_shell from .completion import SUPPORTED_SHELLS # Only show the tip if we're not externally managed by a package manager, # because we expect proper shell integration to be done by distro packagers if self._gc.is_installation_externally_managed: return shell = probe_for_shell(os.environ) if shell not in SUPPORTED_SHELLS: return self._gc.logger.stdout( _(SHELL_AUTO_COMPLETION_TIP).format( shell=shell, shrc=f"~/.{shell}rc", ) ) ruyisdk-ruyi-1f00e2e/ruyi/cli/self_cli.py000066400000000000000000000216611520522431500204740ustar00rootroot00000000000000import argparse import os import pathlib import shutil from typing import Final, TYPE_CHECKING from ..i18n import _, d_ from .cmd import RootCommand if TYPE_CHECKING: from .completion import ArgumentParser from .. import config UNINSTALL_NOTICE: Final = d_( """ [bold]Thanks for hacking with [yellow]Ruyi[/]![/] This will uninstall [yellow]Ruyi[/] from your system, and optionally remove all installed packages and [yellow]Ruyi[/]-managed repository data if the [green]--purge[/] switch is given on the command line. Note that your [yellow]Ruyi[/] virtual environments [bold]will become unusable[/] after [yellow]Ruyi[/] is uninstalled. You should take care of migrating or cleaning them yourselves afterwards. """ ) # Self-management commands class SelfCommand( RootCommand, cmd="self", has_subcommands=True, help=_("Manage this Ruyi installation"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: pass class SelfCleanCommand( SelfCommand, cmd="clean", help=_("Remove various Ruyi-managed data to reclaim storage"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "--quiet", "-q", action="store_true", help=_("Do not print out the actions being performed"), ) p.add_argument( "--all", action="store_true", help=_("Remove all covered data"), ) p.add_argument( "--distfiles", action="store_true", help=_("Remove all downloaded distfiles if any"), ) p.add_argument( "--installed-pkgs", action="store_true", help=_("Remove all installed packages if any"), ) p.add_argument( "--news-read-status", action="store_true", help=_("Mark all news items as unread"), ) p.add_argument( "--progcache", action="store_true", help=_("Clear the Ruyi program cache"), ) p.add_argument( "--repo", action="store_true", help=_("Remove the Ruyi repo if located in Ruyi-managed cache directory"), ) p.add_argument( "--telemetry", action="store_true", help=_("Remove all telemetry data recorded if any"), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: logger = cfg.logger quiet: bool = args.quiet all: bool = args.all distfiles: bool = args.distfiles installed_pkgs: bool = args.installed_pkgs news_read_status: bool = args.news_read_status progcache: bool = args.progcache repo: bool = args.repo telemetry: bool = args.telemetry if all: distfiles = True installed_pkgs = True news_read_status = True progcache = True repo = True telemetry = True if not any( [ distfiles, installed_pkgs, news_read_status, progcache, repo, telemetry, ] ): logger.F(_("no data specified for cleaning")) logger.I( _( "please check [yellow]ruyi self clean --help[/] for a list of cleanable data" ) ) return 1 _do_reset( cfg, quiet=quiet, # state-related all_state=all, news_read_status=news_read_status, telemetry=telemetry, # cache-related all_cache=all, distfiles=distfiles, installed_pkgs=installed_pkgs, progcache=progcache, repo=repo, ) return 0 class SelfUninstallCommand( SelfCommand, cmd="uninstall", help=_("Uninstall Ruyi"), ): @classmethod def configure_args( cls, gc: "config.GlobalConfig", p: "ArgumentParser", ) -> None: p.add_argument( "--purge", action="store_true", help=_("Remove all installed packages and Ruyi-managed remote repo data"), ) p.add_argument( "-y", action="store_true", dest="consent", help=_( "Give consent for uninstallation on CLI; do not ask for confirmation" ), ) @classmethod def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int: from . import user_input logger = cfg.logger purge: bool = args.purge consent: bool = args.consent logger.D(f"ruyi self uninstall: purge={purge}, consent={consent}") if cfg.is_installation_externally_managed: logger.F( _( "this [yellow]ruyi[/] is externally managed, for example, by the system package manager, and cannot be uninstalled this way" ) ) logger.I(_("please uninstall via the external manager instead")) return 1 if not cfg.is_packaged: logger.F( _( "this [yellow]ruyi[/] is not in standalone form, and cannot be uninstalled this way" ) ) return 1 if not consent: logger.stdout(_(UNINSTALL_NOTICE)) if not user_input.ask_for_yesno_confirmation(logger, _("Continue?")): logger.I(_("aborting uninstallation")) return 0 else: logger.I(_("uninstallation consent given over CLI, proceeding")) _do_reset( cfg, quiet=False, installed_pkgs=purge, all_state=purge, all_cache=purge, self_binary=True, ) logger.I(_("[yellow]ruyi[/] is uninstalled")) return 0 def _do_reset( cfg: "config.GlobalConfig", quiet: bool = False, *, installed_pkgs: bool = False, all_state: bool = False, news_read_status: bool = False, # ignored if all_state=True telemetry: bool = False, # ignored if all_state=True all_cache: bool = False, distfiles: bool = False, # ignored if all_cache=True progcache: bool = False, # ignored if all_cache=True repo: bool = False, # ignored if all_cache=True self_binary: bool = False, ) -> None: logger = cfg.logger def status(s: str) -> None: if quiet: return logger.I(s) if installed_pkgs: status(_("removing installed packages")) shutil.rmtree(cfg.data_root, True) cfg.ruyipkg_global_state.purge_installation_info() # do not record any telemetry data if we're purging it if all_state or telemetry: cfg.telemetry.discard_events(True) if all_state: status(_("removing state data")) shutil.rmtree(cfg.state_root, True) else: if news_read_status: status(_("removing read status of news items")) cfg.news_read_status.remove() if telemetry: status(_("removing all telemetry data")) shutil.rmtree(cfg.telemetry_root, True) if all_cache: status(_("removing cached data")) shutil.rmtree(cfg.cache_root, True) else: if distfiles: status(_("removing downloaded distfiles")) # TODO: deduplicate the path derivation shutil.rmtree(os.path.join(cfg.cache_root, "distfiles"), True) if progcache: status(_("clearing the Ruyi program cache")) # TODO: deduplicate the path derivation shutil.rmtree(os.path.join(cfg.cache_root, "progcache"), True) if repo: # for safety, don't remove the repo if it's outside of Ruyi's XDG # cache root repo_dir = pathlib.Path(cfg.get_repo_dir()).resolve() cache_root = pathlib.Path(cfg.cache_root).resolve() repo_is_below_cache_root = False for p in repo_dir.parents: if p == cache_root: repo_is_below_cache_root = True break if not repo_is_below_cache_root: logger.W( _( "not removing the Ruyi repo: it is outside of the Ruyi cache directory" ) ) else: status(_("removing the Ruyi repo")) shutil.rmtree(repo_dir, True) if self_binary: status(_("removing the ruyi binary")) try: os.unlink(cfg.self_exe) except FileNotFoundError: # we might have already removed ourselves during the purge; nothing to # do now. pass ruyisdk-ruyi-1f00e2e/ruyi/cli/user_input.py000066400000000000000000000106411520522431500211050ustar00rootroot00000000000000import os.path from ..i18n import _ from ..log import RuyiLogger def pause_before_continuing( logger: RuyiLogger, ) -> None: """Pause and wait for the user to press Enter before continuing. EOFError should be handled by the caller.""" logger.stdout(_("Press [green][/] to continue: "), end="") input() def ask_for_yesno_confirmation( logger: RuyiLogger, prompt: str, default: bool = False, ) -> bool: choices_help = "(Y/n)" if default else "(y/N)" while True: try: logger.stdout(f"{prompt} {choices_help} ", end="") user_input = input() except EOFError: yesno = _("YES") if default else _("NO") logger.W( _( "EOF while reading user input, assuming the default choice {yesno}" ).format(yesno=yesno) ) return default if not user_input: return default if user_input in {"Y", "y", "yes"}: return True if user_input in {"N", "n", "no"}: return False else: logger.stdout( _("Unrecognized input [yellow]'{user_input}'[/].").format( user_input=user_input ) ) logger.stdout(_("Accepted choices: Y/y/yes for YES, N/n/no for NO.")) def ask_for_kv_choice( logger: RuyiLogger, prompt: str, choices_kv: dict[str, str], default_key: str | None = None, ) -> str: choices_kv_list = list(choices_kv.items()) choices_prompts = [i[1] for i in choices_kv_list] default_idx: int | None = None if default_key is not None: for i, k in enumerate(choices_kv_list): if k[0] == default_key: default_idx = i break if default_idx is None: raise ValueError(f"Default choice key '{default_key}' not in choices") choice = ask_for_choice(logger, prompt, choices_prompts, default_idx) return choices_kv_list[choice][0] def ask_for_choice( logger: RuyiLogger, prompt: str, choices_texts: list[str], default_idx: int | None = None, ) -> int: logger.stdout(prompt, end="\n\n") for i, choice_text in enumerate(choices_texts): logger.stdout(f" {i + 1}. {choice_text}") logger.stdout("") nr_choices = len(choices_texts) if default_idx is not None: if not (0 <= default_idx < nr_choices): raise ValueError(f"Default choice index {default_idx} out of range") choices_help = _("(1-{nr_choices}, default {default})").format( nr_choices=nr_choices, default=default_idx + 1, ) else: choices_help = _("(1-{nr_choices})").format(nr_choices=nr_choices) while True: try: user_input = input( _("Choice? {choices_help} ").format( choices_help=choices_help, ) ) except EOFError: raise ValueError("EOF while reading user choice") if default_idx is not None and not user_input: return default_idx try: choice_int = int(user_input) except ValueError: logger.stdout( _("Unrecognized input [yellow]'{user_input}'[/].").format( user_input=user_input, ) ) logger.stdout( _( "Accepted choices: an integer number from 1 to {nr_choices} inclusive." ).format( nr_choices=nr_choices, ) ) continue if 1 <= choice_int <= nr_choices: return choice_int - 1 logger.stdout( _("Out-of-range input [yellow]'{user_input}'[/].").format( user_input=user_input, ) ) logger.stdout( _( "Accepted choices: an integer number from 1 to {nr_choices} inclusive." ).format( nr_choices=nr_choices, ) ) def ask_for_file( logger: RuyiLogger, prompt: str, ) -> str: while True: try: user_input = input(f"{prompt} ") except EOFError: raise ValueError("EOF while reading user input") if os.path.exists(user_input): return user_input logger.stdout(f"[yellow]'{user_input}'[/] is not a path to an existing file.") ruyisdk-ruyi-1f00e2e/ruyi/cli/version_cli.py000066400000000000000000000025621520522431500212270ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..i18n import _ from .cmd import RootCommand if TYPE_CHECKING: from .completion import ArgumentParser from ..config import GlobalConfig class VersionCommand( RootCommand, cmd="version", help=_("Print version information"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: return cli_version(cfg, args) def cli_version(cfg: "GlobalConfig", args: argparse.Namespace) -> int: import ruyi from ..ruyipkg.host import get_native_host from ..version import COPYRIGHT_NOTICE, MPL_REDIST_NOTICE, RUYI_SEMVER print( _("Ruyi {version}\n\nRunning on {host}.").format( version=RUYI_SEMVER, host=get_native_host(), ) ) if cfg.is_installation_externally_managed: print(_("This Ruyi installation is externally managed.")) print() cfg.logger.stdout(_(COPYRIGHT_NOTICE)) # Output the MPL notice only when we actually bundle and depend on the # MPL component(s), which right now is only certifi. Keep the condition # synced with __main__.py. if hasattr(ruyi, "__compiled__") and ruyi.__compiled__.standalone: cfg.logger.stdout(_(MPL_REDIST_NOTICE)) return 0 ruyisdk-ruyi-1f00e2e/ruyi/config/000077500000000000000000000000001520522431500170325ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/config/__init__.py000066400000000000000000000556411520522431500211560ustar00rootroot00000000000000import datetime from functools import cached_property import locale import os.path from os import PathLike import pathlib import sys from typing import Any, Final, Iterable, Sequence, TypedDict, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import NotRequired, Self from ..log import RuyiLogger from ..ruyipkg.composite_repo import CompositeRepo from ..ruyipkg.repo import MetadataRepo, RepoEntry from ..ruyipkg.state import RuyipkgGlobalStateStore from ..telemetry.provider import TelemetryProvider from ..utils.global_mode import ProvidesGlobalMode from ..utils.xdg_basedir import XDGPathEntry from .news import NewsReadStatusStore import babel # not sure why Pyright insists on individual imports # otherwise, at the use site (`except babel.core.UnknownLocaleError`): # error: "core" is not a known attribute of module "babel" (reportAttributeAccessIssue) from babel.core import UnknownLocaleError from ..i18n import _ from . import errors from . import schema if sys.platform == "linux": PRESET_GLOBAL_CONFIG_LOCATIONS: Final[list[str]] = [ # TODO: enable distro packagers to customize the $PREFIX to suit their # particular FS layout if necessary. "/usr/share/ruyi/config.toml", "/usr/local/share/ruyi/config.toml", ] else: PRESET_GLOBAL_CONFIG_LOCATIONS: Final[list[str]] = [] DEFAULT_APP_NAME: Final = "ruyi" DEFAULT_REPO_URL: Final = "https://github.com/ruyisdk/packages-index.git" DEFAULT_REPO_BRANCH: Final = "main" DEFAULT_TELEMETRY_MODE: Final = "local" # "off", "local", "on" def get_host_path_fragment_for_binary_install_dir(canonicalized_host: str) -> str: # e.g. linux/amd64 -> amd64; "windows/amd64" -> "windows-amd64" if canonicalized_host.startswith("linux/"): return canonicalized_host[6:] return canonicalized_host.replace("/", "-") def _get_lang_code() -> str: lang = locale.getlocale()[0] return lang or "en_US" class GlobalConfigPackagesType(TypedDict): prereleases: "NotRequired[bool]" class GlobalConfigRepoType(TypedDict): local: "NotRequired[str]" remote: "NotRequired[str]" branch: "NotRequired[str]" class GlobalConfigInstallationType(TypedDict): # Undocumented: whether this Ruyi installation is externally managed. # # Can be used by distro packagers (by placing a config file in /etc/xdg/ruyi) # to signify this status to an official Ruyi build (where IS_PACKAGED is # True), to prevent e.g. accidental self-uninstallation. externally_managed: "NotRequired[bool]" class GlobalConfigTelemetryType(TypedDict): mode: "NotRequired[str]" upload_consent: "NotRequired[datetime.datetime | str]" pm_telemetry_url: "NotRequired[str]" class GlobalConfigReposEntryType(TypedDict): id: str name: "NotRequired[str]" remote: "NotRequired[str]" branch: "NotRequired[str]" local: "NotRequired[str]" priority: "NotRequired[int]" active: "NotRequired[bool]" class GlobalConfigRootType(TypedDict): installation: "NotRequired[GlobalConfigInstallationType]" packages: "NotRequired[GlobalConfigPackagesType]" repo: "NotRequired[GlobalConfigRepoType]" repos: "NotRequired[list[GlobalConfigReposEntryType]]" telemetry: "NotRequired[GlobalConfigTelemetryType]" class GlobalConfig: def __init__(self, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> None: from ..utils.xdg_basedir import XDGBaseDir self._gm = gm self.logger = logger # all defaults self.override_repo_dir: str | None = None self.override_repo_url: str | None = None self.override_repo_branch: str | None = None self.include_prereleases = False self.is_installation_externally_managed = False self._lang_code = _get_lang_code() self._dirs = XDGBaseDir(DEFAULT_APP_NAME) self._telemetry_mode: str | None = None self._telemetry_upload_consent: datetime.datetime | None = None self._telemetry_pm_telemetry_url: str | None = None self._extra_repo_entries: list["RepoEntry"] = [] def _apply_config( self, config_data: GlobalConfigRootType, *, is_global_scope: bool, ) -> None: if ins_cfg := config_data.get(schema.SECTION_INSTALLATION): iem = ins_cfg.get(schema.KEY_INSTALLATION_EXTERNALLY_MANAGED, None) if iem is not None and not is_global_scope: iem_cfg_key = f"{schema.SECTION_INSTALLATION}.{schema.KEY_INSTALLATION_EXTERNALLY_MANAGED}" self.logger.W( _( "the config key [yellow]{key}[/] cannot be set from user config; ignoring" ).format( key=iem_cfg_key, ), ) else: self.is_installation_externally_managed = bool(iem) if pkgs_cfg := config_data.get(schema.SECTION_PACKAGES): self.include_prereleases = pkgs_cfg.get( schema.KEY_PACKAGES_PRERELEASES, False ) if repo_cfg := config_data.get(schema.SECTION_REPO): self.override_repo_dir = repo_cfg.get(schema.KEY_REPO_LOCAL, None) self.override_repo_url = repo_cfg.get(schema.KEY_REPO_REMOTE, None) self.override_repo_branch = repo_cfg.get(schema.KEY_REPO_BRANCH, None) if self.override_repo_dir: if not pathlib.Path(self.override_repo_dir).is_absolute(): self.logger.W( _( "the local repo path '{path}' is not absolute; ignoring" ).format( path=self.override_repo_dir, ) ) self.override_repo_dir = None if tele_cfg := config_data.get(schema.SECTION_TELEMETRY): self._telemetry_mode = tele_cfg.get(schema.KEY_TELEMETRY_MODE, None) self._telemetry_pm_telemetry_url = tele_cfg.get( schema.KEY_TELEMETRY_PM_TELEMETRY_URL, None, ) self._telemetry_upload_consent = None if consent := tele_cfg.get(schema.KEY_TELEMETRY_UPLOAD_CONSENT, None): if isinstance(consent, datetime.datetime): self._telemetry_upload_consent = consent if repos_cfg := config_data.get(schema.SECTION_REPOS): self._parse_repos_config(repos_cfg, is_system=is_global_scope) def _parse_repos_config( self, repos_cfg: "list[GlobalConfigReposEntryType]", *, is_system: bool = False, ) -> None: from ..ruyipkg.repo import ( DEFAULT_REPO_ID, DEFAULT_REPO_PRIORITY, REPO_ID_PATTERN, RepoEntry, ) seen_ids: set[str] = {e.id for e in self._extra_repo_entries} new_entries: list[RepoEntry] = [] for entry_data in repos_cfg: repo_id = entry_data.get(schema.KEY_REPOS_ID, "") if not repo_id or not REPO_ID_PATTERN.match(repo_id): self.logger.W( _("ignoring [[repos]] entry with invalid id: '{id}'").format( id=repo_id ) ) continue if repo_id == DEFAULT_REPO_ID: self.logger.W( _( "ignoring [[repos]] entry with reserved id '{id}'; " "use [repo] to configure the default repository" ).format(id=repo_id) ) continue if repo_id in seen_ids: self.logger.W( _("ignoring duplicate [[repos]] entry with id '{id}'").format( id=repo_id ) ) continue remote = entry_data.get(schema.KEY_REPOS_REMOTE, "") local_path = entry_data.get(schema.KEY_REPOS_LOCAL, None) if not remote and not local_path: self.logger.W( _( "ignoring [[repos]] entry '{id}': " "at least one of 'remote' or 'local' must be set" ).format(id=repo_id) ) continue if local_path and not pathlib.Path(local_path).is_absolute(): self.logger.W( _( "ignoring [[repos]] entry '{id}': " "the local path '{path}' is not absolute" ).format(id=repo_id, path=local_path) ) continue seen_ids.add(repo_id) new_entries.append( RepoEntry( id=repo_id, name=entry_data.get(schema.KEY_REPOS_NAME, repo_id), remote=remote, branch=entry_data.get(schema.KEY_REPOS_BRANCH, DEFAULT_REPO_BRANCH), local_path=local_path, priority=entry_data.get( schema.KEY_REPOS_PRIORITY, DEFAULT_REPO_PRIORITY ), active=entry_data.get(schema.KEY_REPOS_ACTIVE, True), is_system=is_system, ) ) self._extra_repo_entries.extend(new_entries) def get_by_key(self, key: str | Sequence[str]) -> object: parsed_key = schema.parse_config_key(key) section, sel = parsed_key[0], parsed_key[1:] attr_name = self._get_attr_name_by_key(section, sel) if attr_name is None: raise errors.InvalidConfigKeyError(key) return getattr(self, attr_name) def set_by_key(self, key: str | Sequence[str], value: object) -> None: # We don't have to check for global-only keys here because this # method is only used for programmatic changes to the in-memory # config, not for loading from config files. parsed_key = schema.parse_config_key(key) section, sel = parsed_key[0], parsed_key[1:] attr_name = self._get_attr_name_by_key(section, sel) if attr_name is None: raise errors.InvalidConfigKeyError(key) schema.ensure_valid_config_kv(key, True, value) setattr(self, attr_name, value) @classmethod def _get_attr_name_by_key(cls, section: str, sel: list[str]) -> str | None: if section == schema.SECTION_INSTALLATION: return cls._get_section_installation(sel) elif section == schema.SECTION_PACKAGES: return cls._get_section_packages(sel) elif section == schema.SECTION_REPO: return cls._get_section_repo(sel) elif section == schema.SECTION_TELEMETRY: return cls._get_section_telemetry(sel) else: return None @classmethod def _get_section_installation(cls, selector: list[str]) -> str | None: if len(selector) != 1: return None leaf = selector[0] if leaf == schema.KEY_INSTALLATION_EXTERNALLY_MANAGED: return "is_installation_externally_managed" else: return None @classmethod def _get_section_packages(cls, selector: list[str]) -> str | None: if len(selector) != 1: return None leaf = selector[0] if leaf == schema.KEY_PACKAGES_PRERELEASES: return "include_prereleases" else: return None @classmethod def _get_section_repo(cls, selector: list[str]) -> str | None: if len(selector) != 1: return None leaf = selector[0] if leaf == schema.KEY_REPO_BRANCH: return "override_repo_branch" elif leaf == schema.KEY_REPO_LOCAL: return "override_repo_dir" elif leaf == schema.KEY_REPO_REMOTE: return "override_repo_url" else: return None @classmethod def _get_section_telemetry(cls, selector: list[str]) -> str | None: if len(selector) != 1: return None leaf = selector[0] if leaf == schema.KEY_TELEMETRY_MODE: return "telemetry_mode" elif leaf == schema.KEY_TELEMETRY_PM_TELEMETRY_URL: return "override_pm_telemetry_url" elif leaf == schema.KEY_TELEMETRY_UPLOAD_CONSENT: return "telemetry_upload_consent_time" else: return None @property def argv0(self) -> str: return self._gm.argv0 @property def main_file(self) -> str: return self._gm.main_file @property def self_exe(self) -> str: return self._gm.self_exe @property def is_debug(self) -> bool: return self._gm.is_debug @property def is_experimental(self) -> bool: return self._gm.is_experimental @property def is_packaged(self) -> bool: return self._gm.is_packaged @property def is_porcelain(self) -> bool: return self._gm.is_porcelain @property def is_telemetry_optout(self) -> bool: return self._gm.is_telemetry_optout @property def is_cli_autocomplete(self) -> bool: return self._gm.is_cli_autocomplete @property def venv_root(self) -> str | None: return self._gm.venv_root @property def lang_code(self) -> str: return self._lang_code @cached_property def babel_locale(self) -> babel.Locale: try: return babel.Locale.parse(self.lang_code) except UnknownLocaleError: # this can happen in case of unrecognized locale names, which # apparently falls back to "C" return babel.Locale.parse("en_US") @property def cache_root(self) -> os.PathLike[Any]: return self._dirs.app_cache @property def data_root(self) -> os.PathLike[Any]: return self._dirs.app_data @property def state_root(self) -> os.PathLike[Any]: return self._dirs.app_state @cached_property def news_read_status(self) -> "NewsReadStatusStore": from .news import NewsReadStatusStore filename = os.path.join(self.ensure_state_dir(), "news.read.txt") return NewsReadStatusStore(filename) @property def telemetry_root(self) -> os.PathLike[Any]: return pathlib.Path(self.ensure_state_dir()) / "telemetry" @cached_property def telemetry(self) -> "TelemetryProvider": from ..telemetry.provider import TelemetryProvider # for allowing minimal uploads when telemetry is off minimal_mode = self.telemetry_mode == "off" return TelemetryProvider(self, minimal_mode) @property def telemetry_mode(self) -> str: return self._telemetry_mode or DEFAULT_TELEMETRY_MODE @telemetry_mode.setter def telemetry_mode(self, mode: str) -> None: if mode not in ("off", "local", "on"): raise ValueError("telemetry mode must be one of: off, local, on") if self._gm.is_telemetry_optout and mode != "off": raise ValueError( "cannot enable telemetry when the environment variable opt-out is set" ) self._telemetry_mode = mode @property def telemetry_upload_consent_time(self) -> datetime.datetime | None: return self._telemetry_upload_consent @telemetry_upload_consent_time.setter def telemetry_upload_consent_time(self, t: datetime.datetime | None) -> None: self._telemetry_upload_consent = t @property def override_pm_telemetry_url(self) -> str | None: return self._telemetry_pm_telemetry_url @override_pm_telemetry_url.setter def override_pm_telemetry_url(self, url: str | None) -> None: self._telemetry_pm_telemetry_url = url @cached_property def default_repo_dir(self) -> str: # Prefer the new repos/ layout if it exists, otherwise fall back # to the legacy packages-index/ path for pre-migration state. new_path = os.path.join(self.cache_root, "repos", "ruyisdk") if os.path.isdir(new_path): return new_path return os.path.join(self.cache_root, "packages-index") def get_repo_dir(self) -> str: import warnings warnings.warn( "get_repo_dir() is deprecated; use get_repo_dir_for_id(repo_id) instead", DeprecationWarning, stacklevel=2, ) return self.override_repo_dir or self.default_repo_dir def get_repo_dir_for_id(self, repo_id: str) -> str: """Return the local checkout path for a repo identified by ``repo_id``. If the matching ``RepoEntry`` has a ``local_path`` set, that path is returned directly; otherwise the default ``/repos//`` location is used. """ for entry in self.repo_entries: if entry.id == repo_id: return entry.resolve_root(self.cache_root) return os.path.join(self.cache_root, "repos", repo_id) @cached_property def have_overridden_repo_dir(self) -> bool: if not self.override_repo_dir: return False override_path = pathlib.Path(self.override_repo_dir) default_path = pathlib.Path(self.default_repo_dir) # we don't use samefile() here because the path may not exist return override_path.resolve() != default_path.resolve() def get_repo_url(self) -> str: return self.override_repo_url or DEFAULT_REPO_URL def get_repo_branch(self) -> str: return self.override_repo_branch or DEFAULT_REPO_BRANCH @cached_property def repo_entries(self) -> "list[RepoEntry]": from ..ruyipkg.repo import RepoEntry entries = [RepoEntry.from_legacy_config(self)] entries.extend(self._extra_repo_entries) entries.sort(key=lambda e: e.priority) return entries @cached_property def repo(self) -> "CompositeRepo": from ..ruyipkg.composite_repo import CompositeRepo self._ensure_repo_layout_migrated() return CompositeRepo(self.repo_entries, self) @cached_property def default_repo(self) -> "MetadataRepo": """Return the default (ruyisdk) MetadataRepo for code that truly needs a specific MetadataRepo instance. .. deprecated:: Use ``self.repo`` (CompositeRepo) instead for multi-repo-aware access, or iterate ``self.repo_entries`` for specific repos. """ import warnings warnings.warn( "default_repo is deprecated; use the CompositeRepo via .repo instead", DeprecationWarning, stacklevel=2, ) self._ensure_repo_layout_migrated() return self.repo_entries[0].make_metadata_repo(self) def _ensure_repo_layout_migrated(self) -> None: """Run the one-time migration from packages-index/ to repos/ruyisdk/ if the user has not overridden the repo directory.""" if self.override_repo_dir: return from ..ruyipkg.migration import migrate_repo_dir migrate_repo_dir(str(self.cache_root), self.logger) def ensure_distfiles_dir(self) -> str: path = pathlib.Path(self.ensure_cache_dir()) / "distfiles" path.mkdir(parents=True, exist_ok=True) return str(path) def global_binary_install_root(self, host: str, slug: str) -> str: host_path = get_host_path_fragment_for_binary_install_dir(host) path = pathlib.Path(self.ensure_data_dir()) / "binaries" / host_path / slug return str(path) def global_blob_install_root(self, slug: str) -> str: path = pathlib.Path(self.ensure_data_dir()) / "blobs" / slug return str(path) def lookup_binary_install_dir(self, host: str, slug: str) -> PathLike[Any] | None: host_path = get_host_path_fragment_for_binary_install_dir(host) for data_dir in self._dirs.app_data_dirs: p = data_dir.path / "binaries" / host_path / slug if p.exists(): return p return None @property def ruyipkg_state_root(self) -> os.PathLike[Any]: return pathlib.Path(self.ensure_state_dir()) / "ruyipkg" @cached_property def ruyipkg_global_state(self) -> "RuyipkgGlobalStateStore": from ..ruyipkg.state import RuyipkgGlobalStateStore return RuyipkgGlobalStateStore(self.ruyipkg_state_root) def ensure_data_dir(self) -> os.PathLike[Any]: p = self._dirs.app_data p.mkdir(parents=True, exist_ok=True) return p def ensure_cache_dir(self) -> os.PathLike[Any]: p = self._dirs.app_cache p.mkdir(parents=True, exist_ok=True) return p def ensure_config_dir(self) -> os.PathLike[Any]: p = self._dirs.app_config p.mkdir(parents=True, exist_ok=True) return p def ensure_state_dir(self) -> os.PathLike[Any]: p = self._dirs.app_state p.mkdir(parents=True, exist_ok=True) return p def iter_preset_configs(self) -> "Iterable[XDGPathEntry]": """ Yields possible Ruyi config files in all preset config path locations, sorted by precedence from lowest to highest (so that each file may be simply applied consecutively). """ from ..utils.xdg_basedir import XDGPathEntry for path in PRESET_GLOBAL_CONFIG_LOCATIONS: yield XDGPathEntry(pathlib.Path(path), True) def iter_xdg_configs(self) -> "Iterable[XDGPathEntry]": """ Yields possible Ruyi config files in all XDG config paths, sorted by precedence from lowest to highest (so that each file may be simply applied consecutively). """ from ..utils.xdg_basedir import XDGPathEntry entries = list(self._dirs.app_config_dirs) for e in reversed(entries): yield XDGPathEntry(e.path / "config.toml", e.is_global) @property def local_user_config_file(self) -> pathlib.Path: return self._dirs.app_config / "config.toml" def _try_apply_config_file( self, path: os.PathLike[Any], *, is_global_scope: bool, ) -> None: import tomlkit try: with open(path, "rb") as fp: data: Any = tomlkit.load(fp) except FileNotFoundError: return self.logger.D(f"applying config: {data}, is_global_scope={is_global_scope}") self._apply_config(data, is_global_scope=is_global_scope) @classmethod def load_from_config(cls, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> "Self": obj = cls(gm, logger) for config_path, is_global in obj.iter_preset_configs(): obj.logger.D(f"trying config file from preset location: {config_path}") obj._try_apply_config_file(config_path, is_global_scope=is_global) for config_path, is_global in obj.iter_xdg_configs(): obj.logger.D(f"trying config file from XDG path: {config_path}") obj._try_apply_config_file(config_path, is_global_scope=is_global) # let environment variable take precedence if gm.is_telemetry_optout: obj._telemetry_mode = "off" obj._telemetry_upload_consent = None return obj ruyisdk-ruyi-1f00e2e/ruyi/config/editor.py000066400000000000000000000142551520522431500207010ustar00rootroot00000000000000from contextlib import AbstractContextManager import pathlib from typing import Final, Sequence, TYPE_CHECKING, cast if TYPE_CHECKING: from types import TracebackType from typing_extensions import Self import tomlkit from tomlkit.items import AoT, Table from .errors import MalformedConfigFileError, ProtectedGlobalConfigError from .schema import ( SECTION_INSTALLATION, SECTION_REPOS, KEY_INSTALLATION_EXTERNALLY_MANAGED, KEY_REPOS_ID, ensure_valid_config_kv, parse_config_key, validate_section, ) if TYPE_CHECKING: from . import GlobalConfig GLOBAL_ONLY_CONFIG_KEYS: Final[set[tuple[str, str]]] = { (SECTION_INSTALLATION, KEY_INSTALLATION_EXTERNALLY_MANAGED), } """Settings that can only be set in global-scope config files. Changes should be reflected in ``GlobalConfig._apply_config`` too.""" def _is_config_key_global_only(key: str | Sequence[str]) -> bool: parsed_key = parse_config_key(key) if len(parsed_key) != 2: return False section, leaf = parsed_key return (section, leaf) in GLOBAL_ONLY_CONFIG_KEYS class ConfigEditor(AbstractContextManager["ConfigEditor"]): def __init__(self, path: pathlib.Path) -> None: self._path = path self._touched = False try: with open(path) as fp: self._content = tomlkit.load(fp) if not isinstance(self._content, tomlkit.TOMLDocument): raise MalformedConfigFileError(path) except FileNotFoundError: self._content = tomlkit.document() self._stage = cast(tomlkit.TOMLDocument, self._content.copy()) # type: ignore[redundant-cast,unused-ignore] @classmethod def work_on_user_local_config(cls, gc: "GlobalConfig") -> "Self": return cls(gc.local_user_config_file) def __enter__(self) -> "Self": return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: "TracebackType | None", ) -> bool | None: self._commit() return None def _commit(self) -> None: if not self._touched: return self._path.parent.mkdir(parents=True, exist_ok=True) with open(self._path, "w", encoding="utf-8") as fp: tomlkit.dump(self._content, fp) def stage(self) -> None: self._content = self._stage self._touched = True self._stage = cast(tomlkit.TOMLDocument, self._content.copy()) # type: ignore[redundant-cast,unused-ignore] def set_value(self, key: str | Sequence[str], val: object | None) -> None: parsed_key = parse_config_key(key) ensure_valid_config_kv(parsed_key, check_val=True, val=val) # Gate protected settings: user-local config is not allowed to override # global-only settings. if _is_config_key_global_only(parsed_key): # This mechanism is only meant for modifying user-local configs, # because global configs are assumed to be maintained by packagers # and/or sysadmins, who are expected to write the config file by # hand. raise ProtectedGlobalConfigError(parsed_key) section, sel = parsed_key[0], parsed_key[1:] if section in self._stage: existing_section = self._stage[section] if not isinstance(existing_section, Table): raise MalformedConfigFileError(self._path) existing_section.update({sel[0]: val}) else: # append a section with its sole key set to val new_section = tomlkit.table() new_section.append(sel[0], val) self._stage.append(section, new_section) def unset_value(self, key: str | Sequence[str]) -> None: parsed_key = parse_config_key(key) ensure_valid_config_kv(parsed_key, check_val=False) section, sel = parsed_key[0], parsed_key[1:] if existing_section := self._stage.get(section): if not isinstance(existing_section, Table): raise MalformedConfigFileError(self._path) if sel[0] in existing_section: existing_section.pop(sel[0]) def remove_section(self, section: str) -> None: validate_section(section) if section in self._stage: self._stage.pop(section) def add_repos_entry(self, entry: dict[str, object]) -> None: """Append a ``[[repos]]`` table-array entry to the staged config.""" tbl = tomlkit.table() for k, v in entry.items(): tbl.append(k, v) if SECTION_REPOS in self._stage: existing = self._stage[SECTION_REPOS] if isinstance(existing, AoT): existing.append(tbl) else: raise MalformedConfigFileError(self._path) else: aot = tomlkit.aot() aot.append(tbl) self._stage.append(SECTION_REPOS, aot) def remove_repos_entry(self, repo_id: str) -> bool: """Remove the ``[[repos]]`` entry with the given *repo_id*. Returns True if an entry was removed, False if not found.""" if SECTION_REPOS not in self._stage: return False existing = self._stage[SECTION_REPOS] if not isinstance(existing, AoT): raise MalformedConfigFileError(self._path) for i, tbl in enumerate(existing): if tbl.get(KEY_REPOS_ID) == repo_id: del existing[i] if len(existing) == 0: self._stage.pop(SECTION_REPOS) return True return False def update_repos_entry(self, repo_id: str, updates: dict[str, object]) -> bool: """Update fields of the ``[[repos]]`` entry with the given *repo_id*. Returns True if the entry was found and updated, False otherwise.""" if SECTION_REPOS not in self._stage: return False existing = self._stage[SECTION_REPOS] if not isinstance(existing, AoT): raise MalformedConfigFileError(self._path) for tbl in existing: if tbl.get(KEY_REPOS_ID) == repo_id: for k, v in updates.items(): tbl[k] = v return True return False ruyisdk-ruyi-1f00e2e/ruyi/config/errors.py000066400000000000000000000055101520522431500207210ustar00rootroot00000000000000from os import PathLike from typing import Any, Sequence from ..i18n import _ class InvalidConfigSectionError(Exception): def __init__(self, section: str) -> None: super().__init__() self._section = section def __str__(self) -> str: return _("invalid config section: {section}").format(section=self._section) def __repr__(self) -> str: return f"InvalidConfigSectionError({self._section!r})" class InvalidConfigKeyError(Exception): def __init__(self, key: str | Sequence[str]) -> None: super().__init__() self._key = key def __str__(self) -> str: return _("invalid config key: {key}").format(key=self._key) def __repr__(self) -> str: return f"InvalidConfigKeyError({self._key:!r})" class InvalidConfigValueTypeError(TypeError): def __init__( self, key: str | Sequence[str], val: object | None, expected: type | Sequence[type], ) -> None: super().__init__() self._key = key self._val = val self._expected = expected def __str__(self) -> str: return _( "invalid value type for config key {key}: {actual_type}, expected {expected_type}" ).format( key=self._key, actual_type=type(self._val), expected_type=self._expected, ) def __repr__(self) -> str: return f"InvalidConfigValueTypeError({self._key!r}, {self._val!r}, {self._expected:!r})" class InvalidConfigValueError(ValueError): def __init__( self, key: str | Sequence[str] | None, val: object | None, typ: type | Sequence[type], ) -> None: super().__init__() self._key = key self._val = val self._typ = typ def __str__(self) -> str: return _("invalid config value for key {key} (type {typ}): {val}").format( key=self._key, typ=self._typ, val=self._val, ) def __repr__(self) -> str: return ( f"InvalidConfigValueError({self._key:!r}, {self._val:!r}, {self._typ:!r})" ) class MalformedConfigFileError(Exception): def __init__(self, path: PathLike[Any]) -> None: super().__init__() self._path = path def __str__(self) -> str: return _("malformed config file: {path}").format(path=self._path) def __repr__(self) -> str: return f"MalformedConfigFileError({self._path:!r})" class ProtectedGlobalConfigError(Exception): def __init__(self, key: str | Sequence[str]) -> None: super().__init__() self._key = key def __str__(self) -> str: return _("attempt to modify protected global config key: {key}").format( key=self._key, ) def __repr__(self) -> str: return f"ProtectedGlobalConfigError({self._key!r})" ruyisdk-ruyi-1f00e2e/ruyi/config/news.py000066400000000000000000000020451520522431500203610ustar00rootroot00000000000000import os class NewsReadStatusStore: def __init__(self, path: str) -> None: self._path = path self._status: set[str] = set() self._orig_status: set[str] = set() def load(self) -> None: try: with open(self._path, "r", encoding="utf-8") as fp: for line in fp: self._orig_status.add(line.strip()) except FileNotFoundError: return self._status = self._orig_status.copy() def __contains__(self, key: str) -> bool: return key in self._status def add(self, id: str) -> None: return self._status.add(id) def save(self) -> None: if self._status == self._orig_status: return content = "".join(f"{id}\n" for id in self._status) with open(self._path, "w", encoding="utf-8") as fp: fp.write(content) def remove(self) -> None: try: os.unlink(self._path) except FileNotFoundError: # nothing to remove, that's fine pass ruyisdk-ruyi-1f00e2e/ruyi/config/schema.py000066400000000000000000000166151520522431500206550ustar00rootroot00000000000000import datetime from typing import Final, Sequence, TypeGuard from .errors import ( InvalidConfigKeyError, InvalidConfigSectionError, InvalidConfigValueError, InvalidConfigValueTypeError, ) def parse_config_key(key: str | Sequence[str]) -> list[str]: if isinstance(key, str): return key.split(".") return list(key) SECTION_INSTALLATION: Final = "installation" KEY_INSTALLATION_EXTERNALLY_MANAGED: Final = "externally_managed" SECTION_PACKAGES: Final = "packages" KEY_PACKAGES_PRERELEASES: Final = "prereleases" SECTION_REPO: Final = "repo" KEY_REPO_BRANCH: Final = "branch" KEY_REPO_LOCAL: Final = "local" KEY_REPO_REMOTE: Final = "remote" SECTION_REPOS: Final = "repos" KEY_REPOS_ID: Final = "id" KEY_REPOS_NAME: Final = "name" KEY_REPOS_REMOTE: Final = "remote" KEY_REPOS_BRANCH: Final = "branch" KEY_REPOS_LOCAL: Final = "local" KEY_REPOS_PRIORITY: Final = "priority" KEY_REPOS_ACTIVE: Final = "active" SECTION_TELEMETRY: Final = "telemetry" KEY_TELEMETRY_MODE: Final = "mode" KEY_TELEMETRY_PM_TELEMETRY_URL: Final = "pm_telemetry_url" KEY_TELEMETRY_UPLOAD_CONSENT: Final = "upload_consent" def validate_section(section: str) -> None: if section not in ( SECTION_INSTALLATION, SECTION_PACKAGES, SECTION_REPO, SECTION_REPOS, SECTION_TELEMETRY, ): raise InvalidConfigSectionError(section) def get_expected_type_for_config_key(key: str | Sequence[str]) -> type | Sequence[type]: parsed_key = parse_config_key(key) if len(parsed_key) != 2: # for now there's no nested config option raise InvalidConfigKeyError(key) section, sel = parsed_key if section == SECTION_INSTALLATION: return _get_expected_type_for_section_installation(sel) elif section == SECTION_PACKAGES: return _get_expected_type_for_section_packages(sel) elif section == SECTION_REPO: return _get_expected_type_for_section_repo(sel) elif section == SECTION_TELEMETRY: return _get_expected_type_for_section_telemetry(sel) else: raise InvalidConfigKeyError(key) def _get_expected_type_for_section_installation(sel: str) -> type: if sel == KEY_INSTALLATION_EXTERNALLY_MANAGED: return bool else: raise InvalidConfigKeyError(sel) def _get_expected_type_for_section_packages(sel: str) -> type: if sel == KEY_PACKAGES_PRERELEASES: return bool else: raise InvalidConfigKeyError(sel) def _get_expected_type_for_section_repo(sel: str) -> type: if sel == KEY_REPO_BRANCH: return str elif sel == KEY_REPO_LOCAL: return str elif sel == KEY_REPO_REMOTE: return str else: raise InvalidConfigKeyError(sel) def _get_expected_type_for_section_telemetry(sel: str) -> type | tuple[type, ...]: if sel == KEY_TELEMETRY_MODE: return str elif sel == KEY_TELEMETRY_PM_TELEMETRY_URL: return str elif sel == KEY_TELEMETRY_UPLOAD_CONSENT: return (type(None), datetime.datetime) else: raise InvalidConfigKeyError(sel) def _is_all_str(obj: object) -> TypeGuard[Sequence[str]]: if not isinstance(obj, Sequence): return False return all(isinstance(i, str) for i in obj) def _is_all_type(obj: object) -> TypeGuard[Sequence[type]]: if not isinstance(obj, Sequence): return False return all(isinstance(i, type) for i in obj) def ensure_valid_config_kv( key: str | Sequence[str], check_val: bool = False, val: object | None = None, ) -> None: parsed_key = parse_config_key(key) if len(parsed_key) != 2: # for now there's no nested config option raise InvalidConfigKeyError(key) expected_types = get_expected_type_for_config_key(parsed_key) # validity of config key is already checked by get_expected_type_for_config_key ensure_value_type(key, check_val, val, expected_types) if not check_val: return section, sel = parsed_key if section == SECTION_TELEMETRY: return _extra_validate_section_telemetry_kv(key, sel, val) def ensure_value_type( key: str | Sequence[str], check_val: bool, val: object | None, expected: type | Sequence[type], ) -> None: if not check_val: return expected_types: tuple[type, ...] if isinstance(expected, type): expected_types = (expected,) else: expected_types = tuple(expected) for ty in expected_types: if isinstance(val, ty): return raise InvalidConfigValueTypeError(key, val, expected) def _extra_validate_section_telemetry_kv( key: str | Sequence[str], sel: str, val: object | None, ) -> None: if sel == KEY_TELEMETRY_MODE: # value type is already ensured earlier if val not in ("local", "off", "on"): raise InvalidConfigValueError(key, val, str) def encode_value(v: object) -> str: """Encodes the given config value into a string representation suitable for display or storage into TOML config files.""" if v is None: from ..utils.toml import NoneValue raise NoneValue() if isinstance(v, bool): return "true" if v else "false" elif isinstance(v, int): return str(v) elif isinstance(v, str): return v elif isinstance(v, datetime.datetime): if v.tzinfo is None: raise ValueError("only timezone-aware datetimes are supported for safety") s = v.isoformat() if s.endswith("+00:00"): # use the shorter 'Z' suffix for UTC return f"{s[:-6]}Z" return s else: raise NotImplementedError(f"invalid type for config value: {type(v)}") def decode_value( key: str | Sequence[str], val: str, ) -> object: """Decodes the given string representation of a config value into a Python value, directed by type information implied by the config key.""" if isinstance(key, type): return _decode_single_type_value(None, val, key) elif _is_all_type(key): return _decode_typed_value(None, val, key) assert isinstance(key, str) or _is_all_str(key) expected_types = get_expected_type_for_config_key(key) if isinstance(expected_types, type): return _decode_single_type_value(key, val, expected_types) return _decode_typed_value(key, val, expected_types) def _decode_typed_value( key: str | Sequence[str] | None, val: str, expected_types: Sequence[type], ) -> object: for ty in expected_types: try: return _decode_single_type_value(key, val, ty) except (RuntimeError, TypeError, ValueError): continue raise InvalidConfigValueError(key, val, expected_types) def _decode_single_type_value( key: str | Sequence[str] | None, val: str, expected_type: type, ) -> object: if expected_type is bool: if val in ("true", "yes", "1"): return True elif val in ("false", "no", "0"): return False else: raise InvalidConfigValueError(key, val, expected_type) elif expected_type is int: return int(val, 10) elif expected_type is str: return val elif expected_type is datetime.datetime: # datetime.fromisoformat() has supported the 'Z' suffix since Python 3.11 v = datetime.datetime.fromisoformat(val) return v.astimezone() if v.tzinfo is None else v else: raise NotImplementedError(f"unhandled type for config value: {expected_type}") ruyisdk-ruyi-1f00e2e/ruyi/device/000077500000000000000000000000001520522431500170245ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/device/__init__.py000066400000000000000000000000001520522431500211230ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/device/provision.py000066400000000000000000000540311520522431500214310ustar00rootroot00000000000000import itertools import os.path from typing import TYPE_CHECKING, TypedDict, TypeGuard, cast from ..cli import user_input from ..config import GlobalConfig from ..i18n import _ from ..log import RuyiLogger from ..ruyipkg.atom import Atom, ExprAtom, SlugAtom from ..ruyipkg.entity_provider import BaseEntity from ..ruyipkg.host import get_native_host from ..ruyipkg.install import do_install_atoms from ..ruyipkg.pkg_manifest import ( KNOWN_PARTITION_KINDS, PartitionKind, PartitionMapDecl, ) from ..ruyipkg.composite_repo import CompositeRepo from ..utils import mounts, prereqs if TYPE_CHECKING: from ..ruyipkg.pkg_manifest import BoundPackageManifest def get_variant_display_name(dev: BaseEntity, variant: BaseEntity) -> str: """Get the display name of a device variant.""" if n := variant.display_name: return n return f"{dev.display_name} ({variant.data['variant_name']})" def do_provision_interactive(config: GlobalConfig) -> int: log = config.logger # ensure ruyi repo is present, for good out-of-the-box experience mr = config.repo mr.ensure_git_repo() log.stdout( _( """ [bold green]RuyiSDK Device Provisioning Wizard[/] This is a wizard intended to help you install a system on your device for your development pleasure, all with ease. You will be asked some questions that help RuyiSDK understand your device and your intended configuration, then packages will be downloaded and flashed onto the device's storage, that you should somehow make available on this host system beforehand. Note that, as Ruyi does not run as [yellow]root[/], but raw disk access is most likely required to flash images, you should arrange to allow your user account [yellow]sudo[/] access to necessary commands such as [yellow]dd[/]. Flashing will fail if the [yellow]sudo[/] configuration does not allow so. """ ) ) if not user_input.ask_for_yesno_confirmation(log, _("Continue?")): log.stdout( _("\nExiting. You can restart the wizard whenever prepared."), end="\n\n", ) return 1 device_entities = list(mr.entity_store.iter_entities("device")) device_entities.sort(key=lambda x: x.display_name or "") devices_by_id = {x.id: x for x in device_entities} dev_choices = {k: v.display_name or "" for k, v in devices_by_id.items()} dev_id = user_input.ask_for_kv_choice( log, _( "\nThe following devices are currently supported by the wizard. Please pick your device:" ), dev_choices, ) dev = devices_by_id[dev_id] variants = list( mr.entity_store.traverse_related_entities( dev, entity_types=["device-variant"], ) ) variants.sort(key=lambda x: x.data.get("variant_name", "")) variant_choices = [get_variant_display_name(dev, i) for i in variants] variant_idx = user_input.ask_for_choice( log, _( "\nThe device has the following variants. Please choose the one corresponding to your hardware at hand:" ), variant_choices, ) variant = variants[variant_idx] supported_combos = list( mr.entity_store.traverse_related_entities( variant, forward_refs=False, reverse_refs=True, entity_types=["image-combo"], ) ) supported_combos.sort(key=lambda x: x.display_name or "") combo_choices = [combo.display_name or "" for combo in supported_combos] combo_idx = user_input.ask_for_choice( log, _( "\nThe following system configurations are supported by the device variant you have chosen. Please pick the one you want to put on the device:" ), combo_choices, ) combo = supported_combos[combo_idx] return do_provision_combo_interactive(config, mr, dev, variant, combo) def maybe_render_postinst_msg( logger: RuyiLogger, mr: CompositeRepo, combo: BaseEntity, lang_code: str, ) -> bool: if postinst_msgid := combo.data.get("postinst_msgid"): # This field is named just "msgid" so no variables to render for # the retrieved text if msg := mr.messages.get_message_template(postinst_msgid, lang_code): logger.stdout(f"\n{msg}") return True return False def do_provision_combo_interactive( config: GlobalConfig, mr: CompositeRepo, dev_decl: BaseEntity, variant_decl: BaseEntity, combo: BaseEntity, ) -> int: logger = config.logger devid = f"{dev_decl.id}@{variant_decl.id}" logger.D(f"provisioning device variant '{devid}'") # download packages pkg_atoms = combo.data.get("package_atoms", []) if not pkg_atoms: if maybe_render_postinst_msg(logger, mr, combo, config.lang_code): return 0 logger.F( _( "malformed config: device variant '{devid}' asks for no packages but provides no messages either" ).format( devid=devid, ) ) return 1 new_pkg_atoms = customize_package_versions(config, mr, pkg_atoms) if new_pkg_atoms is None: logger.stdout( _("\nExiting. You may restart the wizard at any time."), end="\n\n" ) return 1 else: pkg_atoms = new_pkg_atoms pkg_names_for_display = "\n".join(f" * [green]{i}[/]" for i in pkg_atoms) logger.stdout( _( "\nWe are about to download and install the following packages for your device:" ), end="\n\n", ) logger.stdout(pkg_names_for_display, end="\n\n") if not user_input.ask_for_yesno_confirmation(logger, _("Proceed?")): logger.stdout( _("\nExiting. You may restart the wizard at any time."), end="\n\n" ) return 1 ret = do_install_atoms( config, mr, set(pkg_atoms), canonicalized_host=get_native_host(), fetch_only=False, reinstall=False, ) if ret != 0: logger.F(_("failed to download and install packages")) logger.I(_("your device was not touched")) return 2 strat_provider = ProvisionStrategyProvider(mr) strategies = [ (pkg, get_pkg_provision_strategy(strat_provider, mr, pkg)) for pkg in pkg_atoms ] strategies.sort(key=lambda x: x[1].priority, reverse=True) # compose a partition map for each image pkg installed pkg_part_maps = {pkg: make_pkg_part_map(config, mr, pkg) for pkg in pkg_atoms} all_parts: list[PartitionKind] = [] for pkg_part_map in pkg_part_maps.values(): all_parts.extend(pkg_part_map.keys()) # prompt user to give paths to target block device(s) requested_host_blkdevs: list[PartitionKind] = [] for pkg, strat in strategies: requested_host_blkdevs.extend(strat.need_host_blkdevs(all_parts)) host_blkdev_map: PartitionMapDecl = {} if requested_host_blkdevs: logger.stdout( _( """ For initializing this target device, you should plug into this host system the device's storage (e.g. SD card or NVMe SSD), or a removable disk to be reformatted as a live medium, and note down the corresponding device file path(s), e.g. /dev/sdX, /dev/nvmeXnY for whole disks; /dev/sdXY, /dev/nvmeXnYpZ for partitions. You may consult e.g. [yellow]sudo blkid[/] output for the information you will need later. """ ) ) for part in requested_host_blkdevs: part_desc = get_part_desc(part) while True: path = user_input.ask_for_file( logger, _("Please give the path for the {part_desc}:").format( part_desc=part_desc, ), ) # Retrieve the latest mount info in case the user un-mounts # on seeing the prompt all_mounts = mounts.parse_mounts() blkdev_mounts = [m for m in all_mounts if m.source_is_blkdev] path_valid = True for m in blkdev_mounts: if m.source_path.samefile(path): logger.W( _( "path [cyan]'{path}'[/] is currently mounted at [yellow]'{target}'[/]" ).format( path=path, target=m.target, ) ) logger.I( _( "rejecting the path for safety; please double-check and retry" ) ) path_valid = False break if path_valid: break host_blkdev_map[part] = path # final confirmation logger.stdout( _( """ We have collected enough information for the actual flashing. Now is the last chance to re-check and confirm everything is fine. We are about to: """ ) ) pretend_steps = "\n".join( f" * {step_str}" for step_str in itertools.chain( *( strat[1].pretend(pkg_part_maps[strat[0]], host_blkdev_map) for strat in strategies ) ) ) logger.stdout(pretend_steps, end="\n\n") if not user_input.ask_for_yesno_confirmation(logger, _("Proceed with flashing?")): logger.stdout( _( "\nExiting. The device is not touched and you may re-start the wizard at will." ), end="\n\n", ) return 1 # ensure commands all_needed_cmds = set(itertools.chain(*(strat.need_cmd for _, strat in strategies))) if all_needed_cmds: prereqs.ensure_cmds(logger, all_needed_cmds, interactive_retry=True) if "fastboot" in all_needed_cmds: # ask the user to ensure the device shows up # TODO: automate doing so logger.stdout( _(""" Some flashing steps require the use of fastboot, in which case you should ensure the target device is showing up in [yellow]fastboot devices[/] output. Please [bold red]confirm it yourself before continuing[/]. """) ) if not user_input.ask_for_yesno_confirmation( logger, _("Is the device identified by fastboot now?"), ): logger.stdout( _( "\nAborting. The device is not touched. You may re-start the wizard after [yellow]fastboot[/] is fixed for the device." ), end="\n\n", ) return 1 # flash for pkg, strat in strategies: logger.D(f"flashing {pkg} with strategy {strat}") ret = strat.flash(pkg_part_maps[pkg], host_blkdev_map) if ret != 0: logger.F(_("flashing failed, check your device right now")) return ret # parting words logger.stdout( _(""" It seems the flashing has finished without errors. [bold green]Happy hacking![/] """) ) maybe_render_postinst_msg(logger, mr, combo, config.lang_code) return 0 def get_part_desc(part: PartitionKind) -> str: match part: case "disk": return _("target's whole disk") case "live": return _("removable disk to use as live medium") case _: return _("target's '{part}' partition").format(part=part) class PackageProvisionStrategyDecl(TypedDict): priority: int # higher number means earlier need_host_blkdevs_fn: object # Callable[[list[PartitionKind]], list[PartitionKind]] need_cmd: list[str] pretend_fn: object # Callable[[PartitionMapDecl, PartitionMapDecl], list[str]] flash_fn: object # Callable[[PartitionMapDecl, PartitionMapDecl], int] def validate_list_str(x: object) -> TypeGuard[list[str]]: if not isinstance(x, list): return False x = cast(list[object], x) return all(isinstance(y, str) for y in x) def validate_list_partition_kinds(x: object) -> TypeGuard[list[PartitionKind]]: if not isinstance(x, list): return False x = cast(list[object], x) for item in x: if not isinstance(item, str) or item not in KNOWN_PARTITION_KINDS: return False return True class PackageProvisionStrategy: def __init__( self, decl: PackageProvisionStrategyDecl, mr: CompositeRepo, ) -> None: self._d = decl self._mr = mr @property def priority(self) -> int: return self._d["priority"] @property def need_cmd(self) -> list[str]: return self._d["need_cmd"] def need_host_blkdevs(self, x: list[PartitionKind]) -> list[PartitionKind]: result = self._mr.eval_plugin_fn(self._d["need_host_blkdevs_fn"], x) if not validate_list_partition_kinds(result): raise TypeError("need_host_blkdevs_fn must return list[PartitionKind]") return result def pretend( self, img_paths: PartitionMapDecl, blkdev_paths: PartitionMapDecl, ) -> list[str]: result = self._mr.eval_plugin_fn(self._d["pretend_fn"], img_paths, blkdev_paths) if not validate_list_str(result): raise TypeError("pretend_fn must return list[str]") return result def flash( self, img_paths: PartitionMapDecl, blkdev_paths: PartitionMapDecl, ) -> int: result = self._mr.eval_plugin_fn(self._d["flash_fn"], img_paths, blkdev_paths) if not isinstance(result, int): raise TypeError("flash_fn must return int") return result class ProvisionStrategyProvider: def __init__(self, mr: CompositeRepo) -> None: self._mr = mr self._strats: dict[str, PackageProvisionStrategy] = {} # import the "standard library" of strategies self._import_strategy_plugin("std") def _import_strategy_plugin(self, plugin_pkg_name: str) -> None: plugin_id = f"ruyi-device-provision-strategy-{plugin_pkg_name}" provided_strats = self._mr.get_from_plugin( plugin_id, "PROVIDED_DEVICE_PROVISION_STRATEGIES_V1", ) if not isinstance(provided_strats, dict): raise RuntimeError( f"malformed device provisioner strategy plugin '{plugin_id}'" ) for name, decl in provided_strats.items(): self._strats[name] = PackageProvisionStrategy(decl, self._mr) def __getitem__(self, name: str) -> PackageProvisionStrategy: try: return self._strats[name] except KeyError: # for now it's "ruyi-device-provision-strategy-STRATEGY-NAME" # we may have to revise before Ruyi v1.0 though self._import_strategy_plugin(name) return self._strats[name] def get_pkg_provision_strategy( strat_provider: ProvisionStrategyProvider, mr: CompositeRepo, atom: str, ) -> PackageProvisionStrategy: a = Atom.parse(atom) pm = a.match_in_repo(mr, True) assert pm is not None pmd = pm.provisionable_metadata assert pmd is not None return strat_provider[pmd.strategy] def make_pkg_part_map( config: GlobalConfig, mr: CompositeRepo, atom: str, ) -> PartitionMapDecl: a = Atom.parse(atom) pm = a.match_in_repo(mr, True) assert pm is not None pkg_root = config.global_blob_install_root(pm.name_for_installation) pmd = pm.provisionable_metadata assert pmd is not None return {p: os.path.join(pkg_root, f) for p, f in pmd.partition_map.items()} def is_package_version_customization_possible( gc: GlobalConfig, mr: CompositeRepo, pkg_atoms: list[str], ) -> bool: """ Check if package version customization is possible, which means there are at least one package atom specified that matches more than one versions. """ for atom_str in pkg_atoms: # Get all available versions for this package a = Atom.parse(atom_str) try: if len(list(a.iter_in_repo(mr, gc.include_prereleases))) > 1: return True except KeyError: continue return False def customize_package_versions( config: GlobalConfig, mr: CompositeRepo, pkg_atoms: list[str], ) -> list[str] | None: """ Allow the user to customize the versions of packages to be installed. Returns a new list of package atoms with user-selected versions. """ if not is_package_version_customization_possible(config, mr, pkg_atoms): return pkg_atoms logger = config.logger # Ask if the user wants to customize package versions logger.stdout( _( "By default, we'll install the latest version of each package, but in this case, other choices are possible." ) ) if not user_input.ask_for_yesno_confirmation( logger, _("Would you like to customize package versions?"), ): return pkg_atoms while True: # Loop to allow restarting the selection process result: list[str] = [] logger.stdout(_("\n[bold]Package Version Selection[/]")) for atom_str in pkg_atoms: # Parse the atom to get package name a = Atom.parse(atom_str) if isinstance(a, ExprAtom): # If it's already an expression with version constraints, show the constraints logger.stdout( _( "\nPackage [green]{atom}[/] already has version constraints." ).format( atom=atom_str, ) ) if not user_input.ask_for_yesno_confirmation( logger, _("Would you like to change them?"), ): result.append(atom_str) continue elif isinstance(a, SlugAtom): # Slugs already fix the version, so we can't change them logger.W( _( "version cannot be overridden for slug atom [green]{atom}[/]" ).format( atom=atom_str, ) ) result.append(atom_str) continue # Get all available versions for this package package_name = a.name category = a.category pkg_fullname = f"{category}/{package_name}" if category else package_name available_versions: "list[BoundPackageManifest]" = [] try: available_versions = list(mr.iter_pkg_vers(package_name, category)) except KeyError: logger.W( _("could not find package [yellow]{pkg}[/] in repository").format( pkg=pkg_fullname ) ) result.append(atom_str) if not available_versions: logger.W( _("no versions found for package [yellow]{pkg}[/]").format( pkg=pkg_fullname ) ) result.append(atom_str) continue if len(available_versions) == 1: # If there's only one version available, use it selected_version = available_versions[0] logger.stdout( _( "Only one version available for [green]{pkg}[/]: [blue]{ver}[/], using it." ).format( pkg=pkg_fullname, ver=selected_version.ver, ) ) result.append(atom_str) continue # Sort versions with newest first available_versions.sort(key=lambda pm: pm.semver, reverse=True) # Create a list of version choices for display version_choices = [] for pm in available_versions: version_str = str(pm.semver) remarks = [] if pm.is_prerelease: remarks.append(_("prerelease")) if pm.service_level.has_known_issues: remarks.append(_("has known issues")) if pm.upstream_version: remarks.append( _("upstream: {upstream_ver}").format( upstream_ver=pm.upstream_version ) ) remark_str = f" ({', '.join(remarks)})" if remarks else "" version_choices.append(f"{version_str}{remark_str}") # Ask the user to select a version version_idx = user_input.ask_for_choice( logger, _("\nSelect a version for package [green]{pkg}[/]:").format( pkg=pkg_fullname, ), version_choices, ) selected_version = available_versions[version_idx] # Create the new atom string with the selected version if category: new_atom = f"{category}/{package_name}(=={selected_version.ver})" else: new_atom = f"{package_name}(=={selected_version.ver})" logger.stdout(_("Selected: [blue]{new_atom}[/]").format(new_atom=new_atom)) result.append(new_atom) logger.stdout(_("\nPackage versions to be installed:")) for atom in result: logger.stdout(f" * [green]{atom}[/]") confirmation = user_input.ask_for_choice( logger, _("\nHow would you like to proceed?"), [ _("Continue with these versions"), _("Restart version selection"), _("Abort device provisioning"), ], ) if confirmation == 0: # Continue with these versions return result elif confirmation == 1: # Restart version selection logger.stdout(_("\nRestarting package version selection...")) continue else: # Abort installation return None ruyisdk-ruyi-1f00e2e/ruyi/device/provision_cli.py000066400000000000000000000021031520522431500222510ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class DeviceCommand( RootCommand, cmd="device", has_subcommands=True, help=_("Manage devices"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass class DeviceProvisionCommand( DeviceCommand, cmd="provision", aliases=["flash"], help=_("Interactively initialize a device for development"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .provision import do_provision_interactive try: return do_provision_interactive(cfg) except KeyboardInterrupt: cfg.logger.stdout( _("\n\nKeyboard interrupt received, exiting."), end="\n\n" ) return 1 ruyisdk-ruyi-1f00e2e/ruyi/i18n/000077500000000000000000000000001520522431500163445ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/i18n/__init__.py000066400000000000000000000060111520522431500204530ustar00rootroot00000000000000from io import BytesIO import gettext import os from typing import Final, LiteralString, Mapping, NewType from ..resource_bundle import get_resource_blob def _probe_lang(environ: Mapping[str, str]) -> list[str]: """Probe the environment variables the gettext way, to determine the list of preferred languages.""" languages: list[str] = [] # check the variables in this order for envar in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"): if val := environ.get(envar): languages = val.split(":") break if "C" not in languages: languages.append("C") for i, lang in enumerate(languages): # normalize things like en_US.UTF-8 to en_US if "." in lang: languages[i] = lang.split(".", 1)[0] return languages _DOMAINS = ( "argparse", "ruyi", ) """gettext domains we supply and use ourselves""" class I18nAdapter: """Adapter for gettext translation functions.""" def __init__(self) -> None: self._t = gettext.NullTranslations() def hook(self) -> None: # monkey-patch the global gettext functions # the type ignore comments are necessary because mypy doesn't see # the bounded methods as compatible with the unbound functions # (it doesn't remove self from the unbound method signature) gettext.gettext = self.gettext # type: ignore[assignment] gettext.ngettext = self.ngettext # type: ignore[assignment] def init_from_env(self, environ: Mapping[str, str] | None = None) -> None: if environ is None: environ = os.environ langs = _probe_lang(environ) for domain in _DOMAINS: for lang in langs: if self.set_locale(domain, lang): break def _get_mo(self, domain: str, locale: str) -> BytesIO | None: # this is always forward-slash-separated, because this is not a concrete # filesystem path, rather a resource bundle key path = f"locale/{locale}/LC_MESSAGES/{domain}.mo" blob = get_resource_blob(path) if blob: return BytesIO(blob) return None def set_locale(self, domain: str, locale: str | None = None) -> bool: if locale is not None: if mo_file := self._get_mo(domain, locale): self._t.add_fallback(gettext.GNUTranslations(mo_file)) return True return False def gettext(self, x: str) -> str: return self._t.gettext(x) def ngettext(self, singular: str, plural: str, n: int) -> str: return self._t.ngettext(singular, plural, n) ADAPTER: Final = I18nAdapter() DeferredI18nString = NewType("DeferredI18nString", str) def _(x: LiteralString | DeferredI18nString) -> str: """``gettext`` alias that ensures its input is string literal via type signature.""" return ADAPTER.gettext(x) def d_(x: LiteralString) -> DeferredI18nString: """Mark a string literal for deferred translation: call ``_`` at use sites.""" return DeferredI18nString(x) ruyisdk-ruyi-1f00e2e/ruyi/log/000077500000000000000000000000001520522431500163465ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/log/__init__.py000066400000000000000000000152031520522431500204600ustar00rootroot00000000000000import abc import datetime from functools import cached_property import io import sys import time from typing import Any, TextIO, TYPE_CHECKING if TYPE_CHECKING: # too heavy at package import time from rich.console import Console, RenderableType from rich.text import Text from ..i18n import _ from ..utils.global_mode import ProvidesGlobalMode from ..utils.porcelain import PorcelainEntity, PorcelainEntityType, PorcelainOutput class PorcelainLog(PorcelainEntity): t: int """Timestamp of the message line in microseconds""" lvl: str """Log level of the message line (one of D, F, I, W)""" msg: str """Message content""" def log_time_formatter(x: datetime.datetime) -> "Text": from rich.text import Text return Text(f"debug: [{x.isoformat()}]") def _make_porcelain_log( t: int, lvl: str, message: "RenderableType", sep: str, *objects: Any, ) -> PorcelainLog: from rich.console import Console with io.StringIO() as buf: tmp_console = Console(file=buf) tmp_console.print(message, *objects, sep=sep, end="") return { "ty": PorcelainEntityType.LogV1, "t": t, "lvl": lvl, "msg": buf.getvalue(), } class RuyiLogger(metaclass=abc.ABCMeta): def __init__(self) -> None: pass @property @abc.abstractmethod def log_console(self) -> "Console": raise NotImplementedError @abc.abstractmethod def stdout( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: raise NotImplementedError @abc.abstractmethod def D( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", _stack_offset_delta: int = 0, ) -> None: raise NotImplementedError @abc.abstractmethod def F( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: raise NotImplementedError @abc.abstractmethod def I( # noqa: E743 # the name intentionally mimics Android logging for brevity self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: raise NotImplementedError @abc.abstractmethod def W( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: raise NotImplementedError class RuyiConsoleLogger(RuyiLogger): def __init__( self, gm: ProvidesGlobalMode, stdout: TextIO = sys.stdout, stderr: TextIO = sys.stderr, ) -> None: super().__init__() self._gm = gm self._stdout = stdout self._stderr = stderr @cached_property def _stdout_console(self) -> "Console": from rich.console import Console return Console( file=self._stdout, highlight=False, soft_wrap=True, ) @cached_property def _debug_console(self) -> "Console": from rich.console import Console return Console( file=self._stderr, log_time_format=log_time_formatter, soft_wrap=True, ) @cached_property def _log_console(self) -> "Console": from rich.console import Console return Console( file=self._stderr, highlight=False, soft_wrap=True, ) @cached_property def _porcelain_sink(self) -> PorcelainOutput: if isinstance(self._stderr, io.TextIOWrapper): return PorcelainOutput(binary_out=self._stderr.buffer) return PorcelainOutput(text_out=self._stderr) @property def log_console(self) -> "Console": return self._log_console def _emit_porcelain_log( self, lvl: str, message: "RenderableType", sep: str = " ", *objects: Any, ) -> None: t = int(time.time() * 1000000) obj = _make_porcelain_log(t, lvl, message, sep, *objects) self._porcelain_sink.emit(obj) def stdout( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: return self._stdout_console.print(message, *objects, sep=sep, end=end) def D( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", _stack_offset_delta: int = 0, ) -> None: if not self._gm.is_debug: return if self._gm.is_porcelain: return self._emit_porcelain_log("D", message, sep, *objects) return self._debug_console.log( message, *objects, sep=sep, end=end, _stack_offset=2 + _stack_offset_delta, ) def F( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: if self._gm.is_porcelain: return self._emit_porcelain_log("F", message, sep, *objects) return self.log_console.print( _("[bold red]fatal error:[/] {message}").format(message=message), *objects, sep=sep, end=end, ) def I( # noqa: E743 # the name intentionally mimics Android logging for brevity self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: if self._gm.is_porcelain: return self._emit_porcelain_log("I", message, sep, *objects) return self.log_console.print( _("[bold green]info:[/] {message}").format(message=message), *objects, sep=sep, end=end, ) def W( self, message: "RenderableType", *objects: Any, sep: str = " ", end: str = "\n", ) -> None: if self._gm.is_porcelain: return self._emit_porcelain_log("W", message, sep, *objects) return self.log_console.print( _("[bold yellow]warn:[/] {message}").format(message=message), *objects, sep=sep, end=end, ) def humanize_list( obj: list[str] | set[str], *, sep: str = ", ", item_color: str | None = None, empty_prompt: str | None = None, ) -> str: if not obj: return empty_prompt if empty_prompt is not None else _("(none)") if item_color is None: return sep.join(obj) return sep.join(f"[{item_color}]{x}[/]" for x in obj) ruyisdk-ruyi-1f00e2e/ruyi/mux/000077500000000000000000000000001520522431500163765ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/mux/.gitignore000066400000000000000000000000061520522431500203620ustar00rootroot00000000000000!venv ruyisdk-ruyi-1f00e2e/ruyi/mux/__init__.py000066400000000000000000000000001520522431500204750ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/mux/runtime.py000066400000000000000000000164551520522431500204460ustar00rootroot00000000000000import atexit import os import re import shlex from typing import Final, List, NoReturn from ..config import GlobalConfig from ..i18n import _ from ..utils.global_mode import ProvidesGlobalMode from .venv_cfg import RuyiVenvConfig def _run_exit_handlers_and_execv( path: str, argv: list[str], ) -> NoReturn: # run all exit handlers before execv # crucially this includes our telemetry handler so we don't lose telemetry # events in mux mode atexit._run_exitfuncs() os.execv(path, argv) def mux_main( gm: ProvidesGlobalMode, gc: GlobalConfig, argv: List[str], ) -> int | NoReturn: basename = os.path.basename(gm.argv0) logger = gc.logger logger.D(f"mux mode: argv = {argv}, basename = {basename}") vcfg = RuyiVenvConfig.load_from_venv(gm, logger) if vcfg is None: logger.F(_("the Ruyi toolchain mux is not configured")) logger.I(_("check out `ruyi venv` for making a virtual environment")) return 1 direct_symlink_target = resolve_direct_symlink_target(gm.argv0, vcfg) if direct_symlink_target is not None: logger.D( f"detected direct symlink target: {direct_symlink_target}, overriding basename" ) basename = direct_symlink_target if basename == "ruyi-qemu": return mux_qemu_main(gc, vcfg, argv) # match the basename with one of the configured target tuples target_tuple: str | None = None binpath: str | None = None toolchain_sysroot: str | None = None toolchain_flags: str | None = None gcc_install_dir: str | None = None # prefer v1+ cached info which is lossless if md := vcfg.resolve_cmd_metadata_with_cache(basename): target_tuple = md["target_tuple"] binpath = md["dest"] if target_tuple: tgt_data = vcfg.targets.get(target_tuple) if tgt_data is None: logger.F( _( "internal error: no target data for tuple [yellow]{target_tuple}[/]" ).format( target_tuple=target_tuple, ) ) return 1 toolchain_sysroot = tgt_data.get("toolchain_sysroot") toolchain_flags = tgt_data.get("toolchain_flags") gcc_install_dir = tgt_data.get("gcc_install_dir") else: toolchain_bindir: str | None = None for tgt_tuple, tgt_data in vcfg.targets.items(): if not basename.startswith(f"{tgt_tuple}-"): continue logger.D(f"matched target '{tgt_tuple}', data {tgt_data}") target_tuple = tgt_tuple toolchain_bindir = tgt_data["toolchain_bindir"] toolchain_sysroot = tgt_data.get("toolchain_sysroot") toolchain_flags = tgt_data.get("toolchain_flags") gcc_install_dir = tgt_data.get("gcc_install_dir") break if toolchain_bindir is None: # should not happen logger.F( _( "internal error: no bindir configured for target [yellow]{target_tuple}[/]" ).format( target_tuple=target_tuple, ) ) return 1 binpath = os.path.join(toolchain_bindir, basename) if target_tuple is None: logger.F( _("no configured target found for command [yellow]{basename}[/]").format( basename=basename, ) ) return 1 logger.D(f"binary to exec: {binpath}") argv_to_insert: list[str] | None = None if is_proxying_to_cc(basename): logger.D(f"{basename} is considered a CC") argv_to_insert = [] if is_proxying_to_clang(basename): logger.D(f"adding target for clang: {target_tuple}") argv_to_insert.append(f"--target={target_tuple}") if gcc_install_dir is not None: logger.D(f"informing clang of GCC install dir: {gcc_install_dir}") argv_to_insert.append(f"--gcc-install-dir={gcc_install_dir}") if toolchain_flags is not None: argv_to_insert.extend(shlex.split(toolchain_flags)) logger.D(f"parsed toolchain flags: {argv_to_insert}") if toolchain_sysroot is not None: logger.D(f"adding sysroot: {toolchain_sysroot}") argv_to_insert.extend(("--sysroot", toolchain_sysroot)) new_argv = [binpath] if argv_to_insert: new_argv.extend(argv_to_insert) if len(argv) > 1: new_argv.extend(argv[1:]) ensure_venv_in_path(vcfg) logger.D(f"exec-ing with argv {new_argv}") return _run_exit_handlers_and_execv(binpath, new_argv) # TODO: dedup with venv provision logic (into a command name parser) CC_ARGV0_RE: Final = re.compile( r"(?:^|-)(?:g?cc|c\+\+|g\+\+|cpp|clang|clang\+\+|clang-cl|clang-cpp)(?:-[0-9.]+)?$" ) def resolve_direct_symlink_target(argv0: str, vcfg: RuyiVenvConfig) -> str | None: direct_symlink_target = resolve_argv0_symlink(argv0, vcfg) if direct_symlink_target is not None and os.path.sep in direct_symlink_target: # we're not designed to handle such indirections return None return direct_symlink_target def resolve_argv0_symlink(argv0: str, vcfg: RuyiVenvConfig) -> str | None: if os.path.sep in argv0: # argv[0] contains path information that we can just use try: return os.readlink(argv0) except OSError: # argv[0] is not a symlink return None # argv[0] is bare command name, in which case we expect venv root to # be available, so we can just check f'{venv_root}/bin/{argv[0]}'. # we're guaranteed a venv_root because of the vcfg init logic. try: return os.readlink(vcfg.venv_root / "bin" / argv0) except OSError: return None def is_proxying_to_cc(argv0: str) -> bool: return CC_ARGV0_RE.search(argv0) is not None def is_proxying_to_clang(basename: str) -> bool: return "clang" in basename def mux_qemu_main( gc: GlobalConfig, vcfg: RuyiVenvConfig, argv: List[str], ) -> int | NoReturn: logger = gc.logger binpath = vcfg.qemu_bin if binpath is None: logger.F(_("this virtual environment has no QEMU-like emulator configured")) return 1 if vcfg.profile_emu_env is not None: logger.D(f"seeding QEMU environment with {vcfg.profile_emu_env}") for k, v in vcfg.profile_emu_env.items(): os.environ[k] = v logger.D(f"QEMU binary to exec: {binpath}") new_argv = [binpath] if len(argv) > 1: new_argv.extend(argv[1:]) logger.D(f"exec-ing with argv {new_argv}") return _run_exit_handlers_and_execv(binpath, new_argv) def ensure_venv_in_path(vcfg: RuyiVenvConfig) -> None: venv_root = vcfg.venv_root venv_bindir = venv_root / "bin" venv_bindir = venv_bindir.resolve() orig_path = os.environ.get("PATH", "") for p in orig_path.split(os.pathsep): try: if os.path.samefile(p, venv_bindir): # TODO: what if our bindir actually comes after the system ones? return except FileNotFoundError: # maybe the PATH entry is stale continue # we're not in PATH, so prepend the bindir to PATH os.environ["PATH"] = f"{venv_bindir}:{orig_path}" if orig_path else str(venv_bindir) ruyisdk-ruyi-1f00e2e/ruyi/mux/venv/000077500000000000000000000000001520522431500173545ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/mux/venv/__init__.py000066400000000000000000000000001520522431500214530ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/mux/venv/emulator_cfg.py000066400000000000000000000021451520522431500223770ustar00rootroot00000000000000import os from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Self from ...ruyipkg.pkg_manifest import EmulatorProgDecl from ...ruyipkg.profile import ProfileProxy class ResolvedEmulatorProg: def __init__( self, display_name: str, binfmt_misc_str: str | None, env: dict[str, str] | None, ) -> None: self.display_name = display_name self.binfmt_misc_str = binfmt_misc_str self.env = env @classmethod def new( cls, prog: EmulatorProgDecl, prog_install_root: os.PathLike[Any], profile: ProfileProxy, sysroot: os.PathLike[Any] | None, ) -> "Self": return cls( get_display_name_for_emulator(prog, prog_install_root), prog.get_binfmt_misc_str(prog_install_root), profile.get_env_config_for_emu_flavor(prog.flavor, sysroot), ) def get_display_name_for_emulator( prog: EmulatorProgDecl, prog_install_root: os.PathLike[Any], ) -> str: return f"{os.path.basename(prog.relative_path)} from {prog_install_root}" ruyisdk-ruyi-1f00e2e/ruyi/mux/venv/maker.py000066400000000000000000001171271520522431500210360ustar00rootroot00000000000000from dataclasses import dataclass import enum import glob import os from os import PathLike import pathlib import re import shutil import stat from typing import Any, Final, Iterator, TypedDict from ...config import GlobalConfig from ...i18n import _ from ...log import RuyiLogger, humanize_list from ...ruyipkg.atom import Atom from ...ruyipkg.pkg_manifest import BoundPackageManifest, EmulatorProgDecl from ...ruyipkg.profile import ProfileProxy from ...utils.global_mode import ProvidesGlobalMode from ...utils.l10n import match_lang_code from ...utils.templating import render_template_str from .emulator_cfg import ResolvedEmulatorProg class ConfiguredTargetTuple(TypedDict): target: str toolchain_root: PathLike[Any] toolchain_sysroot: PathLike[Any] | None toolchain_flags: str binutils_flavor: str cc_flavor: str gcc_install_dir: PathLike[Any] | None class ResolvedSysrootPkgSource(TypedDict): sysroot_dir: PathLike[Any] pkg_manifest: BoundPackageManifest gcc_install_dir: PathLike[Any] | None class SysrootProvisionMode(enum.Enum): COPY_TREE = "copy-tree" SYMLINK_TREE = "symlink-tree" PROJECT_ROOTFS = "project-rootfs" class VenvProvisionError(Exception): pass PROJECTED_SYSROOT_ROOTS: Final = ( "include", "lib", "lib32", "lib64", "usr/include", "usr/lib", "usr/lib32", "usr/lib64", "usr/libexec", "usr/share", "bin", "sbin", ) @dataclass class SysrootProjectionReport: copied_entries: int = 0 skipped_entries: int = 0 included_roots: int = 0 def _count_copytree_failures(exc: shutil.Error) -> int: if not exc.args: return 1 failures = exc.args[0] if isinstance(failures, list): return len(failures) return 1 def _is_projected_rootfs_relpath(relpath: pathlib.PurePosixPath) -> bool: for root in PROJECTED_SYSROOT_ROOTS: projected_root = pathlib.PurePosixPath(root) if relpath == projected_root or projected_root in relpath.parents: return True return False def _copy_projected_sysroot_symlink( source_root: pathlib.Path, dest_root: pathlib.Path, src: pathlib.Path, dest: pathlib.Path, ) -> None: target = os.readlink(src) if not target.startswith("/"): os.symlink(target, dest) return target_relpath = pathlib.PurePosixPath(target.removeprefix("/")) target_in_source = source_root.joinpath(*target_relpath.parts) if ( _is_projected_rootfs_relpath(target_relpath) and (target_in_source.exists() or target_in_source.is_symlink()) ): target_in_dest = dest_root.joinpath(*target_relpath.parts) os.symlink(os.path.relpath(target_in_dest, dest.parent), dest) return os.symlink(target, dest) def _copy_projected_sysroot_entry( logger: RuyiLogger, source_root: pathlib.Path, dest_root: pathlib.Path, src: pathlib.Path, dest: pathlib.Path, report: SysrootProjectionReport, ) -> None: try: mode = src.lstat().st_mode except OSError as e: report.skipped_entries += 1 logger.D(f"skipping sysroot entry {src}: {e}") return try: if stat.S_ISLNK(mode): dest.parent.mkdir(parents=True, exist_ok=True) _copy_projected_sysroot_symlink(source_root, dest_root, src, dest) report.copied_entries += 1 return if stat.S_ISDIR(mode): dest.mkdir(parents=True, exist_ok=True) report.copied_entries += 1 try: children = list(src.iterdir()) except OSError as e: report.skipped_entries += 1 logger.D(f"skipping sysroot directory contents {src}: {e}") return for child in children: _copy_projected_sysroot_entry( logger, source_root, dest_root, child, dest / child.name, report, ) return if stat.S_ISREG(mode): dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest, follow_symlinks=False) report.copied_entries += 1 return except OSError as e: report.skipped_entries += 1 logger.D(f"skipping sysroot entry {src}: {e}") return report.skipped_entries += 1 logger.D(f"skipping unsupported sysroot entry {src}") def _project_rootfs_sysroot( logger: RuyiLogger, src: pathlib.Path, dest: pathlib.Path, ) -> SysrootProjectionReport: report = SysrootProjectionReport() dest.mkdir(parents=True, exist_ok=False) for root in PROJECTED_SYSROOT_ROOTS: root_src = src / root if not root_src.exists() and not root_src.is_symlink(): continue report.included_roots += 1 _copy_projected_sysroot_entry( logger, src, dest, root_src, dest / root, report, ) return report def provision_sysroot( logger: RuyiLogger, src: pathlib.Path, dest: pathlib.Path, mode: SysrootProvisionMode, target_tuple: str, ) -> None: if mode == SysrootProvisionMode.SYMLINK_TREE: logger.D(f"symlinking sysroot for {target_tuple}") os.symlink(src, dest) return if mode == SysrootProvisionMode.COPY_TREE: logger.D(f"copying sysroot for {target_tuple}") try: shutil.copytree( src, dest, symlinks=True, ignore_dangling_symlinks=True, ) except shutil.Error as e: failure_count = _count_copytree_failures(e) if failure_count == 1: reason = _("one entry could not be copied") else: reason = _("{count} entries could not be copied").format( count=failure_count, ) logger.F( _( "cannot copy sysroot from [yellow]{src}[/]: {reason}" ).format( src=src, reason=reason, ) ) logger.I( _( "Ruyi does not elevate privileges when creating virtual environments; use a sysroot readable by the current user, --symlink-sysroot-from-dir, or --project-sysroot-from-rootfs" ) ) logger.D(f"sysroot copy failure details: {e}") raise VenvProvisionError from e except OSError as e: logger.F( _("cannot copy sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}").format( src=src, dest=dest, err=e, ) ) raise VenvProvisionError from e return if mode == SysrootProvisionMode.PROJECT_ROOTFS: logger.D(f"projecting rootfs sysroot for {target_tuple}") try: report = _project_rootfs_sysroot(logger, src, dest) except OSError as e: logger.F( _("cannot project sysroot from [yellow]{src}[/] to [green]{dest}[/]: {err}").format( src=src, dest=dest, err=e, ) ) raise VenvProvisionError from e if report.included_roots == 0: logger.F( _( "cannot project sysroot from [yellow]{src}[/]: no supported sysroot directories were found" ).format(src=src) ) raise VenvProvisionError logger.I( _( "projected sysroot from [yellow]{src}[/]: copied {copied_count} entries, skipped {skipped_count} entries" ).format( src=src, copied_count=report.copied_entries, skipped_count=report.skipped_entries, ) ) if report.skipped_entries: logger.W( _( "some unreadable or unsupported files were skipped; the projected sysroot may be incomplete" ) ) return raise NotImplementedError(mode) def _resolve_sysroot_pkg_source( config: GlobalConfig, host: str, sysroot_atom_str: str, ) -> ResolvedSysrootPkgSource | int: """Resolve a sysroot package source from an atom string. Returns ResolvedSysrootPkgSource on success, or an error exit code (int). """ logger = config.logger mr = config.repo gcc_pkg_atom = Atom.parse(sysroot_atom_str) gcc_pkg_pm = gcc_pkg_atom.match_in_repo(mr, config.include_prereleases) if gcc_pkg_pm is None: logger.F( _("cannot match a toolchain package with [yellow]{atom}[/]").format( atom=sysroot_atom_str, ) ) return 1 if gcc_pkg_pm.toolchain_metadata is None: logger.F( _("the package [yellow]{atom}[/] is not a toolchain").format( atom=sysroot_atom_str, ) ) return 1 gcc_pkg_root = config.lookup_binary_install_dir( host, gcc_pkg_pm.name_for_installation, ) if gcc_pkg_root is None: logger.F( _("cannot find the installed directory for the sysroot package") ) return 1 tc_sysroot_relpath = gcc_pkg_pm.toolchain_metadata.included_sysroot if tc_sysroot_relpath is None: logger.F( _("sysroot is requested but the package [yellow]{atom}[/] does not contain one").format( atom=sysroot_atom_str, ) ) return 1 sysroot_dir = pathlib.Path(gcc_pkg_root) / tc_sysroot_relpath # also figure the GCC include/libs path out for Clang to be able to # locate them gcc_install_dir = find_gcc_install_dir( gcc_pkg_root, # we should use the GCC-providing package's target tuple as that's # not guaranteed to be the same as llvm's gcc_pkg_pm.toolchain_metadata.target, ) # for now, require this directory to be present (or clang would barely work) if gcc_install_dir is None: logger.F( _("cannot find a GCC include & lib directory in the sysroot package") ) return 1 return ResolvedSysrootPkgSource( sysroot_dir=sysroot_dir, pkg_manifest=gcc_pkg_pm, gcc_install_dir=gcc_install_dir, ) class VenvPackageInfo(TypedDict): repo_id: str category: str name: str version: str class VenvMetadata(TypedDict): emulator_pkgs: dict[str, VenvPackageInfo] extra_pkgs: list[VenvPackageInfo] sysroot_pkg: VenvPackageInfo | None toolchain_pkgs: dict[str, VenvPackageInfo] def _venv_pkg_info_from_pkg(pkg: BoundPackageManifest) -> VenvPackageInfo: return VenvPackageInfo( repo_id=pkg.repo_id, category=pkg.category, name=pkg.name, version=pkg.ver, ) def do_make_venv( config: GlobalConfig, host: str, profile_name: str, dest: pathlib.Path, with_sysroot: bool, override_name: str | None = None, tc_atoms_str: list[str] | None = None, emu_atom_str: str | None = None, sysroot_atom_str: str | None = None, copy_sysroot_dir_str: str | None = None, symlink_sysroot_dir_str: str | None = None, project_sysroot_dir_str: str | None = None, extra_cmd_atoms_str: list[str] | None = None, ) -> int: logger = config.logger explicit_sysroot_dir: pathlib.Path | None = None explicit_sysroot_gcc_install_dir: PathLike[Any] | None = None explicit_sysroot_pkg: VenvPackageInfo | None = None sysroot_provision_mode = SysrootProvisionMode.COPY_TREE if sysroot_atom_str is not None: result = _resolve_sysroot_pkg_source(config, host, sysroot_atom_str) if isinstance(result, int): return result explicit_sysroot_dir = pathlib.Path(result["sysroot_dir"]) explicit_sysroot_gcc_install_dir = result["gcc_install_dir"] explicit_sysroot_pkg = _venv_pkg_info_from_pkg(result["pkg_manifest"]) elif copy_sysroot_dir_str is not None: explicit_sysroot_dir = pathlib.Path(copy_sysroot_dir_str).resolve() if not explicit_sysroot_dir.is_dir(): logger.F( _("the sysroot directory [yellow]{path}[/] does not exist").format( path=copy_sysroot_dir_str, ) ) return 1 elif symlink_sysroot_dir_str is not None: explicit_sysroot_dir = pathlib.Path(symlink_sysroot_dir_str).resolve() if not explicit_sysroot_dir.is_dir(): logger.F( _("the sysroot directory [yellow]{path}[/] does not exist").format( path=symlink_sysroot_dir_str, ) ) return 1 sysroot_provision_mode = SysrootProvisionMode.SYMLINK_TREE elif project_sysroot_dir_str is not None: explicit_sysroot_dir = pathlib.Path(project_sysroot_dir_str).resolve() if not explicit_sysroot_dir.is_dir(): logger.F( _("the rootfs directory [yellow]{path}[/] does not exist").format( path=project_sysroot_dir_str, ) ) return 1 sysroot_provision_mode = SysrootProvisionMode.PROJECT_ROOTFS # TODO: support omitting this if user only has one toolchain installed # this should come after implementation of local state cache if tc_atoms_str is None: logger.F( _("You have to specify at least one toolchain atom for now, e.g. [yellow]`-t gnu-plct`[/]") ) return 1 mr = config.repo profile = mr.get_profile(profile_name) if profile is None: logger.F(_("profile '{profile}' not found").format(profile=profile_name)) return 1 target_arch = "" seen_target_tuples: set[str] = set() targets: list[ConfiguredTargetTuple] = [] warn_differing_target_arch = False venv_metadata = VenvMetadata( emulator_pkgs={}, extra_pkgs=[], sysroot_pkg=None, toolchain_pkgs={}, ) for tc_atom_str in tc_atoms_str: tc_atom = Atom.parse(tc_atom_str) tc_pm = tc_atom.match_in_repo(mr, config.include_prereleases) if tc_pm is None: logger.F(_("cannot match a toolchain package with [yellow]{atom}[/]").format( atom=tc_atom_str, )) return 1 if tc_pm.toolchain_metadata is None: logger.F(_("the package [yellow]{atom}[/] is not a toolchain").format( atom=tc_atom_str, )) return 1 if not tc_pm.toolchain_metadata.satisfies_quirk_set(profile.need_quirks): logger.F(_( "the package [yellow]{atom}[/] does not support all necessary features for the profile [yellow]{profile}[/]" ).format( atom=tc_atom_str, profile=profile_name, ) ) logger.I( _("quirks needed by profile: {humanized_list}").format( humanized_list=humanize_list(profile.need_quirks, item_color='cyan'), ) ) logger.I( _("quirks provided by package: {humanized_list}").format( humanized_list=humanize_list(tc_pm.toolchain_metadata.quirks, item_color='yellow'), ) ) return 1 target_tuple = tc_pm.toolchain_metadata.target if target_tuple in seen_target_tuples: logger.F( _("the target tuple [yellow]{target_tuple}[/] is already covered by one of the requested toolchains").format( target_tuple=target_tuple, ) ) logger.I( _("for now, only toolchains with differing target tuples can co-exist in one virtual environment") ) return 1 toolchain_root = config.lookup_binary_install_dir( host, tc_pm.name_for_installation, ) if toolchain_root is None: logger.F(_("cannot find the installed directory for the toolchain")) return 1 tc_sysroot_dir: PathLike[Any] | None = None gcc_install_dir: PathLike[Any] | None = None if with_sysroot: if explicit_sysroot_dir is not None: tc_sysroot_dir = explicit_sysroot_dir gcc_install_dir = explicit_sysroot_gcc_install_dir if explicit_sysroot_pkg is not None: venv_metadata["sysroot_pkg"] = explicit_sysroot_pkg elif tc_sysroot_relpath := tc_pm.toolchain_metadata.included_sysroot: tc_sysroot_dir = pathlib.Path(toolchain_root) / tc_sysroot_relpath venv_metadata["sysroot_pkg"] = _venv_pkg_info_from_pkg(tc_pm) else: logger.F( _("sysroot is requested but the toolchain package does not include one, and no explicit sysroot source is given") ) return 1 # derive flags for (the quirks of) this toolchain tc_flags = profile.get_common_flags(tc_pm.toolchain_metadata.quirks) # record the target tuple info to configure in the venv configured_target: ConfiguredTargetTuple = { "target": target_tuple, "toolchain_root": toolchain_root, "toolchain_sysroot": tc_sysroot_dir, "toolchain_flags": tc_flags, # assume clang is preferred if package contains clang # this is mostly true given most packages don't contain both "cc_flavor": "clang" if tc_pm.toolchain_metadata.has_clang else "gcc", # same for binutils provider flavor "binutils_flavor": ( "llvm" if tc_pm.toolchain_metadata.has_llvm else "binutils" ), "gcc_install_dir": gcc_install_dir, } logger.D(f"configuration for {target_tuple}: {configured_target}") targets.append(configured_target) seen_target_tuples.add(target_tuple) venv_metadata["toolchain_pkgs"][target_tuple] = _venv_pkg_info_from_pkg(tc_pm) # record the target architecture for use in emulator package matching if not target_arch: target_arch = tc_pm.toolchain_metadata.target_arch elif target_arch != tc_pm.toolchain_metadata.target_arch: # first one wins warn_differing_target_arch = True if warn_differing_target_arch: logger.W(_("multiple toolchains specified with differing target architecture")) logger.I( _("using the target architecture of the first toolchain: [yellow]{arch}[/]").format( arch=target_arch, ) ) # Now handle the emulator. emu_progs = None emu_root: PathLike[Any] | None = None if emu_atom_str: emu_atom = Atom.parse(emu_atom_str) emu_pm = emu_atom.match_in_repo(mr, config.include_prereleases) if emu_pm is None: logger.F(_("cannot match an emulator package with [yellow]{atom}[/]").format( atom=emu_atom_str, )) return 1 if emu_pm.emulator_metadata is None: logger.F(_("the package [yellow]{atom}[/] is not an emulator").format( atom=emu_atom_str, )) return 1 emu_progs = list(emu_pm.emulator_metadata.list_for_arch(target_arch)) if not emu_progs: logger.F( _("the emulator package [yellow]{atom}[/] does not support the target architecture [yellow]{arch}[/]").format( atom=emu_atom_str, arch=target_arch, ) ) return 1 for prog in emu_progs: if not profile.check_emulator_flavor( prog.flavor, emu_pm.emulator_metadata.quirks, ): logger.F( _("the package [yellow]{atom}[/] does not support all necessary features for the profile [yellow]{profile}[/]").format( atom=emu_atom_str, profile=profile_name, ) ) logger.I( _("quirks needed by profile: {humanized_list}").format( humanized_list=humanize_list(profile.get_needed_emulator_pkg_flavors(prog.flavor), item_color='cyan'), ) ) logger.I( _("quirks provided by package: {humanized_list}").format( humanized_list=humanize_list(emu_pm.emulator_metadata.quirks or [], item_color='yellow'), ) ) return 1 emu_root = config.lookup_binary_install_dir( host, emu_pm.name_for_installation, ) if emu_root is None: logger.F(_("cannot find the installed directory for the emulator")) return 1 venv_metadata["emulator_pkgs"][target_arch] = _venv_pkg_info_from_pkg(emu_pm) # Now resolve extra commands to provide in the venv. extra_cmds: dict[str, str] = {} if extra_cmd_atoms_str: for extra_cmd_atom_str in extra_cmd_atoms_str: extra_cmd_atom = Atom.parse(extra_cmd_atom_str) extra_cmd_pm = extra_cmd_atom.match_in_repo( mr, config.include_prereleases, ) if extra_cmd_pm is None: logger.F( _("cannot match an extra command package with [yellow]{atom}[/]").format( atom=extra_cmd_atom_str, ) ) return 1 extra_cmd_bm = extra_cmd_pm.binary_metadata if not extra_cmd_bm: logger.F( _("the package [yellow]{atom}[/] is not a binary-providing package").format( atom=extra_cmd_atom_str, ) ) return 1 extra_cmds_decl = extra_cmd_bm.get_commands_for_host(host) if not extra_cmds_decl: logger.W( _("the package [yellow]{atom}[/] does not provide any command for host [yellow]{host}[/], ignoring").format( atom=extra_cmd_atom_str, host=host, ) ) continue cmd_root = config.lookup_binary_install_dir( host, extra_cmd_pm.name_for_installation, ) if cmd_root is None: logger.F( _("cannot find the installed directory for the package [yellow]{pkg}[/]").format( pkg=extra_cmd_pm.name_for_installation, ) ) return 1 cmd_root = pathlib.Path(cmd_root) venv_metadata["extra_pkgs"].append(_venv_pkg_info_from_pkg(extra_cmd_pm)) for cmd, cmd_rel_path in extra_cmds_decl.items(): # resolve the command path cmd_path = (cmd_root / cmd_rel_path).resolve() if not cmd_path.is_relative_to(cmd_root): # we don't allow commands to resolve outside of the # providing package's install root logger.F( _("internal error: resolved command path is outside of the providing package") ) return 1 # add the command to the list extra_cmds[cmd] = str(cmd_path) if override_name is not None: logger.I( _("Creating a Ruyi virtual environment [cyan]'{name}'[/] at [green]{dest}[/]...").format( name=override_name, dest=dest, ) ) else: logger.I( _("Creating a Ruyi virtual environment at [green]{dest}[/]...").format( dest=dest, ) ) maker = VenvMaker( config, profile, targets, dest.resolve(), emu_progs, emu_root, extra_cmds, venv_metadata, override_name, sysroot_provision_mode, ) try: maker.provision() except VenvProvisionError: return 1 # TODO: move the template to PO locale = match_lang_code(config.lang_code, avail=("en", "zh_CN")) logger.I( render_template_str( f"prompt.venv-created.{locale}.txt", { "sysroot": maker.sysroot_destdir(None), }, ) ) return 0 def find_gcc_install_dir( install_root: PathLike[Any], target_tuple: str, ) -> PathLike[Any] | None: # check $PREFIX/lib/gcc/$TARGET/* search_root = pathlib.Path(install_root) / "lib" / "gcc" / target_tuple try: for p in search_root.iterdir(): # only want the first one (should be the only one) return p except FileNotFoundError: pass # nothing? return None class VenvMaker: """Performs the actual creation of a Ruyi virtual environment.""" def __init__( self, gc: GlobalConfig, profile: ProfileProxy, targets: list[ConfiguredTargetTuple], dest: PathLike[Any], emulator_progs: list[EmulatorProgDecl] | None, emulator_root: PathLike[Any] | None, extra_cmds: dict[str, str] | None, metadata: VenvMetadata, override_name: str | None = None, sysroot_provision_mode: SysrootProvisionMode = SysrootProvisionMode.COPY_TREE, ) -> None: self.gc = gc self.profile = profile self.targets = targets self.venv_root = pathlib.Path(dest) self.emulator_progs = emulator_progs self.emulator_root = emulator_root self.extra_cmds = extra_cmds or {} self.metadata = metadata self.override_name = override_name self.sysroot_provision_mode = sysroot_provision_mode self.bindir = self.venv_root / "bin" @property def logger(self) -> RuyiLogger: return self.gc.logger def render_and_write( self, dest: PathLike[Any], template_name: str, data: dict[str, Any], ) -> None: self.logger.D(f"rendering template '{template_name}' with data {data}") content = render_template_str(template_name, data).encode("utf-8") self.logger.D(f"writing {dest}") with open(dest, "wb") as fp: fp.write(content) def sysroot_srcdir(self, target_tuple: str | None) -> pathlib.Path | None: if target_tuple is None: # check the primary target if s := self.targets[0]["toolchain_sysroot"]: return pathlib.Path(s) return None # check if we have this target for t in self.targets: if t["target"] != target_tuple: continue if s := t["toolchain_sysroot"]: return pathlib.Path(s) return None def has_sysroot_for(self, target_tuple: str | None) -> bool: return self.sysroot_srcdir(target_tuple) is not None def sysroot_destdir(self, target_tuple: str | None) -> pathlib.Path | None: if not self.has_sysroot_for(target_tuple): return None dirname = f"sysroot.{target_tuple}" if target_tuple is not None else "sysroot" return self.venv_root / dirname def provision(self) -> None: venv_root = self.venv_root bindir = self.bindir venv_root.mkdir() bindir.mkdir() env_data = { "profile": self.profile.id, "sysroot": self.sysroot_destdir(None), "metadata": self.metadata, } self.render_and_write( venv_root / "ruyi-venv.toml", "ruyi-venv.toml", env_data, ) for i, tgt in enumerate(self.targets): is_primary = i == 0 self.provision_target(tgt, is_primary) if self.extra_cmds: symlink_binaries( self.gc, self.logger, bindir, src_cmds_names=list(self.extra_cmds.keys()), ) template_data = { "RUYI_VENV": str(venv_root), "RUYI_VENV_NAME": self.override_name, } self.render_and_write( bindir / "ruyi-activate", "ruyi-activate.bash", template_data, ) self.render_and_write( bindir / "ruyi-activate.fish", "ruyi-activate.fish", template_data, ) qemu_bin: PathLike[Any] | None = None profile_emu_env: dict[str, str] | None = None if self.emulator_root is not None and self.emulator_progs: resolved_emu_progs = [ ResolvedEmulatorProg.new( p, self.emulator_root, self.profile, self.sysroot_destdir(None), ) for p in self.emulator_progs ] binfmt_data = { "resolved_progs": resolved_emu_progs, } self.render_and_write( venv_root / "binfmt.conf", "binfmt.conf", binfmt_data, ) for i, p in enumerate(self.emulator_progs): if not p.is_qemu: continue qemu_bin = pathlib.Path(self.emulator_root) / p.relative_path profile_emu_env = resolved_emu_progs[i].env self.logger.D("symlinking the ruyi-qemu wrapper") os.symlink(self.gc.self_exe, bindir / "ruyi-qemu") # provide initial cached configuration to venv self.render_and_write( venv_root / "ruyi-cache.v2.toml", "ruyi-cache.toml", self.make_venv_cache_data( qemu_bin, self.extra_cmds, profile_emu_env, ), ) def make_venv_cache_data( self, qemu_bin: PathLike[Any] | None, extra_cmds: dict[str, str], profile_emu_env: dict[str, str] | None, ) -> dict[str, object]: targets_cache_data: dict[str, object] = { tgt["target"]: { "toolchain_bindir": str(pathlib.Path(tgt["toolchain_root"]) / "bin"), "toolchain_sysroot": self.sysroot_destdir(tgt["target"]), "toolchain_flags": tgt["toolchain_flags"], "gcc_install_dir": tgt["gcc_install_dir"], } for tgt in self.targets } cmd_metadata_map = make_cmd_metadata_map(self.logger, self.targets) # add extra cmds that are not associated with any target for cmd, dest in extra_cmds.items(): if cmd in cmd_metadata_map: self.logger.W( _("extra command {cmd} is already provided by another package, overriding it").format( cmd=cmd, ) ) cmd_metadata_map[cmd] = { "dest": dest, "target_tuple": "", } return { "profile_emu_env": profile_emu_env, "qemu_bin": qemu_bin, "targets": targets_cache_data, "cmd_metadata_map": cmd_metadata_map, } def provision_target( self, tgt: ConfiguredTargetTuple, is_primary: bool, ) -> None: venv_root = self.venv_root bindir = self.bindir target_tuple = tgt["target"] # getting the destdir this way ensures it's suffixed with the target # tuple if sysroot_destdir := self.sysroot_destdir(target_tuple): sysroot_srcdir = tgt["toolchain_sysroot"] assert sysroot_srcdir is not None sysroot_srcdir = pathlib.Path(sysroot_srcdir) provision_sysroot( self.logger, sysroot_srcdir, sysroot_destdir, self.sysroot_provision_mode, target_tuple, ) if is_primary: self.logger.D("symlinking primary sysroot into place") primary_sysroot_destdir = self.sysroot_destdir(None) assert primary_sysroot_destdir is not None os.symlink(sysroot_destdir.name, primary_sysroot_destdir) self.logger.D(f"symlinking {target_tuple} binaries into venv") toolchain_bindir = pathlib.Path(tgt["toolchain_root"]) / "bin" symlink_binaries(self.gc, self.logger, bindir, src_bindir=toolchain_bindir) make_llvm_tool_aliases( self.logger, bindir, target_tuple, tgt["binutils_flavor"] == "llvm", tgt["cc_flavor"] == "clang", ) # CMake toolchain file & Meson cross file if tgt["cc_flavor"] == "clang": cc_path = bindir / "clang" cxx_path = bindir / "clang++" elif tgt["cc_flavor"] == "gcc": cc_path = bindir / f"{target_tuple}-gcc" cxx_path = bindir / f"{target_tuple}-g++" else: raise NotImplementedError if tgt["binutils_flavor"] == "binutils": meson_additional_binaries = { "ar": bindir / f"{target_tuple}-ar", "nm": bindir / f"{target_tuple}-nm", "objcopy": bindir / f"{target_tuple}-objcopy", "objdump": bindir / f"{target_tuple}-objdump", "ranlib": bindir / f"{target_tuple}-ranlib", "readelf": bindir / f"{target_tuple}-readelf", "strip": bindir / f"{target_tuple}-strip", } elif tgt["binutils_flavor"] == "llvm": meson_additional_binaries = { "ar": bindir / "llvm-ar", "nm": bindir / "llvm-nm", "objcopy": bindir / "llvm-objcopy", "objdump": bindir / "llvm-objdump", "ranlib": bindir / "llvm-ranlib", "readelf": bindir / "llvm-readelf", "strip": bindir / "llvm-strip", } else: raise NotImplementedError cmake_toolchain_file_path = venv_root / f"toolchain.{target_tuple}.cmake" toolchain_file_data = { "cc": cc_path, "cxx": cxx_path, "processor": self.profile.arch, "sysroot": self.sysroot_destdir(target_tuple), "venv_root": venv_root, "cmake_toolchain_file": str(cmake_toolchain_file_path), "meson_additional_binaries": meson_additional_binaries, } self.render_and_write( cmake_toolchain_file_path, "toolchain.cmake", toolchain_file_data, ) meson_cross_file_path = venv_root / f"meson-cross.{target_tuple}.ini" self.render_and_write( meson_cross_file_path, "meson-cross.ini", toolchain_file_data, ) if is_primary: self.logger.D( f"making cmake & meson file symlinks to primary target {target_tuple}" ) primary_cmake_toolchain_file_path = venv_root / "toolchain.cmake" primary_meson_cross_file_path = venv_root / "meson-cross.ini" os.symlink( cmake_toolchain_file_path.name, primary_cmake_toolchain_file_path, ) os.symlink(meson_cross_file_path.name, primary_meson_cross_file_path) def iter_binaries_to_symlink( logger: RuyiLogger, bindir: pathlib.Path, ) -> Iterator[pathlib.Path]: for filename in glob.iglob("*", root_dir=bindir): src_cmd_path = bindir / filename if not is_executable(src_cmd_path): logger.D(f"skipping non-executable {filename} in src bindir") continue if should_ignore_symlinking(filename): logger.D(f"skipping command {filename} explicitly") continue yield bindir / filename class CmdMetadataEntry(TypedDict): dest: str target_tuple: str def make_cmd_metadata_map( logger: RuyiLogger, targets: list[ConfiguredTargetTuple], ) -> dict[str, CmdMetadataEntry]: result: dict[str, CmdMetadataEntry] = {} for tgt in targets: # TODO: dedup this and provision_target toolchain_bindir = pathlib.Path(tgt["toolchain_root"]) / "bin" for cmd in iter_binaries_to_symlink(logger, toolchain_bindir): result[cmd.name] = { "dest": str(cmd), "target_tuple": tgt["target"], } return result def symlink_binaries( gm: ProvidesGlobalMode, logger: RuyiLogger, dest_bindir: PathLike[Any], *, src_bindir: PathLike[Any] | None = None, src_cmds_names: list[str] | None = None, ) -> None: dest_binpath = pathlib.Path(dest_bindir) self_exe_path = gm.self_exe if src_bindir is not None: src_binpath = pathlib.Path(src_bindir) for src_cmd_path in iter_binaries_to_symlink(logger, src_binpath): filename = src_cmd_path.name # symlink self to dest with the name of this command dest_path = dest_binpath / filename logger.D(f"making ruyi symlink to {self_exe_path} at {dest_path}") os.symlink(self_exe_path, dest_path) return if src_cmds_names is not None: for cmd in src_cmds_names: # symlink self to dest with the name of this command dest_path = dest_binpath / cmd logger.D(f"making ruyi symlink to {self_exe_path} at {dest_path}") os.symlink(self_exe_path, dest_path) return raise ValueError( "internal error: either src_bindir or src_cmds_names must be provided" ) LLVM_BINUTILS_ALIASES: Final = { "addr2line": "llvm-addr2line", "ar": "llvm-ar", "as": "llvm-as", "c++filt": "llvm-cxxfilt", "gcc-ar": "llvm-ar", "gcc-nm": "llvm-nm", "gcc-ranlib": "llvm-ranlib", # 'gcov': 'llvm-cov', # I'm not sure if this is correct "ld": "ld.lld", "nm": "llvm-nm", "objcopy": "llvm-objcopy", "objdump": "llvm-objdump", "ranlib": "llvm-ranlib", "readelf": "llvm-readelf", "size": "llvm-size", "strings": "llvm-strings", "strip": "llvm-strip", } CLANG_GCC_ALIASES: Final = { "c++": "clang++", "cc": "clang", "cpp": "clang-cpp", "g++": "clang++", "gcc": "clang", } def make_llvm_tool_aliases( logger: RuyiLogger, dest_bindir: PathLike[Any], target_tuple: str, do_binutils: bool, do_clang: bool, ) -> None: if do_binutils: make_compat_symlinks(logger, dest_bindir, target_tuple, LLVM_BINUTILS_ALIASES) if do_clang: make_compat_symlinks(logger, dest_bindir, target_tuple, CLANG_GCC_ALIASES) def make_compat_symlinks( logger: RuyiLogger, dest_bindir: PathLike[Any], target_tuple: str, aliases: dict[str, str], ) -> None: destdir = pathlib.Path(dest_bindir) for compat_basename, symlink_target in aliases.items(): compat_name = f"{target_tuple}-{compat_basename}" logger.D(f"making compat symlink: {compat_name} -> {symlink_target}") os.symlink(symlink_target, destdir / compat_name) def is_executable(p: PathLike[Any]) -> bool: return os.access(p, os.F_OK | os.X_OK) def should_ignore_symlinking(c: str) -> bool: return is_command_specific_to_ct_ng(c) or is_command_versioned_cc(c) def is_command_specific_to_ct_ng(c: str) -> bool: return c.endswith("populate") or c.endswith("ct-ng.config") VERSIONED_CC_RE: Final = re.compile( r"(?:^|-)(?:g?cc|c\+\+|g\+\+|cpp|clang|clang\+\+)-[0-9.]+$" ) def is_command_versioned_cc(c: str) -> bool: return VERSIONED_CC_RE.search(c) is not None ruyisdk-ruyi-1f00e2e/ruyi/mux/venv/venv_cli.py000066400000000000000000000135161520522431500215410ustar00rootroot00000000000000import argparse import pathlib from typing import TYPE_CHECKING from ...cli.cmd import RootCommand from ...i18n import _ if TYPE_CHECKING: from ...cli.completion import ArgumentParser from ...config import GlobalConfig class VenvCommand( RootCommand, cmd="venv", help=_("Generate a virtual environment adapted to the chosen toolchain and profile"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.formatter_class = argparse.RawDescriptionHelpFormatter p.epilog = _( "Sysroot provisioning:\n" " By default, Ruyi uses the sysroot bundled with the selected toolchain if one is available.\n" " Use --copy-sysroot-from-pkg to copy the sysroot from another installed toolchain package.\n" " Use --copy-sysroot-from-dir only for a complete sysroot directory readable by the current user; it performs a faithful full-tree copy.\n" " Use --symlink-sysroot-from-dir to point the virtual environment at an existing sysroot directory without copying it.\n" " Use --project-sysroot-from-rootfs for distro rootfs or chroot trees: Ruyi copies common cross-build directories such as include, lib*, usr/include, usr/lib*, usr/share, bin, and sbin, and skips unreadable or unsupported files.\n" " Ruyi never elevates privileges when creating virtual environments. If a rootfs contains private system files such as /etc/shadow, prepare a readable sysroot yourself or use projection mode." ) p.add_argument("profile", type=str, help=_("Profile to use for the environment"), ) p.add_argument("dest", type=str, help=_("Path to the new virtual environment"), ) p.add_argument( "--name", "-n", type=str, default=None, help=_("Override the venv's name"), ) p.add_argument( "--toolchain", "-t", type=str, action="append", help=_("Specifier(s) (atoms) of the toolchain package(s) to use"), ) p.add_argument( "--emulator", "-e", type=str, help=_("Specifier (atom) of the emulator package to use"), ) p.add_argument( "--with-sysroot", action="store_true", dest="with_sysroot", default=True, help=_("Provision a fresh sysroot inside the new virtual environment (default)"), ) p.add_argument( "--without-sysroot", action="store_false", dest="with_sysroot", help=_("Do not include a sysroot inside the new virtual environment"), ) p.add_argument( "--copy-sysroot-from-pkg", "--sysroot-from", type=str, dest="copy_sysroot_from_pkg", help=_("Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable"), ) p.add_argument( "--copy-sysroot-from-dir", type=str, help=_("Copy the sysroot from the given directory into the virtual environment"), ) p.add_argument( "--symlink-sysroot-from-dir", type=str, help=_("Symlink the virtual environment's sysroot to the given existing directory"), ) p.add_argument( "--project-sysroot-from-rootfs", type=str, help=_("Project a build sysroot from the given distro rootfs directory"), ) p.add_argument( "--extra-commands-from", type=str, action="append", help=_("Specifier(s) (atoms) of extra package(s) to add commands to the new virtual environment"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ...ruyipkg.host import get_native_host from .maker import do_make_venv # validate sysroot source options: at most one source may be specified sysroot_sources = sum([ args.copy_sysroot_from_pkg is not None, args.copy_sysroot_from_dir is not None, args.symlink_sysroot_from_dir is not None, args.project_sysroot_from_rootfs is not None, ]) if sysroot_sources > 1: cfg.logger.F( _("at most one of --copy-sysroot-from-pkg, --copy-sysroot-from-dir, --symlink-sysroot-from-dir, and --project-sysroot-from-rootfs may be specified") ) return 1 if not args.with_sysroot and sysroot_sources > 0: cfg.logger.F( _("--without-sysroot cannot be combined with a sysroot source option") ) return 1 profile_name: str = args.profile dest = pathlib.Path(args.dest) with_sysroot: bool = args.with_sysroot override_name: str | None = args.name tc_atoms_str: list[str] | None = args.toolchain emu_atom_str: str | None = args.emulator sysroot_atom_str: str | None = args.copy_sysroot_from_pkg copy_sysroot_dir_str: str | None = args.copy_sysroot_from_dir symlink_sysroot_dir_str: str | None = args.symlink_sysroot_from_dir project_sysroot_dir_str: str | None = args.project_sysroot_from_rootfs extra_cmd_atoms_str: list[str] | None = args.extra_commands_from host = str(get_native_host()) return do_make_venv( cfg, host, profile_name, dest, with_sysroot, override_name, tc_atoms_str, emu_atom_str, sysroot_atom_str, copy_sysroot_dir_str, symlink_sysroot_dir_str, project_sysroot_dir_str, extra_cmd_atoms_str, ) ruyisdk-ruyi-1f00e2e/ruyi/mux/venv_cfg.py000066400000000000000000000151461520522431500205540ustar00rootroot00000000000000import copy import os.path import pathlib import tomllib from typing import Any, TypedDict, TYPE_CHECKING, cast if TYPE_CHECKING: from typing_extensions import NotRequired, Self from ..log import RuyiLogger from ..utils.global_mode import ProvidesGlobalMode class VenvConfigType(TypedDict): profile: str sysroot: "NotRequired[str]" class VenvConfigRootType(TypedDict): config: VenvConfigType class VenvCacheV0Type(TypedDict): target_tuple: str toolchain_bindir: str gcc_install_dir: "NotRequired[str]" profile_common_flags: str qemu_bin: "NotRequired[str]" profile_emu_env: "NotRequired[dict[str, str]]" class VenvCacheV1TargetType(TypedDict): toolchain_bindir: str toolchain_sysroot: "NotRequired[str]" gcc_install_dir: "NotRequired[str]" class VenvCacheV2TargetType(VenvCacheV1TargetType): toolchain_flags: str class VenvCacheV1CmdMetadataEntryType(TypedDict): dest: str target_tuple: str class VenvCacheV1Type(TypedDict): profile_common_flags: str profile_emu_env: "NotRequired[dict[str, str]]" qemu_bin: "NotRequired[str]" targets: dict[str, VenvCacheV1TargetType] cmd_metadata_map: "NotRequired[dict[str, VenvCacheV1CmdMetadataEntryType]]" class VenvCacheV2Type(TypedDict): profile_emu_env: "NotRequired[dict[str, str]]" qemu_bin: "NotRequired[str]" targets: dict[str, VenvCacheV2TargetType] cmd_metadata_map: "NotRequired[dict[str, VenvCacheV1CmdMetadataEntryType]]" class VenvCacheRootType(TypedDict): cached: "NotRequired[VenvCacheV0Type]" cached_v1: "NotRequired[VenvCacheV1Type]" cached_v2: "NotRequired[VenvCacheV2Type]" def parse_venv_cache( cache: VenvCacheRootType, global_sysroot: str | None, ) -> VenvCacheV2Type: if "cached_v2" in cache: return cache["cached_v2"] if "cached_v1" in cache: return upgrade_venv_cache_v1(cache["cached_v1"]) if "cached" in cache: return upgrade_venv_cache_v0(cache["cached"], global_sysroot) raise RuntimeError("unsupported venv cache version") def upgrade_venv_cache_v1(x: VenvCacheV1Type) -> VenvCacheV2Type: profile_common_flags = x["profile_common_flags"] tmp = cast(dict[str, Any], copy.deepcopy(x)) del tmp["profile_common_flags"] v2 = cast(VenvCacheV2Type, tmp) for tgt in v2["targets"].values(): tgt["toolchain_flags"] = profile_common_flags return v2 def upgrade_venv_cache_v0( x: VenvCacheV0Type, global_sysroot: str | None, ) -> VenvCacheV2Type: # v0 only supports one single target so upgrading is trivial v1_target: VenvCacheV1TargetType = { "toolchain_bindir": x["toolchain_bindir"], } if "gcc_install_dir" in x: v1_target["gcc_install_dir"] = x["gcc_install_dir"] if global_sysroot is not None: v1_target["toolchain_sysroot"] = global_sysroot y: VenvCacheV1Type = { "profile_common_flags": x["profile_common_flags"], "targets": {x["target_tuple"]: v1_target}, } if "profile_emu_env" in x: y["profile_emu_env"] = x["profile_emu_env"] if "qemu_bin" in x: y["qemu_bin"] = x["qemu_bin"] return upgrade_venv_cache_v1(y) class RuyiVenvConfig: def __init__( self, venv_root: pathlib.Path, cfg: VenvConfigRootType, cache: VenvCacheRootType, ) -> None: self.venv_root = venv_root self.profile = cfg["config"]["profile"] self.sysroot = cfg["config"].get("sysroot") parsed_cache = parse_venv_cache(cache, self.sysroot) self.targets = parsed_cache["targets"] self.qemu_bin = parsed_cache.get("qemu_bin") self.profile_emu_env = parsed_cache.get("profile_emu_env") self.cmd_metadata_map = parsed_cache.get("cmd_metadata_map") # this must be in sync with provision.py self._ruyi_priv_dir = self.venv_root / "ruyi-private" self._cached_cmd_targets_dir = self._ruyi_priv_dir / "cached-cmd-targets" @classmethod def explicit_ruyi_venv_root(cls, gm: ProvidesGlobalMode) -> str | None: return gm.venv_root @classmethod def probe_venv_root(cls, gm: ProvidesGlobalMode) -> pathlib.Path | None: if explicit_root := cls.explicit_ruyi_venv_root(gm): return pathlib.Path(explicit_root) # check ../.. from argv[0] # this only works if it contains a path separator, otherwise it's really # hard without an explicit root (/proc/*/exe points to the resolved file, # but we want the path to the first symlink without any symlink dereference) argv0_path = gm.argv0 if os.path.sep not in argv0_path: return None implied_root = pathlib.Path(os.path.dirname(os.path.dirname(argv0_path))) if (implied_root / "ruyi-venv.toml").exists(): return implied_root return None @classmethod def load_from_venv( cls, gm: ProvidesGlobalMode, logger: RuyiLogger, ) -> "Self | None": venv_root = cls.probe_venv_root(gm) if venv_root is None: return None if cls.explicit_ruyi_venv_root(gm) is not None: logger.D(f"using explicit venv root {venv_root}") else: logger.D(f"detected implicit venv root {venv_root}") venv_config_path = venv_root / "ruyi-venv.toml" with open(venv_config_path, "rb") as fp: cfg: Any = tomllib.load(fp) # in order to cast to our stricter type cache: Any # in order to cast to our stricter type venv_cache_v2_path = venv_root / "ruyi-cache.v2.toml" try: with open(venv_cache_v2_path, "rb") as fp: cache = tomllib.load(fp) except FileNotFoundError: venv_cache_v1_path = venv_root / "ruyi-cache.v1.toml" try: with open(venv_cache_v1_path, "rb") as fp: cache = tomllib.load(fp) except FileNotFoundError: venv_cache_v0_path = venv_root / "ruyi-cache.toml" with open(venv_cache_v0_path, "rb") as fp: cache = tomllib.load(fp) # NOTE: for now it's not prohibited to have cache data of a different # version in a certain version's cache path, but this situation is # harmless return cls(venv_root, cfg, cache) def resolve_cmd_metadata_with_cache( self, basename: str, ) -> VenvCacheV1CmdMetadataEntryType | None: if self.cmd_metadata_map is None: # we are operating in a venv created with an older ruyi, thus no # cmd_metadata_map in cache return None return self.cmd_metadata_map.get(basename) ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/000077500000000000000000000000001520522431500177615ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/__init__.py000066400000000000000000000000001520522431500220600ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/api.py000066400000000000000000000162541520522431500211140ustar00rootroot00000000000000from contextlib import AbstractContextManager from functools import cached_property import pathlib import subprocess import time import tomllib from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from rich.console import Console, RenderableType from ..cli import user_input from ..log import RuyiLogger from ..version import RUYI_SEMVER from .paths import resolve_ruyi_load_path if TYPE_CHECKING: from .build_api import RuyiBuildRecipeAPI from .ctx import PluginHostContext, PluginLoadMode from .traits import SupportsEvalFunction, SupportsGetOption T = TypeVar("T") U = TypeVar("U") class RuyiHostAPI: def __init__( self, phctx: "PluginHostContext[SupportsGetOption, SupportsEvalFunction]", this_file: pathlib.Path, this_plugin_dir: pathlib.Path, allow_host_fs_access: bool, ) -> None: self._phctx = phctx self._this_file = this_file self._this_plugin_dir = this_plugin_dir self._ev = phctx.make_evaluator() self._allow_host_fs_access = allow_host_fs_access self._logger = RuyiPluginLogger(self._phctx.host_logger) # TODO: unify into the plugin logger self._host_logger = self._phctx.host_logger @property def ruyi_version(self) -> str: return RUYI_SEMVER @property def ruyi_plugin_api_rev(self) -> int: return 1 def load_toml(self, path: str) -> object: resolved_path = resolve_ruyi_load_path( path, self._phctx.plugin_root, True, self._this_file, self._allow_host_fs_access, recipe_project_root=self._phctx.recipe_project_root, ) with open(resolved_path, "rb") as f: return tomllib.load(f) @property def log(self) -> "RuyiPluginLogger": return self._logger def cli_ask_for_choice(self, prompt: str, choice_texts: list[str]) -> int: return user_input.ask_for_choice(self._host_logger, prompt, choice_texts) def cli_ask_for_file(self, prompt: str) -> str: return user_input.ask_for_file(self._host_logger, prompt) def cli_ask_for_kv_choice(self, prompt: str, choices_kv: dict[str, str]) -> str: return user_input.ask_for_kv_choice(self._host_logger, prompt, choices_kv) def cli_ask_for_yesno_confirmation( self, prompt: str, default: bool = False, ) -> bool: return user_input.ask_for_yesno_confirmation(self._host_logger, prompt, default) def call_subprocess_argv( self, argv: list[str], ) -> int: if "call-subprocess-v1" not in self._phctx.capabilities: raise RuntimeError( "call_subprocess_argv is not available in this plugin context" ) return subprocess.call(argv) def sleep(self, seconds: float, /) -> None: return time.sleep(seconds) def with_( self, cm: AbstractContextManager[T], fn: object | Callable[[T], U], ) -> U: with cm as obj: return cast(U, self._ev.eval_function(fn, obj)) def has_feature(self, feature: str) -> bool: return feature in self._phctx.capabilities ######################################################################### # Exported methods for the `i18n-v1` feature @cached_property def i18n(self) -> "RuyiPluginI18nAPI": return RuyiPluginI18nAPI(self._phctx) ######################################################################### # Exported methods for the `build-recipe-v1` feature @cached_property def build(self) -> "RuyiBuildRecipeAPI": if "build-recipe-v1" not in self._phctx.capabilities: raise RuntimeError( "RUYI.build is only available when loading a build recipe" ) from .build_api import RuyiBuildRecipeAPI return RuyiBuildRecipeAPI(self._phctx, self._this_file) class RuyiPluginI18nAPI: def __init__( self, phctx: "PluginHostContext[SupportsGetOption, SupportsEvalFunction]", ) -> None: self._phctx = phctx @property def locale(self) -> str: return self._phctx.locale def msg(self, msgid: str, locale: str | None = None) -> str | None: if not self._phctx.message_store: raise RuntimeError("message store is not available in this context") locale = locale or self.locale return self._phctx.message_store.get_message_template(msgid, locale) def _ensure_str(message: RenderableType) -> None: if not isinstance(message, str): raise TypeError("message must be str in plugins") class RuyiPluginLogger(RuyiLogger): def __init__(self, host_logger: RuyiLogger) -> None: self._h = host_logger @property def log_console(self) -> Console: return self._h.log_console def stdout( self, message: RenderableType, *objects: Any, sep: str = " ", end: str = "\n", ) -> None: _ensure_str(message) self._h.stdout(message, *objects, sep=sep, end=end) def D( self, message: RenderableType, *objects: Any, sep: str = " ", end: str = "\n", _stack_offset_delta: int = 0, ) -> None: _ensure_str(message) if _stack_offset_delta != 0: raise ValueError("_stack_offset_delta is not supported in plugins") self._h.D(message, *objects, sep=sep, end=end, _stack_offset_delta=1) def W( self, message: RenderableType, *objects: Any, sep: str = " ", end: str = "\n", ) -> None: _ensure_str(message) self._h.W(message, *objects, sep=sep, end=end) def I( # noqa: E743 # the name intentionally mimics Android logging for brevity self, message: RenderableType, *objects: Any, sep: str = " ", end: str = "\n", ) -> None: _ensure_str(message) self._h.I(message, *objects, sep=sep, end=end) def F( self, message: RenderableType, *objects: Any, sep: str = " ", end: str = "\n", ) -> None: _ensure_str(message) self._h.F(message, *objects, sep=sep, end=end) def _ruyi_plugin_rev( phctx: "PluginHostContext[SupportsGetOption, SupportsEvalFunction]", this_file: pathlib.Path, this_plugin_dir: pathlib.Path, allow_host_fs_access: bool, rev: object, ) -> RuyiHostAPI: if not isinstance(rev, int): raise TypeError("rev must be int in ruyi_plugin_rev calls") if rev != 1: raise ValueError( f"Ruyi plugin API revision {rev} is not supported by this Ruyi" ) return RuyiHostAPI( phctx, this_file, this_plugin_dir, allow_host_fs_access, ) def make_ruyi_plugin_api_for_module( phctx: "PluginHostContext[SupportsGetOption, SupportsEvalFunction]", this_file: pathlib.Path, this_plugin_dir: pathlib.Path, load_mode: "PluginLoadMode", ) -> Callable[[object], RuyiHostAPI]: allow_host_fs_access = load_mode.allow_host_fs_access return lambda rev: _ruyi_plugin_rev( phctx, this_file, this_plugin_dir, allow_host_fs_access, rev, ) ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/build_api.py000066400000000000000000000154351520522431500222730ustar00rootroot00000000000000"""Build-recipe-only additions to the RuyiHostAPI. Only reachable from inside a phctx where ``recipe_project_root`` is set (which in turn causes the ``build-recipe-v1`` capability to be granted and ``call-subprocess-v1`` to be revoked). At load time the recipe module gets a ``RUYI.build`` namespace exposing :meth:`RuyiBuildRecipeAPI.schedule_build`. At build time, each scheduled callable is invoked with a :class:`RecipeBuildCtx` that returns plans (:class:`Invocation`, :class:`Artifact`) rather than executing anything. """ from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING, Mapping, NamedTuple import pathlib from ..ruyipkg.recipe_project import RecipeProject, safe_join if TYPE_CHECKING: from .ctx import PluginHostContext from .traits import SupportsEvalFunction, SupportsGetOption class ScheduledBuild(NamedTuple): """A single build registered by a recipe at load time.""" name: str fn: object recipe_file: pathlib.Path @dataclass(frozen=True) class Invocation: """A declarative plan for a single subprocess invocation. Produced by ``ctx.subprocess(...)``. The executor (B7) is responsible for actually running it and, after a zero-exit, resolving the :class:`Artifact` globs in :attr:`produces`. """ argv: tuple[str, ...] cwd: pathlib.Path env: Mapping[str, str] = field(default_factory=dict) produces: tuple["Artifact", ...] = () @dataclass(frozen=True) class Artifact: """A declared build output matched against a glob under ``root``.""" glob: str root: pathlib.Path class RuyiBuildRecipeAPI: """The ``RUYI.build`` namespace for build-recipe modules (load time).""" def __init__( self, phctx: "PluginHostContext[SupportsGetOption, SupportsEvalFunction]", recipe_file: pathlib.Path, ) -> None: self._phctx = phctx self._recipe_file = recipe_file def schedule_build( self, fn: object, name: str | None = None, ) -> None: """Register a scheduled build callable. Must be called at module top level. ``name`` defaults to the callable's ``__name__``; duplicate names within the same recipe file raise :class:`RuntimeError`. """ if not callable(fn): raise RuntimeError( f"schedule_build: expected a callable, got {type(fn).__name__}" ) resolved_name = name if name is not None else getattr(fn, "__name__", None) if not isinstance(resolved_name, str) or not resolved_name: raise RuntimeError( "schedule_build: could not derive a name from the callable; " "pass name=... explicitly" ) registry = self._phctx.scheduled_builds_for(self._recipe_file) if any(sb.name == resolved_name for sb in registry): raise RuntimeError( f"schedule_build: duplicate build name {resolved_name!r} " f"in recipe {self._recipe_file}" ) registry.append( ScheduledBuild( name=resolved_name, fn=fn, recipe_file=self._recipe_file, ) ) _MISSING = object() class RecipeBuildCtx: """The per-build ``ctx`` object passed to each scheduled callable. All methods return *plans* — they do not execute subprocesses or touch the filesystem beyond path resolution and traversal checks. """ def __init__( self, project: RecipeProject, name: str, recipe_file: pathlib.Path, user_vars: Mapping[str, str], ) -> None: self._project = project self._name = name self._recipe_file = recipe_file self._user_vars = dict(user_vars) @property def name(self) -> str: return self._name @property def recipe_file(self) -> str: return str(self._recipe_file) @property def repo_root(self) -> str: return str(self._project.root) def repo_path(self, rel: str) -> str: return str(safe_join(self._project.root, rel)) def var(self, name: str, default: object = _MISSING) -> str: if name in self._user_vars: return self._user_vars[name] if default is _MISSING: raise RuntimeError( f"ctx.var: no value provided for {name!r} and no default given" ) if not isinstance(default, str): raise RuntimeError( f"ctx.var: default for {name!r} must be a string " f"(got {type(default).__name__})" ) return default def subprocess( self, argv: list[str] | tuple[str, ...], cwd: str | None = None, env: Mapping[str, str] | None = None, produces: list["Artifact"] | tuple["Artifact", ...] = (), ) -> Invocation: if not argv: raise RuntimeError("ctx.subprocess: argv must be non-empty") if not all(isinstance(x, str) for x in argv): raise RuntimeError("ctx.subprocess: argv entries must be strings") resolved_cwd = self._project.root if cwd is None else pathlib.Path(cwd) env_map = dict(env) if env is not None else {} produces_tuple = tuple(produces) for a in produces_tuple: if not isinstance(a, Artifact): raise RuntimeError( "ctx.subprocess: produces entries must be Artifact values " "(returned by ctx.artifact(...))" ) return Invocation( argv=tuple(argv), cwd=resolved_cwd, env=env_map, produces=produces_tuple, ) def artifact( self, glob: str, root: str | None = None, ) -> Artifact: if not glob: raise RuntimeError("ctx.artifact: glob must be a non-empty string") if root is None: resolved_root = self._project.output_dir else: root_path = pathlib.Path(root) if root_path.is_absolute(): resolved_root = root_path.resolve() allowed_by_extras = any( resolved_root == extra or resolved_root.is_relative_to(extra) for extra in self._project.extra_artifact_roots ) if not allowed_by_extras and ( not resolved_root.is_relative_to(self._project.root.resolve()) ): raise RuntimeError( f"ctx.artifact: absolute root {resolved_root} is not in " f"extra_artifact_roots and not inside the project" ) else: resolved_root = safe_join(self._project.root, root) return Artifact(glob=glob, root=resolved_root) ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/ctx.py000066400000000000000000000262641520522431500211430ustar00rootroot00000000000000import abc import enum from functools import cached_property import os import pathlib from typing import ( Callable, Final, Generic, MutableMapping, TypeVar, TYPE_CHECKING, ) if TYPE_CHECKING: from typing_extensions import Self from ..log import RuyiLogger from . import api from . import paths from .build_api import ScheduledBuild from .traits import SupportsEvalFunction, SupportsGetOption, SupportsMessageStore ENV_PLUGIN_BACKEND_KEY: Final = "RUYI_PLUGIN_BACKEND" class PluginLoadMode(enum.Enum): """The context in which a Starlark module is being loaded. The mode controls which host API surfaces are exposed to the loaded module and what file system accesses are permitted. * ``PACKAGE_PLUGIN``: an ordinary plugin shipped inside a packages-index repository (profile plugins, device-provisioner strategies, ...). These have no access to the host filesystem outside of their plugin directory. * ``COMMAND_PLUGIN``: a ``ruyi-cmd-*`` plugin implementing a user-facing ``ruyi`` subcommand. Allowed to reach into the host filesystem via the ``host://`` load path scheme. * ``BUILD_RECIPE``: a ``ruyi admin build-package`` recipe. Rooted at a ``ruyi-build-recipes.toml`` project root; may register scheduled builds but has no host-FS access through load paths. """ PACKAGE_PLUGIN = "package-plugin" COMMAND_PLUGIN = "command-plugin" BUILD_RECIPE = "build-recipe" @property def allow_host_fs_access(self) -> bool: return self is PluginLoadMode.COMMAND_PLUGIN ModuleTy = TypeVar("ModuleTy", bound=SupportsGetOption, covariant=True) EvalTy = TypeVar("EvalTy", bound=SupportsEvalFunction, covariant=True) class PluginHostContext(Generic[ModuleTy, EvalTy], metaclass=abc.ABCMeta): @staticmethod def new( host_logger: RuyiLogger, plugin_root: pathlib.Path, *, locale: str | None = None, message_store_factory: Callable[[], SupportsMessageStore] | None = None, recipe_project_root: pathlib.Path | None = None, ) -> "PluginHostContext[SupportsGetOption, SupportsEvalFunction]": plugin_backend = os.environ.get("RUYI_PLUGIN_BACKEND", "") if not plugin_backend: plugin_backend = "unsandboxed" match plugin_backend: case "unsandboxed": return UnsandboxedPluginHostContext( host_logger, plugin_root, locale=locale, message_store_factory=message_store_factory, recipe_project_root=recipe_project_root, ) case _: raise RuntimeError(f"unsupported plugin backend: {plugin_backend}") def __init__( self, host_logger: RuyiLogger, plugin_root: pathlib.Path, *, locale: str | None = None, message_store_factory: Callable[[], SupportsMessageStore] | None = None, recipe_project_root: pathlib.Path | None = None, ) -> None: self._host_logger = host_logger self._plugin_root = plugin_root # resolved path: finalized module self._module_cache: MutableMapping[str, ModuleTy] = {} # plugin id: finalized plugin module self._loaded_plugins: dict[str, SupportsGetOption] = {} # plugin id: {key: value} self._value_cache: dict[str, dict[str, object]] = {} self._locale = locale or "" self._msg_store_factory = message_store_factory self._recipe_project_root = recipe_project_root capabilities: set[str] = {"call-subprocess-v1"} if self.has_i18n_capability(): # Expose the i18n-v1 feature only if the host context is properly # configured for it capabilities.add("i18n-v1") if recipe_project_root is not None: capabilities.add("build-recipe-v1") capabilities.discard("call-subprocess-v1") self._capabilities: frozenset[str] = frozenset(capabilities) # Scheduled builds, populated by RUYI.build.schedule_build during # load of a build-recipe module. Keyed by recipe file path. self._scheduled_builds: dict[pathlib.Path, list["ScheduledBuild"]] = {} @abc.abstractmethod def make_loader( self, originating_file: pathlib.Path, module_cache: MutableMapping[str, ModuleTy], load_mode: PluginLoadMode, ) -> "BasePluginLoader[ModuleTy]": raise NotImplementedError @abc.abstractmethod def make_evaluator(self) -> EvalTy: raise NotImplementedError @property def host_logger(self) -> RuyiLogger: return self._host_logger @property def plugin_root(self) -> pathlib.Path: return self._plugin_root @property def recipe_project_root(self) -> pathlib.Path | None: return self._recipe_project_root def scheduled_builds_for( self, recipe_file: pathlib.Path, ) -> list[ScheduledBuild]: """Return (creating if needed) the scheduled-build registry for the given recipe file. Shared by all ``RUYI.build.schedule_build`` calls within the same module load. """ return self._scheduled_builds.setdefault(recipe_file, []) def all_scheduled_builds(self) -> dict[pathlib.Path, list[ScheduledBuild]]: return self._scheduled_builds def load_recipe(self, recipe_file: pathlib.Path) -> list[ScheduledBuild]: """Load a build-recipe ``.star`` file via a fresh loader using the host context's shared module cache, then return the list of :class:`ScheduledBuild` entries registered during the load. Intended for callers outside the pluginhost package so they can drive recipe loading without reaching into private state. """ loader = self.make_loader( recipe_file, self._module_cache, PluginLoadMode.BUILD_RECIPE, ) loader.load_this_plugin() return list(self.scheduled_builds_for(recipe_file)) def load_plugin(self, plugin_id: str, load_mode: PluginLoadMode) -> None: plugin_dir = paths.get_plugin_dir(plugin_id, self._plugin_root) loader = self.make_loader( plugin_dir / paths.PLUGIN_ENTRYPOINT_FILENAME, self._module_cache, load_mode, ) loaded_plugin = loader.load_this_plugin() self._loaded_plugins[plugin_id] = loaded_plugin def is_plugin_loaded(self, plugin_id: str) -> bool: return plugin_id in self._loaded_plugins def get_from_plugin( self, plugin_id: str, key: str, is_cmd_plugin: bool = False, ) -> object | None: if not self.is_plugin_loaded(plugin_id): load_mode = ( PluginLoadMode.COMMAND_PLUGIN if is_cmd_plugin else PluginLoadMode.PACKAGE_PLUGIN ) self.load_plugin(plugin_id, load_mode) if plugin_id not in self._value_cache: self._value_cache[plugin_id] = {} try: return self._value_cache[plugin_id][key] except KeyError: v = self._loaded_plugins[plugin_id].get_option(key) self._value_cache[plugin_id][key] = v return v def has_i18n_capability(self) -> bool: return self._msg_store_factory is not None @property def capabilities(self) -> frozenset[str]: return self._capabilities @property def locale(self) -> str: return self._locale @cached_property def message_store(self) -> SupportsMessageStore | None: if self._msg_store_factory is None: return None return self._msg_store_factory() class BasePluginLoader(Generic[ModuleTy], metaclass=abc.ABCMeta): """Base class for plugin loaders loading from Ruyi repo. Load paths take one of the following shapes: * relative path: loads the path relative from the originating file's location, but crossing plugin boundary is not allowed * absolute path: similar to above, but relative to the plugin's FS root * `ruyi-plugin://${plugin-id}`: loads from the plugin `plugin-id` residing in the same repo as the originating plugin, the "entrypoint" being hard-coded to whatever the concrete implementation dictates """ def __init__( self, phctx: PluginHostContext[ModuleTy, SupportsEvalFunction], originating_file: pathlib.Path, module_cache: MutableMapping[str, ModuleTy], load_mode: PluginLoadMode, ) -> None: self._phctx = phctx self.originating_file = originating_file self.module_cache = module_cache self.load_mode = load_mode @property def host_logger(self) -> RuyiLogger: return self._phctx.host_logger @property def root(self) -> pathlib.Path: return self._phctx.plugin_root def make_sub_loader(self, originating_file: pathlib.Path) -> "Self": return self.__class__( self._phctx, originating_file, self.module_cache, self.load_mode, ) def load_this_plugin(self) -> ModuleTy: return self._load(str(self.originating_file), True) def load(self, path: str) -> ModuleTy: return self._load(path, False) def _load(self, path: str, is_root: bool) -> ModuleTy: resolved_path: pathlib.Path if is_root: resolved_path = pathlib.Path(path) else: resolved_path = paths.resolve_ruyi_load_path( path, self.root, False, self.originating_file, self.load_mode.allow_host_fs_access, recipe_project_root=self._phctx.recipe_project_root, ) resolved_path_str = str(resolved_path) if resolved_path_str in self.module_cache: return self.module_cache[resolved_path_str] if self.load_mode is PluginLoadMode.BUILD_RECIPE: recipe_root = self._phctx.recipe_project_root if recipe_root is None: raise RuntimeError( "BUILD_RECIPE load mode requires a recipe_project_root on " "the host context" ) plugin_dir = recipe_root else: plugin_id = resolved_path.relative_to(self.root).parts[0] plugin_dir = self.root / plugin_id host_bridge = api.make_ruyi_plugin_api_for_module( self._phctx, resolved_path, plugin_dir, self.load_mode, ) mod = self.do_load_module( resolved_path, resolved_path.read_text("utf-8"), host_bridge, ) self.module_cache[resolved_path_str] = mod return mod @abc.abstractmethod def do_load_module( self, resolved_path: pathlib.Path, program: str, ruyi_host_bridge: Callable[[object], api.RuyiHostAPI], ) -> ModuleTy: raise NotImplementedError # import the built-in supported PluginHostContext implementation(s) # this must come after the baseclass declarations # pylint: disable-next=wrong-import-position from .unsandboxed import UnsandboxedPluginHostContext # noqa: E402 ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/paths.py000066400000000000000000000135151520522431500214570ustar00rootroot00000000000000import pathlib import re from typing import Final from urllib.parse import ParseResult, unquote, urlparse PLUGIN_ENTRYPOINT_FILENAME: Final = "mod.star" PLUGIN_DATA_DIR: Final = "data" PLUGIN_ID_RE: Final = re.compile("^[A-Za-z_][A-Za-z0-9_-]*$") def validate_plugin_id(name: str) -> None: if PLUGIN_ID_RE.match(name) is None: raise RuntimeError(f"invalid plugin ID '{name}'") def get_plugin_dir(plugin_id: str, plugin_root: pathlib.Path) -> pathlib.Path: validate_plugin_id(plugin_id) return plugin_root / plugin_id def resolve_ruyi_load_path( path: str, plugin_root: pathlib.Path, is_for_data: bool, originating_file: pathlib.Path, allow_host_fs_access: bool, recipe_project_root: pathlib.Path | None = None, ) -> pathlib.Path: parsed = urlparse(path) if parsed.params or parsed.query or parsed.fragment: raise RuntimeError("fancy URI features are not supported for load paths") match parsed.scheme: case "": if parsed.netloc: raise RuntimeError("'//' is not allowed as load path prefix") return resolve_plain_load_path( parsed.path, plugin_root, is_for_data, originating_file=originating_file, ) case "ruyi-plugin": if is_for_data: raise RuntimeError( "the ruyi-plugin protocol is not allowed in this context" ) if parsed.path: raise RuntimeError( "non-empty path segment is not allowed for ruyi-plugin:// load paths" ) if not parsed.netloc: raise RuntimeError( "empty location is not allowed for ruyi-plugin:// load paths" ) plugin_id = unquote(parsed.netloc) return get_plugin_dir(plugin_id, plugin_root) / PLUGIN_ENTRYPOINT_FILENAME case "ruyi-plugin-data": if not is_for_data: raise RuntimeError( "the ruyi-plugin-data protocol is not allowed in this context" ) if not parsed.path: raise RuntimeError( "empty path segment is not allowed for ruyi-plugin-data:// load paths" ) if not parsed.netloc: raise RuntimeError( "empty location is not allowed for ruyi-plugin-data:// load paths" ) return resolve_plain_load_path( parsed.path, plugin_root, True, plugin_id=parsed.netloc, ) case "host": if not allow_host_fs_access: raise RuntimeError("the host protocol is not allowed in this context") if not parsed.path: raise RuntimeError( "empty path segment is not allowed for host:// load paths" ) if parsed.netloc: raise RuntimeError( "non-empty location is not allowed for host:// load paths" ) return pathlib.Path(parsed.path) case "ruyi-build": if is_for_data: raise RuntimeError( "the ruyi-build protocol is not allowed in this context" ) return _resolve_ruyi_build(parsed, recipe_project_root) case "ruyi-build-data": if not is_for_data: raise RuntimeError( "the ruyi-build-data protocol is not allowed in this context" ) return _resolve_ruyi_build(parsed, recipe_project_root) case _: raise RuntimeError( f"unsupported Ruyi Starlark load path scheme {parsed.scheme}" ) def _resolve_ruyi_build( parsed: ParseResult, recipe_project_root: pathlib.Path | None, ) -> pathlib.Path: if recipe_project_root is None: raise RuntimeError( f"the {parsed.scheme} protocol is only available when loading " f"a build recipe" ) if not parsed.netloc and not parsed.path: raise RuntimeError( f"empty path is not allowed for {parsed.scheme}:// load paths" ) # Reconstruct the repo-relative path. urlparse of "ruyi-build://lib/x.star" # puts "lib" in netloc and "/x.star" in path; reassemble them. combined = parsed.netloc + parsed.path rel = combined.lstrip("/") if not rel: raise RuntimeError( f"empty path is not allowed for {parsed.scheme}:// load paths" ) root_resolved = recipe_project_root.resolve() joined = (root_resolved / pathlib.PurePosixPath(rel)).resolve() if not joined.is_relative_to(root_resolved): raise RuntimeError( f"{parsed.scheme}:// load path {combined!r} escapes " f"recipe project root {root_resolved}" ) return joined def resolve_plain_load_path( path: str, plugin_root: pathlib.Path, is_for_data: bool, *, originating_file: pathlib.Path | None = None, plugin_id: str | None = None, ) -> pathlib.Path: if originating_file is None and plugin_id is None: raise ValueError("one of originating_file or plugin_id must be specified") if plugin_id is None: assert originating_file is not None rel = originating_file.relative_to(plugin_root) plugin_id = rel.parts[0] plugin_dir = plugin_root / plugin_id if is_for_data: plugin_dir = plugin_dir / PLUGIN_DATA_DIR p = pathlib.PurePosixPath(path) if p.is_absolute(): return plugin_dir / p.relative_to("/") resolved = (plugin_dir / p).resolve() if not resolved.is_relative_to(plugin_dir): raise ValueError("plain load paths are not allowed to cross plugin boundary") return resolved ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/plugin_cli.py000066400000000000000000000017461520522431500224700ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import AdminCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class AdminRunPluginCommand( AdminCommand, cmd="run-plugin-cmd", help=_("Run a plugin-defined command"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "cmd_name", type=str, metavar="COMMAND-NAME", help=_("Command name"), ) p.add_argument( "cmd_args", type=str, nargs="*", metavar="COMMAND-ARG", help=_("Arguments to pass to the plugin command"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: cmd_name = args.cmd_name cmd_args = args.cmd_args return cfg.repo.run_plugin_cmd(cmd_name, cmd_args) ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/traits.py000066400000000000000000000011131520522431500216350ustar00rootroot00000000000000from typing import Protocol class SupportsGetOption(Protocol): def get_option(self, key: str) -> object: ... class SupportsEvalFunction(Protocol): def eval_function( self, function: object, *args: object, **kwargs: object, ) -> object: ... class SupportsMessageStore(Protocol): def get_message_template(self, msgid: str, lang_code: str) -> str | None: ... def render_message( self, msgid: str, lang_code: str, params: dict[str, str], add_trailing_newline: bool = False, ) -> str: ... ruyisdk-ruyi-1f00e2e/ruyi/pluginhost/unsandboxed.py000066400000000000000000000567271520522431500226660ustar00rootroot00000000000000"""Unsandboxed Python plugin host. Rationale --------- Ruyi plugins were originally authored in Starlark and executed through ``xingque`` (a binding to ``starlark-rust``), which provided genuine language-level isolation. That backend was dropped in October 2024; see commits ``bc458ce`` (the introduction of this file), ``a9d66bd`` (making it the default), and ``2b7c24d`` / ``9e5abe1`` (removal of the Starlark backend). The stated reason in ``bc458ce`` is that the plugin surface was restricted to standard Python 3 rather than any dialect of it. The underlying project-level goal is broader: the whole of RuyiSDK is kept to Python and shell so that onboarding is trivial, so that loss of project staff is survivable, and so that third-party commercial partners can take over maintenance of their own forks without being blocked by any non-trivial piece of code in a less widely known language. Reintroducing Starlark -- or any other embedded language or Rust-backed sandbox -- would reintroduce exactly the kind of specialist-knowledge cliff this policy exists to avoid, and is therefore not on the table regardless of its technical merits. Threat model and non-goals -------------------------- This module is intentionally *not* a sandbox. Plugin sources are parsed, AST-linted, compiled, and ``exec``-ed in the host interpreter with a curated ``__builtins__`` mapping. A malicious plugin can escape trivially. The original justification, as recorded in ``bc458ce`` in 2024, was: No "outsiders" are involved in plugin creation yet, and attacks from "insiders" are not going to be thwarted by code-level sandboxing alone. That premise has since lapsed and the quote should be read as historical rather than current. Third-party addon repositories are a supported feature, which means plugin code loaded by Ruyi can now originate from authors outside the project. An unsandboxed Python runtime provides no meaningful defence against a hostile or compromised third-party addon; in particular, the capability set in ``PluginHostContext`` (``call-subprocess-v1``, ``build-recipe-v1``, ``i18n-v1``, ...) is an API-shape boundary, not a security boundary, and is trivially escapable from in-process Python. The present mitigations against this are operational rather than technical: a prominent warning and explicit user confirmation on ``ruyi repo add``, and the expectation that users extend trust to third-party repos on the same footing as any other third-party code they choose to run. Revisiting this with an actual sandbox (process isolation, a reintroduced Starlark backend, or another mechanism) is out of scope of this module and currently gated on project-level decisions rather than technical ones; see https://github.com/ruyisdk/ruyi/issues/444 for tracking. Recursion detection, resource limits, timeouts, and filesystem or network isolation are likewise out of scope here; they are not soundly achievable with pure AST inspection plus ``exec`` in CPython. What the module does enforce, and why, is documented on the individual enforcement points themselves: ``BUILTINS_TO_EXPOSE``, ``GatedLanguageFeaturesPass``, and ``_load_stmt_helper`` below, plus the ``PluginHostContext`` capability set in ``api.py`` / ``build_api.py``. The unifying goal of those checks is not security but *Starlark portability*: keeping plugin sources close to the shape of the original Starlark subset so that a future move back to a real Starlark runtime -- should the project-level policy above ever shift -- would not require rewriting every in-tree plugin. """ import ast import builtins import inspect import os import pathlib from types import CodeType from typing import Callable, Final, MutableMapping, NoReturn, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Buffer from .api import RuyiHostAPI from .ctx import PluginHostContext, BasePluginLoader, PluginLoadMode class UnsandboxedModuleDict(dict[str, object]): def get_option(self, key: str) -> object: return self.get(key, None) class UnsandboxedTrivialEvaluator: def eval_function( self, function: object, *args: object, **kwargs: object, ) -> object: if callable(function): return function(*args, **kwargs) raise RuntimeError(f"the Python value {function!r} is not callable") # The set of Python builtins exposed to plugin code in place of the real # ``builtins`` module. Membership criterion: the name must either exist in # Starlark or map cleanly onto a Starlark equivalent, so that keeping a # plugin portable to a future Starlark backend does not require giving up # any builtin listed here. Introspection / reflection / dynamic-eval # builtins (``eval``, ``exec``, ``compile``, ``globals``, ``locals``, # ``vars``, ``__import__``, ``open``, ...) are deliberately absent for the # same reason: they have no Starlark counterpart. This is a portability # fence, not a security boundary -- see the module docstring. BUILTINS_TO_EXPOSE: Final = { k: getattr(builtins, k) for k in [ "abs", "any", "all", "bool", "bytes", "dict", "dir", "enumerate", "float", "getattr", "hasattr", "hash", "int", "len", "list", "max", "min", "print", "range", "repr", "reversed", "sorted", "str", "tuple", "type", "zip", ] } def _fail_helper(*args: object) -> NoReturn: raise RuntimeError(f"fail: {''.join(str(x) for x in args)}") class UnsandboxedPluginHostContext( PluginHostContext[UnsandboxedModuleDict, UnsandboxedTrivialEvaluator] ): def make_loader( self, originating_file: pathlib.Path, module_cache: MutableMapping[str, UnsandboxedModuleDict], load_mode: PluginLoadMode, ) -> BasePluginLoader[UnsandboxedModuleDict]: return UnsandboxedRuyiPluginLoader( self, originating_file, module_cache, load_mode ) def make_evaluator(self) -> UnsandboxedTrivialEvaluator: return UnsandboxedTrivialEvaluator() def _is_name_private(n: str) -> bool: return n.startswith("_") def _assert_name_is_public(n: str) -> None | NoReturn: if _is_name_private(n): raise RuntimeError(f"error: trying to load private name {n}") return None class UnsandboxedRuyiPluginLoader(BasePluginLoader[UnsandboxedModuleDict]): def do_load_module( self, resolved_path: pathlib.Path, program: str, ruyi_host_bridge: Callable[[object], RuyiHostAPI], ) -> UnsandboxedModuleDict: self.host_logger.D(f"unsandboxed module load: path {resolved_path}") sub_loader = self.make_sub_loader(resolved_path) def _load_stmt_helper( spec: str, *values_to_bind: str, **renamed_values_to_bind: str, ) -> None: """Starlark-style ``load()`` exposed to plugins. This is deliberately *not* a wrapper over Python ``import``: it mirrors Starlark's ``load()`` statement so that plugin sources remain portable to a real Starlark backend. In particular, it binds names by injection into the caller's frame (matching Starlark semantics), and it refuses to bind names beginning with ``_``, matching Starlark's rule that underscore-prefixed symbols are module-private. ``import`` / ``from ... import ...`` are separately rejected by ``GatedLanguageFeaturesPass`` so that ``load()`` is the only way for plugins to pull in other modules. """ mod = sub_loader.load(spec) curr_frame = inspect.currentframe() if curr_frame is None: raise RuntimeError( "cannot inspect the Python runtime for the current frame" ) parent_frame = curr_frame.f_back if parent_frame is None: raise RuntimeError( "internal error: no parent frame for load() statement" ) g = parent_frame.f_locals for name in values_to_bind: _assert_name_is_public(name) g[name] = mod[name] for dst_name, src_name in renamed_values_to_bind.items(): _assert_name_is_public(src_name) g[dst_name] = mod[src_name] return None code = self.source_to_code(program, resolved_path) mod_globals: dict[str, object] = { "__builtins__": BUILTINS_TO_EXPOSE, "fail": _fail_helper, "load": _load_stmt_helper, "ruyi_plugin_rev": ruyi_host_bridge, } # pylint: disable-next=exec-used exec(code, mod_globals) return UnsandboxedModuleDict(mod_globals) # intentionally follows the importlib.abc.InspectLoader protocol, for # easier refactoring whenever necessary. @staticmethod def source_to_code( data: "Buffer | str | ast.Module", path: "str | os.PathLike[str]" = "", ) -> CodeType: mod_ast: ast.Module if isinstance(data, ast.Module): mod_ast = data else: # isinstance(data, str) or isinstance(data, Buffer) mod_ast = ast.parse(data, path, "exec") # lint the module on a best-effort basis to help fight syntax feature # creep lint_module(mod_ast) return compile(mod_ast, path, "exec") def lint_module(mod: ast.Module) -> None: """Run best-effort parse-time lints over a plugin module AST. Currently this runs only ``GatedLanguageFeaturesPass``; additional best-effort static checks (for example a call-graph pass flagging obvious direct or mutual recursion) may be layered in over time. Anything added here should be understood as a lint, not a soundness guarantee -- see the module docstring for why real enforcement is out of scope. """ try: GatedLanguageFeaturesPass().visit(mod) except _GatedFeatureError as e: raise RuntimeError( f"line {e.node.lineno}: {e.feature} is not allowed in plugin code" ) from e class _GatedFeatureError(Exception): """Internal signal raised by ``GatedLanguageFeaturesPass`` when it encounters a gated construct. Carries the offending AST node (for its line number) and a short human-readable name of the feature, so ``lint_module`` can surface a useful diagnostic instead of the bare node type. """ def __init__(self, node: ast.stmt | ast.expr | ast.arg, feature: str) -> None: super().__init__(feature) self.node = node self.feature = feature def _reject_annotated_args(args: ast.arguments) -> None: """Raise ``_GatedFeatureError`` if any parameter in ``args`` carries a type annotation. Starlark's Parameter grammar has no annotation production, so annotations on any category of parameter -- regular, positional-only, keyword-only, ``*args``, or ``**kwargs`` -- are rejected uniformly. """ for arg in ( *args.posonlyargs, *args.args, *args.kwonlyargs, *((args.vararg,) if args.vararg is not None else ()), *((args.kwarg,) if args.kwarg is not None else ()), ): if arg.annotation is not None: raise _GatedFeatureError(arg.annotation, "parameter type annotation") def _find_slice_assign_target(target: ast.expr) -> ast.Subscript | None: """Return the offending ``Subscript`` node if ``target`` is, or directly contains within a top-level tuple/list unpacking, a subscript whose slice is an ``ast.Slice``. Return ``None`` otherwise. The check is deliberately shallow: Starlark's spec says "Starlark does not allow a slice expression to be the target of an assignment, although it may appear as a subexpression in the target," and forms like ``a[b[c:d]] = x`` are explicitly legal. We therefore only inspect the target spine itself (tuple/list unpacking), not arbitrary sub-expressions underneath a Subscript's value. """ if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Slice): return target if isinstance(target, (ast.Tuple, ast.List)): for elt in target.elts: if (found := _find_slice_assign_target(elt)) is not None: return found return None def _find_starred_in_target(target: ast.expr) -> ast.Starred | None: """Return the offending ``Starred`` node if ``target`` is, or contains anywhere within a tuple/list unpacking spine, a starred expression. Return ``None`` otherwise. Starlark's ``LoopVariables`` and ``AssignStmt`` LHS grammars do not permit ``*x`` unpacking; a starred target would have no portable equivalent. Starred expressions on the *right-hand side* or inside call arguments (``f(*args)``) are a separate grammatical position and are not checked here. """ if isinstance(target, ast.Starred): return target if isinstance(target, (ast.Tuple, ast.List)): for elt in target.elts: if (found := _find_starred_in_target(elt)) is not None: return found return None class GatedLanguageFeaturesPass(ast.NodeVisitor): """Reject Python syntax that has no Starlark analogue. Each ``visit_*`` override below names one construct that is gated at parse time. The selection criterion is *Starlark portability*, not safety: a feature is gated if accepting it would make it materially harder to move plugin sources back to a real Starlark runtime later. Rejected constructs include, among others: * ``NamedExpr`` (walrus) -- not in Starlark. * Decorators, return type annotations, parameter annotations and positional-only parameters on ``FunctionDef`` -- none of these have a production in Starlark's ``DefStmt`` or Parameter grammar. * ``AnnAssign`` (variable type annotations) -- no analogue. * ``JoinedStr`` (f-strings) -- Starlark has only plain string and bytes literals. * ``Set`` / ``SetComp`` -- Starlark has no set type or set comprehensions. * ``GeneratorExp`` -- Starlark has no generators. * ``Delete`` (``del`` statement) -- not in Starlark. * ``BinOp`` / ``AugAssign`` with ``MatMult`` (``@``, ``@=``) -- not in Starlark's operator list. * ``Compare`` with more than one operator (chained comparisons) -- Starlark's comparison operators are non-associative. * ``While`` -- not in Starlark's Statement grammar. * Slice expressions in assignment targets, and starred expressions in assignment / loop-variable targets -- forbidden by Starlark's ``AssignStmt`` and ``LoopVariables`` grammars respectively. * ``Raise``, ``Assert`` -- Starlark uses ``fail()`` for errors. * ``Import`` / ``ImportFrom`` -- Starlark uses ``load()``; see ``_load_stmt_helper``. * ``Try`` / ``TryStar`` / ``With`` -- no Starlark equivalents. * ``Match`` -- not in Starlark. * ``Yield`` / ``YieldFrom`` -- Starlark has no generators. * ``Global`` / ``Nonlocal`` -- Starlark's scoping rules differ. * ``ClassDef`` -- Starlark has no user-defined classes. * ``AsyncFunctionDef`` / ``Await`` / ``AsyncFor`` / ``AsyncWith`` -- Starlark has no async model. The gate is necessarily best-effort: CPython's grammar is larger than Starlark's and evolves between releases. When a new language feature lands in CPython, the default choice should be to add it here -- anything not already required by an existing plugin is cheaper to forbid now than to un-ship later. This is a portability fence, not a security boundary; see the module docstring. """ def visit_NamedExpr(self, node: ast.NamedExpr) -> None: raise _GatedFeatureError(node, "walrus operator (`:=`)") def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # Decorators have no analogue in the Starlark ``DefStmt`` grammar. # Reject them, but continue recursing into the body so that other # gated constructs inside the function are still reported. if node.decorator_list: raise _GatedFeatureError(node.decorator_list[0], "decorator") # Starlark's parameter grammar has no annotation syntax, and its # function headers have no return-type annotation. Reject both. if node.returns is not None: raise _GatedFeatureError(node.returns, "return type annotation") _reject_annotated_args(node.args) # Starlark's Parameter grammar has no ``/`` marker (PEP 570), so # positional-only parameters have no way to be expressed there. if node.args.posonlyargs: raise _GatedFeatureError( node.args.posonlyargs[0], "positional-only parameter (`/`)" ) self.generic_visit(node) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: raise _GatedFeatureError(node, "variable type annotation") def visit_JoinedStr(self, node: ast.JoinedStr) -> None: # Starlark's lexical grammar offers only plain string and bytes # literals. Reject f-strings so that plugin sources stay portable. raise _GatedFeatureError(node, "f-string") def visit_Set(self, node: ast.Set) -> None: # Starlark has no set type and therefore no set-display syntax. raise _GatedFeatureError(node, "set display") def visit_SetComp(self, node: ast.SetComp) -> None: # Starlark's comprehension grammar offers only ListComp and # DictComp; set comprehensions have no equivalent. raise _GatedFeatureError(node, "set comprehension") def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: # Starlark has no generators and no generator-expression # production in its grammar. raise _GatedFeatureError(node, "generator expression") def visit_Delete(self, node: ast.Delete) -> None: # Starlark's spec explicitly states that it does not have a # ``del`` statement. raise _GatedFeatureError(node, "`del` statement") def visit_While(self, node: ast.While) -> None: # Starlark's Statement grammar only has DefStmt, IfStmt, ForStmt # and SimpleStmt; ``while`` is listed among the reserved-but- # unused keywords. Rejecting it here also keeps plugin loops # bounded in the same way Starlark intends. raise _GatedFeatureError(node, "`while` loop") def visit_BinOp(self, node: ast.BinOp) -> None: # ``@`` (matrix multiplication) is not in Starlark's binary # operator list. Other binary operators are fine. if isinstance(node.op, ast.MatMult): raise _GatedFeatureError(node, "matrix-multiplication operator (`@`)") self.generic_visit(node) def visit_AugAssign(self, node: ast.AugAssign) -> None: # Same reasoning for the in-place form ``@=``. if isinstance(node.op, ast.MatMult): raise _GatedFeatureError(node, "matrix-multiplication assignment (`@=`)") # Starlark forbids a slice expression on the LHS of an assignment. if (bad := _find_slice_assign_target(node.target)) is not None: raise _GatedFeatureError(bad, "slice as assignment target") # Starlark does not permit ``*x`` unpacking in an assignment LHS. if (starred := _find_starred_in_target(node.target)) is not None: raise _GatedFeatureError(starred, "starred assignment target") self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: # Starlark forbids a slice expression on the LHS of an assignment. for target in node.targets: if (bad := _find_slice_assign_target(target)) is not None: raise _GatedFeatureError(bad, "slice as assignment target") if (starred := _find_starred_in_target(target)) is not None: raise _GatedFeatureError(starred, "starred assignment target") self.generic_visit(node) def visit_For(self, node: ast.For) -> None: # Starlark's LoopVariables grammar permits no ``*x`` unpacking. if (starred := _find_starred_in_target(node.target)) is not None: raise _GatedFeatureError(starred, "starred loop variable") self.generic_visit(node) def visit_ListComp(self, node: ast.ListComp) -> None: for gen in node.generators: if (starred := _find_starred_in_target(gen.target)) is not None: raise _GatedFeatureError(starred, "starred loop variable") self.generic_visit(node) def visit_DictComp(self, node: ast.DictComp) -> None: for gen in node.generators: if (starred := _find_starred_in_target(gen.target)) is not None: raise _GatedFeatureError(starred, "starred loop variable") self.generic_visit(node) def visit_Compare(self, node: ast.Compare) -> None: # Starlark spec: "Comparison operators, ``in``, and ``not in`` are # non-associative, so the parser will not accept ``0 <= i < n``." # Reject chained comparisons (more than one operator in a single # Compare node) accordingly. if len(node.ops) > 1: raise _GatedFeatureError(node, "chained comparison") # Starlark's comparison operator list is ``==``, ``!=``, ``<``, # ``>``, ``<=``, ``>=``, ``in`` and ``not in``; there are no # identity operators. for op in node.ops: if isinstance(op, ast.Is): raise _GatedFeatureError(node, "`is` operator") if isinstance(op, ast.IsNot): raise _GatedFeatureError(node, "`is not` operator") self.generic_visit(node) def visit_Raise(self, node: ast.Raise) -> None: raise _GatedFeatureError(node, "`raise` statement") def visit_Assert(self, node: ast.Assert) -> None: raise _GatedFeatureError(node, "`assert` statement") def visit_Import(self, node: ast.Import) -> None: raise _GatedFeatureError(node, "`import` statement") def visit_ImportFrom(self, node: ast.ImportFrom) -> None: raise _GatedFeatureError(node, "`from ... import ...` statement") def visit_Try(self, node: ast.Try) -> None: raise _GatedFeatureError(node, "`try` statement") def visit_TryStar(self, node: ast.TryStar) -> None: raise _GatedFeatureError(node, "`try ... except*` statement") def visit_With(self, node: ast.With) -> None: raise _GatedFeatureError(node, "`with` statement") def visit_Match(self, node: ast.Match) -> None: raise _GatedFeatureError(node, "`match` statement") def visit_Yield(self, node: ast.Yield) -> None: raise _GatedFeatureError(node, "`yield` expression") def visit_YieldFrom(self, node: ast.YieldFrom) -> None: raise _GatedFeatureError(node, "`yield from` expression") def visit_Global(self, node: ast.Global) -> None: raise _GatedFeatureError(node, "`global` statement") def visit_Nonlocal(self, node: ast.Nonlocal) -> None: raise _GatedFeatureError(node, "`nonlocal` statement") def visit_ClassDef(self, node: ast.ClassDef) -> None: raise _GatedFeatureError(node, "`class` definition") def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: raise _GatedFeatureError(node, "`async def` function") def visit_Await(self, node: ast.Await) -> None: raise _GatedFeatureError(node, "`await` expression") def visit_AsyncFor(self, node: ast.AsyncFor) -> None: raise _GatedFeatureError(node, "`async for` loop") def visit_AsyncWith(self, node: ast.AsyncWith) -> None: raise _GatedFeatureError(node, "`async with` statement") ruyisdk-ruyi-1f00e2e/ruyi/py.typed000066400000000000000000000000001520522431500172520ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/resource_bundle/000077500000000000000000000000001520522431500207455ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/resource_bundle/__init__.py000066400000000000000000000016041520522431500230570ustar00rootroot00000000000000import base64 import zlib from .data import RESOURCES, TEMPLATE_NAME_MAP def _unpack_payload(x: bytes) -> bytes: return zlib.decompress(base64.b64decode(x)) _CACHE: dict[str, bytes] = {} def get_resource_blob(name: str) -> bytes | None: if t := RESOURCES.get(name): if name not in _CACHE: # In our use cases, the program is short-lived and involved resources # are small in size, so it is fine to just store the decompressed # blobs without eviction. _CACHE[name] = _unpack_payload(t) return _CACHE[name] return None def get_resource_str(name: str) -> str | None: if blob := get_resource_blob(name): return blob.decode("utf-8") return None def get_template_str(template_name: str) -> str | None: if t := TEMPLATE_NAME_MAP.get(template_name): return get_resource_str(t) return None ruyisdk-ruyi-1f00e2e/ruyi/resource_bundle/__main__.py000077500000000000000000000031731520522431500230460ustar00rootroot00000000000000#!/usr/bin/env python3 # Regenerates data.py from fresh contents. import base64 import pathlib from typing import Any import zlib def make_payload_from_file(path: pathlib.Path) -> str: with open(path, "rb") as fp: content = fp.read() return base64.b64encode(zlib.compress(content, 9)).decode("ascii") def main() -> None: self_path = pathlib.Path(__file__).parent.resolve() bundled_resource_root = self_path / ".." / ".." / "resources" / "bundled" resources: dict[str, str] = {} template_names: dict[str, str] = {} for f in bundled_resource_root.glob("**/*"): if not f.is_file(): continue rel_path = f.relative_to(bundled_resource_root) resources[str(rel_path)] = make_payload_from_file(f) if f.suffix.lower() == ".jinja": # strip the .jinja suffix for the template name template_names[str(rel_path.with_suffix(""))] = str(rel_path) with open(self_path / "data.py", "w", encoding="utf-8") as fp: def p(*args: Any) -> None: return print(*args, file=fp) p("# NOTE: This file is auto-generated. DO NOT EDIT!") p("# Update by running the __main__.py alongside this file\n") p("from typing import Final\n\n") p("RESOURCES: Final = {") for filename, payload in sorted(resources.items()): p(f' "{filename}": b"{payload}", # fmt: skip') p("}\n") p("TEMPLATE_NAME_MAP: Final = {") for stem, full_filename in sorted(template_names.items()): p(f' "{stem}": "{full_filename}",') p("}") if __name__ == "__main__": main() ruyisdk-ruyi-1f00e2e/ruyi/resource_bundle/data.py000066400000000000000000001173041520522431500222360ustar00rootroot00000000000000# NOTE: This file is auto-generated. DO NOT EDIT! # Update by running the __main__.py alongside this file from typing import Final RESOURCES: Final = { "_ruyi_completion": b"eNqlVntP40YQ/z+fYmosCC0h5KpKd7Tmjoc5ogYSxUlPd4FaTryJV9i7kXdNLofoZ++M7TgmmOiutVDYncdvZuexOzsTGc19NoU4WfJabQc6/J6FS/BAxzyKmA++XAh4YLHiUoCcQqD1XB03mzOug2R8iPrNe67CZXLf9OIZwYVMs+Y4lONm5HHxnOqpwM13iHfoN935UgdSNEpS6MUVC+doEgLvgYHPleZiomEesyn/CtNYRqADBjLmMy68ENQk5nMNWoL3ILkPwosYTGQYcvJa0bn6iQAlI6YDLmYHECUIOQOZ6HmiEQhi5vOYTVIqT6HIgs/GyQyUjpkXIYjP5kz4qaJI+Q9emDCKinvaP3cv7LPhx0OUa0/htP/xvHvd69gD2x06tjuwr3uX7Y7tAFegmD6ARDHQLJpPecgUTNGHdu/8sObmEXFLEXEpO/gj6vvwWAP8+BRGI2h8A8N8fNVU48mAu7vfyVORatG3Fd/lQrAYQT8YhULMdBJn+lOe/gvlBIOuM9ctw6xH93SQ/UzHLfvjDC66w4FL7tycXtsonKsZP+ZIZnIifWaZ71PKxNOwRktJcfSCkvoOJinWnmq177BZGeJ1eitjSm7C25PdFrw72X0DrZOmzx6aIglDeEPUP4p9qsJCxV7X3X1XqYSxpwO84n/hdRao9qVjmXu3R61f90rUrE0swyifT9D5vjhX7l9232l3byoPuAr/qnFVwSnRrLpZJ8OGib8G3BYy9FFFuJ32DZXA2fDy0u5XSvS67ZsBipwP+073hUi5tKzWFqbrXNmdjmV8U4GxVWzY6/Vtx3Gd3ul5BeS2cgHzMQvoccN8XMjYV6PW3dPT/v5G1IRUMtZWvYI+9yaszMCUcNXwdCNkntLw2+HbjTzQt8JryHy51s/7c51dTG0pQZRa6x+oj/6+vb3bP4bdXRKIPD0JyPWUObKax3cvCyA3nDnccGBvr9Kq6zOKyZgRcBETo1wlxMn8Hn0gVrYl4HT/skGyaG1k6qh8UEKXeP//Ilc+YgdB0T4VR9nMe9VZqEj6dq/z+bvrulhvL+1iUyk2+NwrsGi9tX5TqU/d/sVZ3z7901nprSn/u4Poxf6RFjKf7/9rQ7XKfZTVsvkefrLgqLo2E4Fv6jplBY+FufKGW5YFLUSiFsjapFBtlPrArDa2qrdGUW+b9ZNe1uUHZNsFuwpECY+WOJV5SZhaoSQU20t4LYjZEFc0zw58QhP4UIYhznHjZTqxfFFBqRtBLRU+3QfIwrFkwfHBwgEHFzrIIYxQer6XaDlNxMSABSFywTVHVBwTkUECiO+hnqGCTCz0NL7eUhyARKPxgit2kAOSE/m8tvDwNmA4QxkNBFAyHYmMfCI1aAaL2QyHP4TCkYycIu0c58UhUvrKofI9WnrtTHwQXLKIQ6jQ7KumQviZfK4YlF6LcjGUPLumVoP09uTkxYF//wJg/FlP", # fmt: skip "binfmt.conf.jinja": b"eNpNUrlu20AQ7f0VAwgCbECk+wQp06Zwl0oakUNyoD2UvQRC1r/7LUkhrkjuzjuHOzqrG2w6Wo0ddd4NOlLMmvhshAYfKE0a6SPPSkVDymxIHN68s+LS4WVH6ijOMYntm5XrSQO05fSTogidLLtNqu1PC3EvidXEFhR/faY4+Wx6CjIqyAJ5J+QHyFcbxvibuhGYznDgpN69xrdD1Wa68QwOvl6DvwbltPqefYYGuIKecwXQO5yEop0QzPAIjXeS1B0I0+qKvwhoqp7YbDjhFIY5zAtVFDOgAV4GTgF9NP8wd6JbgLKE9uW+X3ThYqy+gkRvivTHehBp/wD57yfx/b7MtfB3NTwfHVuhxwMcDemw3qHmBQXcH49QaeJUrRCb6GnigiNfR4v29VaifF8NFUYZ2GIk4EJ2Sa1s+/KhR3qguZtUQNT5EKRLW/JaVhSUlLSLP+Bgi3Y5UKnwp71WsfX4+gabO2pqqAtC/MKz0CcWuiaCqb6i92s8fCFhU7+2Er79gUes69nDf9gX9HfteA==", # fmt: skip "locale/zh_CN/LC_MESSAGES/argparse.mo": b"eNqFld1PFFcUwMeCqEvTB1urbfpxa6Ww6tBlW1q6+IEitTR8FYk2fagddu/ujp2dWefOCPhSFFEREUwlosEPFBStiNFEF1hKYtr+AU3fmrSJmZndfSnv7YM9d+4suyAoyeZ3555zzz1f9/DX6tx+Dv7eg99b8NuwjJv3t/kljlsL5IEvA6uAbwMbge8Co8CPgX3AN4C/AguBfwM3AlfkgE2gN4ft7wNuAf6Qw+Rx4DvARA6zm5fLcWuoH7nMH18u820f8CNgG/Ar4Jgjfwp8n/q+nOlVAFcAm4GvAQ8AP6D3LWf+3gDmA38DFgF/X87s/AcsBa7L47g3gXV5zI4IvA45Oejsr1nJca8APUAE3O7wO2A58AjwdeA0sAD4B9AN/Hcli3fDKpaH+lUs3hbgq8B+4DrgLYePgauBfzrf/wDX03y6ICfAjcBvgdjF4vrFxeIwXExvGQSZR+/LZ3XblM/yU5bP8v0NcBXNZz7zoyOfxTGUz+rzOJ/V/amjt5YaQUUBHBR0SfOhgvTSTdxcQVEYCwFRDrmJDz6iqkJXCKuqolLVCCZECGE3cWVJWwRVhiML5EKkWQzpik6QEtVERaZitnIT5Fd0KYAiguYP01OUmLgJJ6ghPYJlDa3n16MWUQujiBLAqEDNSAqK0sv9shDB1IGsezN6hc8oFiKRoACOqtgvaDjA+QW5UAPvsEyVg6KEHT1q0Q4Z7PmVSESQA1QjKqgEq0sZU+SgJPo1SIQTMCKaytJClpYSW4xbo9gPVmCN0k4vupmlKmhIwgKBAGS8yCEQR5QlpfN2xZCsqLAJUvBR1FAm1yonyocESQQnirS2KMSN4FPHNEP2wp3R8IcV0Z8tQUWwpRCMgqoSgW2mQGifLXlmMzRFWzNGbYqOIliw6+2XwAjR3Or251iUFfBbkpQWCMRunEwQhKPhKkGkhTNRE5pWKKGKD+oiRM85ZSnMdOnCErN9wkUVItKVIGXVhYSVFkRfhCpECiFLWCXUnKxHmrGKaAfhVlFjaloYDIexFEVO32bJ9Wan4whH3Q0qNCbaNhnHBRXPuW23jy5nCvtM6+ny97LSIiPWvWh+G7Ma0Rz65qdTlyFqJSSLh2kvpa9mBqnHPsQ1qMoBuJSvDvB7WbQ+1NBY/2VVZRPaW9W4p7q+ztWIo4qq8bUkJAb4nXqI8E2KD1XV7qiuqdixa1dj1Z49rob6Jr5SxQJ1nN8FmfYhr8f7Ce8p5Us+Qx6vr9S7yVPm8YAi34gPiWShXglf8inyen3eUqZXA6+Cb1IFmUiCRufWvh11u9HXOlZ1EW1pEeRQq72uEIlfIMWCv9gvb4NTcki3Izsc3v8FHN5fWTe3yTdhIZItQVtqaioksVhRQ9tcDZKuChL/uaJGIEVy1P4kW0vKEVtu9ZS7aqtrqzJ5Kin2uCoVWYOk8k3wrnxIw63ah1FJEOVyeBW0RNpWXQvyZRk9GlEQq3yV7FcCdnnLmkXNtRvLWKUNyu9s86GdQjOWkLe4pAyu4GbjJ1NTA8m7w7Pxi1mDfjbelT3qQTg3zmGdOnchOT5un1hk3sN+cuyGefbUQoVUe1dqaMIcGzAHR62xm8ZEl60xN/PN3vFkx7R5eiLV2WPETs2b+wUqskaHzHhv4uIxs/eo1X+P/gfgnOULRj7TetI++Izek/ZL5qP7yWs/mfGOxLlRzjp/1XrQb3X9aMbbbf3M0AdV29303DfPThtTw7bSvLm/0KR5/H7i9hFwOx3++cSdG0bsPjVGuNS1Y9bgFTpqjNht5qezlzzxwLx31oi1LyYYvriIwOzpz2yZM9OJ/hFj8rg18DOkLZ0o1Q6x/yT4k5nYZnvcDi09sOdUwGWr+1a2DPrFmDqTPQrA8bG0Xtfzj562jo6yIlsdD0DHGhjPnt6z8SsvMm/EesCAETtDM8ZisvrGaL9Mz9jJnulMDU0Z8atm54it0vkQzhsT3ZAup//siqV7bmGxmApnTPckpu86mYQEJoYnE6Pd5iTtvkTXSWvwjtn7yJx4mGpvN09MOhrJ8REzdtc8dcuYGbKOjGfE5lgfa5Z57hlTI0asm93h9MKly6yQSzWLNXg7cWUEpMmb163LfeaF0YXTmj7nTOLnkuikDp55cvz43AtiRiFsaHn6wv8HpFmDqA==", # fmt: skip "locale/zh_CN/LC_MESSAGES/ruyi.mo": b"eNq1fQl8XEeZ5xtYdgbBzuzMwDIHDC9csoMlJ4FAkHM5tpOYOHbwkcQJwWmpW1LHrW6lX7cVxRHIsWXLp2THp2zHl3zEh2THt+QjCQGGM5BACDcEvZY0EEKYGeZgmd3v+/5V9er1Icuwmx+41K/ur767vqr68V/9tw0O/feNP3Gcd1O66s+d0H//mz6WU/p7Sv8XpR98j+P8DaXXU5qhdA6ld1P6FUorKb3iHxzn5Tc5zl2UTqPf5Vc4zscpzVB6NaWHKd1Gff2Y0vfS74Xvd5xZlG6ldBKlr1NaRelHPuA41KSTpPR9PC5KE5T2UjqG0nd/kMq+2XGmfBD9XjvWcb5NaZTSGKWfuBLj2kHpKkp/SykVdRZ/2HHupPQpStdT+m+UXsPtj3OcF2ncFyltpPGtG+84NBTnzTTo5+l3OaV/T79/eQ3g9GcfQf4HKX0bpVMp/SuGB6XUpFNP6RRK138E8/7+RzCPt38U8x5PaQWlM9TvFvV7O6VXUfpVSj/B8Kd0KqUfu9Zx/ielrdei37WUTqD0JKX/ndI3KP0flP77tVif//4xrNd7P4ZxfYxSXtqplP4tpfdSSkvgpD+G9V1PKYHWOafK/4xSAqXzL5ROpPQfPo56D34c83n045jf4Y+jv998HON623Vob/x1KH/PdZj3w5TO536uQ32eHKfXUPouXrdPAK6PUfp2Sreo3/sofSuln6f0Vkq/TukuSt9RBfw6VQV8efMEjPtGSm+m9FMTUH/dBMCpl/6hpXV+odK/uZ5gQumU6zG/x67H+l6g9J28XpQS6J1fU3oHr/MNWJeHbwC+7KaUltT5zg2A01tvBDw+QultlDZT+klKn70ReP32mxznQ5TedRPG20QpoZvzNKX1vI6UXslwvhntT70Z8HnsZrTfczPG98rN6PdfbsZ4xkzEus2diPX/LKV3MT5T+heU/mgi4PDaRNQvuwW/P3QLxjPpFuDFQ7eA3ppvAV5sUr/P3oJ6370F8/hnVe/jkzDez0wCfi6fhHG9NAnz+tPJjvPX3B+lb+Z5UTqD0vdNRvn0ZIVvkzG/n00G/r+NgLiQ0WMK4H9yCuj7n6YAz/72VvR/za2gs7m3Ag5ttwL/Tqnf31e/33Yb5jH+NvRzJ6U3MPxvAz84qb7/gFKX0rfcDrz90O2gn5sp/TPG49uBxw9SeiPzkduBj8/dDnx/61TA+2pKCUTOxKmA25Kp6GfnVOD5F6di/D+m9Cbu75OAw5RPYhwPfRLf138S8Pz2JzGeP7sD6/Y+So9QetMdGM9rd6Dd/0nEEGV8mIb5rqaUWJhzaBrg9INp4Jt/eifGc9WdyL/7Tqzj0jtBF8fuxDr8+E609/bpNHfG++ng41+ejvX7E1pMjxb2jhngXxdnot+fzMTv388En33PLNDTfEpJJDiLZqH9nZT+F8sdSt/CdD0b836J0r+j9K/nAC8r52DeN8xBvfvmIL99DtZrzxys8/Nz0P6QKvendwPvrr0bcuATd2M9Ou9GucN3o95X78Z4X6f0HZT+xT3A2w/cA/hedw/6u/8e1FtwD9Zrwz2Ae989gOeL92Bd//0ewPW99zpOB/P3ewG3n9wLvHzXXOD17XOx3i1zgd/7KSUScF6ei3X/+/vQzoz7wM8+dx/qPXMfyv3rfeBrVffTHHlclN7O87of/f2rSis+TbKa0tinwZd2UzqX6enT4FeTHwD/W/EA+OHXHgA+v/kzgMuHPwO8uOMz6OeRz4Av9XwG/f/0M0oezwN8p8wD/4jMAz9erH5vnAc4nZkH/PnxPNQf8yDkXPRBzO/JB9H/Sw+iv989iPFfHaG1YH0gArjsjwCvvxsBHry5Guv+gWrwv5nVwOs91Rj/Nyh9gNurhtyYW4P+9tVgvX9QA3x7vQbt/1cN5G1lFO3fFEW7D0YB52NRrOd/UvogwykGuKyKgQ4uxIBfP4xh/ZxazHtiLfjDSvX7G7XAm7+pQ7831YHeU3WA2/I6rMuhOvCjV+sA7/+ow3jeVw+4evXQA7ZQ+pdMX/XA06frMc7v1gMOfxbH+K6llJbAmRVHueOUNrJ8iYPepz4EObTrIYz32w9hHu+aj3W9dz7amT8f/HH5fKzTuvmgkxfmY1y/mo/5vSMB/j0hgXWbnQDet6q0l9I440UCeHl7A9pf0wA8PNyAcXyN0oeYTpOQo48mAfeTSfQ/mASefCAFPv1ACvJ1m0rf0oh1STZiHdc1gr5/0Ij+ftsI+nrHw5j/3Ich/1c9jPG/8jD01TcexjpMSaP/JKVlDPc02j+Uxjq8kEa7P6OUlsz5P2nAp87DeixV6XEP+tFPPdDjtAzmsyaD9flqBvT16wz41NuzaPeKLL7flAW/uj8LfP5sFviyLotxP5VFP89loZe8nkU7VQtQfsYCrFON+r1iAfDvyQXg388sAB28vADr8W8qfVeT0geasI6foXQ6y58m4PcPKb2P5ekjgOeiR7AuJx5B+X9+BHB8XzPK3dEMfXpfM8bxdDPg/8Vm6P9/9Sjm+7lHkb/1UfDR3kfBN773KOTWnywEvtRSOofnT+mnGP4LMY5PPQb5tOQx9P/iY6Cn3z0GvJjWQjyM+XwL1uHfWoC3H/0s6PHBz4IeNn0WdPTKZ4GX130OcmzP50B3P1fpO1qBr1NawfdaW8HfzrWivd+0go+MXYT5rliEdX95Ecb3O0rPMV4/TjjP+EQpiVjn04uhz/Quxnq+thh6yZuWYP2nLgF+PrgE7a1bAnl6egnw4ttLsH6vLcF6vKMN6aQ2rG+6DeXWtUF+HGrD+P+5Dfx2/FLA5YGlyF9OKbFO51tLAb9rl2G9Ni1D+ePLQB8/Wwb+8JZ2rMOHKf0S2VnN7RjPxBXAu/oV6P+zlDZzOyvQ7l+uBL57lH6BFM6NK8HXP70GeupTazDv1yg9RO2+twPw+Gon8PLVTsxj3FqUq18Leupai3mfWQt6fGUtxjl5neP4pFA8tA7y+a6NJAMYfzYq+2wT1rd1E/CvfxPm4W7G+txG6UrWUzej3F9ucZyDNO5plH6OxvdvW9Fv03biCfR703bM/x92gO4+ugP4PWcH+ErLDugfHTuU/rADePvyDqzHe3ei3h07US6xE3Bfon4fUL+f2Qk9YWAn4PHuXaCbe3dB/m3dhXkc3oX2X9oFe+zdu0GnH9oNfLpmN/jojN2QXw/thpxYor5v2o31eno3+v3ObsD7P3ZjXd61B/K6cg/6n7sH9bftQb+9e0Bv/74H+PSJbgWPbvCpFpXu6AaeP9sNfpHrBt7N2gs9Y9Fe4FO/Sr+9F/bma+r3f6rff70PdOlSyg6Se/ZhHK+p7x/bj/E17Ac+tO7HuE7sB5/95n7M5/f7Ma4xB/D7kwfAH7IHUP7kAbTzrQNqHQ6A/t/9FOrVP4Xxr3gK8z3zFOb306cAp7ccBH+6/SDm13YQ/V88iPn88iDG95ZDsMs+cQh86vpDaGf/IbT7tUPgU/9yCPj0wcOQU7MP47t3GOPvPoxxvXAYcHrjMPj9fx3GPO48ovwER1D/0SOY55YjqHfyCOp9V30fUOWcHoznvT34PbkH/LpGfT/Zg3F+vwfy+U29gMttvdDzs71or7kXetAGSu9l/OnFuv1rL+Bx41G0c89RwOVzlKaYL6nv7z+G9I5jGId3DPBYd0zh1THAOXcM8HCfRjt3PI32P/c06GHz08D3k09jfV5Rv995HHC47jjK33cccPNUuvS48n8cx3z2HQe/eO445Obvj2Nc159A+ftPQG9MnkD5J09gnH3q909OYHy/OwG9avxJrGf2JL7vOwk8+v5J4PV7TqHd2afQTh2lnaz/nAJc3nMacmbKaehr950G33r+NNodOI1y7zyD9IYzGG/sDOax5Az47UWVvnEGevj7z5INw3znLPS0F86CH13XB3rJUNpIC32iD+198ILCkwsYZ80FzK/9Asb/jQuwx392AfB+g9JXmK4vAj9+eRHr8s8XwYeuegb8bPozgNt9zwCuC55BP/ueQTtfeAb9DT6D9Xv7s/j9wWeBh5+kdDa38yzgsudZ1Pv8s5Aj/rPAg7c9B/2lUqW3PKfs8efAL5c+Bzza9RzgeuE54MXrz6G///0c8P+Kz0M/mPF5rNMjnwf9v/V5rO/Hnke7Dz0PvafreeR//XnIrf98HvCf/AXgc/MXAJfuL2DcP/sC2nnzF5H/8S/ie/UXIW+XfBF861sqfeuXsM4Tv4T8x74EverLX4Le/Hf/CD0h+Y8od+ofIQ/e+EfA+Z1fxnw/+mWMO/Zl4MmGL6P8l74MefSmr0C+jVXpJ78CuTj/K1iXlq8AThu/Aj2p/yvQq//8q8Dzu74K/WvZV9Hv+a+i3x9+Ff38j68hvf1rkKeLvwY4934N+PLK1wCP334NeusVX1f+uq/je8/Xwb++93XQyYe/gfls+Abk7xe+AXvzR9/AOv7yG8DXv3sBcv/KFyAHbn4B67TqBazDyy+gv7Jvor/YN1F+0zexLke+iXG/8E3oQWXfAn1P/xboY+23lN38LdDXm18Evt30Iurf9yLg89iL4OPHX8S4v/4ixvP7F4HHU18CHaVewrgXvIR2l74Efvv0S4DD715Cux/6Nuj69m/j+zFKkwzvb0N+3PEd8Iuu74C/P/cdzPdPX8b8JryM9fzUy4DbgpfBD9teBr0+/zL6+alK3/RdfP/Ad4HPN38XeN7yXbT7NKVp5uPfBT+f+wrKP/EK6Lb3FcDvhVegz/319/D7ke8pvf170Ed+8z3g/Ue+j9/zv4/9h8Pfhz39Vz8Avi7+Aeyugz8Afv6rSv/8h5hH+Q+BX7U/hD7c8UPwsR+p37/+IeZ/44+ArzU/gh72+I/wfdWPwEeO/UjxKUofZj71I/CbuT/G/B9T6bEfY/8m92PYR/f/BHz22E/Q3hs/QT//66cY57U/xffpP0X73k+xXhtU/nmVvqzyX/8p4PGXPwP9zfwZ4LpXpV/4GfDUV7/dV1H/ZpXOfRX9p1W6VqVHX8V8XngV+PubV+Hfq/w58LTt5+AvJ38OfPz9z4HPYwfw+8EBtd8zADq6OAA4/p0PfP+ADz5xI6WfYb1Off+JDzz8Dx/j+Isc0socyt2Zg9/hcA58zs/BDvvEIPC9dRB0cWgQ/OpfBoGX7xzC95lD4Jdbh8CHhobQ/t8OA7+uGwbeNVL6aZZHKn3zP2E9H/wnzG+DSp/9J/CV11T+3/8CdvJdv8B4Hv0F6HnvL+CXf+MXWN+JvwR911L6LMPpl7Bvp78GuPa8Bvr57WvAzyt/Bby+4VfgA+2/Qj+bf4V2nvsV1nPoV6CvP3kdeP6e1zHPW1+HPGp6Hfndr4M/PvM66OIVVe5dvwad3v5rtP/4r8EXD/8acPitSme9ofDkDcDhhTeAj79Tv9/zG8J15l+/YeZYdkesuToVSUfdeDITS6ezjRk3HauJxRfEouPc2CPxTDxZV+mUvf/973dviScj6WY3ks7EayM1Ga8Mn2enUoma+kg86TbEMpFoJBOhjInVqbRUdWfXx9xobEG8JubGPTeZyriZVLamPhatdOemsm5DpJk6rPAy1KybobJN8Ud5OJFaGo57f3MskUg1PVAb8TLVqVTm/vEPcCu18UdiUbc2lZYaaJ0GOTGRcJOxJs+NZ2INnlsfWRBzq2OxJHUQoe5mp1wvFnMjbiLuZdxULVduGOems0nTTzrbHEcTXIZ6q6S5TE65zTTSpkiSx+6mCESpLBqIJWI0aQJKLJmJp2OJ5pu4eFMykYpEPbcmknRrI/GEjDTiNmQTmXgmG41xVRqSl0p649yGFAbTVB+vqXe9+lQ2ERUwRZLRMmqB/6yO0WSS0QRNurrZDHYmDZaH6N5KzdMI025NKrkglozHkgTsDLVUVz/ObUxQTzGXIF4z343XUrPNZZg7DYvboVWiig0NKQw2m47RmKIxz400NiaaecbU9jgejpuJzCf41WTiNPKySE1NKh2l2lSI2k0lYxqmbiabTnoCJKpdHZPOamgUVWVlV7q3RLx4DQ81GaOWFsQzzW5jOlVNkPTKXPqvgldYz5Fr1kUysaZIM689QY1QjcreVFhUzaEpVu3R+nulilOLNEOCgqk4efost5FAkeV5UbWbaJAz0nWRJGEif4okePLjCchTZ91FbXqZdFzBIL/VAGEJG5oiiQSPojEdW8D4QXDmJXMz6UhtbbzGGpMsnq7KnVQnUjXzuQIBOebJXAiSDAyPitLqqlnyUO9OJWiYiZibbaSRxSINoVExNdMyEebo9hPx5HyimUiUp+qOuQv4kY7EPZ6AjDDuedmYwlqitSvGEhVMUbygNEELijSPRNMZ+iuRqLQaYxbAZMJQzSvdVB9LEuDSDL/GSJoYRl49dFOsF17dTLyBeQLTRjxJtSKJ+KMM0Uw9AyaSrotl1DTGyaAV5TUmsnXMC1MoWM/U6TV7Gcbq+lgZapR7rpdJpSN1MXdMrJKGM2syzYL6ps6m331nzJ01a/LYca7ALx1rSC1gHHSjcW8+CKIsHSPgNkQyGYaaJyyJeFVDLBrPNoDUCK5Ug1gJiCeVppk2ppJMbxr6tbToZY2RTP0YjzqTcYynrPFe9N5x+Cu5oCF2b3KurGRTfUqNwZtgys0NF2y8r4yLNjKLFwQPAE306hH/QjcalbxslKaTmB9nXGJ6bySSV2y5LJ7EHKkd8M+4sGeaMeFrLM2c9fZUk9skYOcCiThxF4IPcYMaKkasdGqGOXYDELk2EfHqefr1EZYAybjHONcUZ0ZH40qnU2mvsqzs/uoUtVeXJr7/wO3EwpqpvNDSFTRG6nN6rIlwivDK4wmSNCOsX0B8T5aIx+6lGoSTCVHGk4RfCWa8jdQKLbhXRW3chb/d+9HNwkgm1dDCIIgkWNQ0yxBVFwI5InnCKa8yqMol5id5fYXYaAnths3wwD/NKKqcspmxigKJpfIVm8g21qUj0RjwKJpKlsuaML5TJtFlDY0xAxZIjIiYcCxatiCezmSJ0cWS9Fcq2RDDaGeCvBjqjeGh0cIkYsIFKyu5IA1j1uQ7aLIJ/uy5DbRADdRg1uNKrBXQKJXUSTcwfB/KEmlFTHvJbEM1LUyqtky4VjaZ5F7tadL0xvEU6mOJRmrXjTcQpiyAdKG/otmaTKV7D+GDEYYeTWOcGRujcSThpfQg3Ug0GlcMPplKVtAigelaYwb8uZ2yxlg6norGawjQzYoLcglB62qLzzLUBYU9D/Ja958h3lzREElS49EyL5bmmbvE6CPMBRRwJhGGx4gRNxC+JKglRmgg2S3NRPe1EaLBccwchBKIdS+IKfHRQKKO6gqQNCNmziozZoFMoK21plamoAAUbSA1gbQZmtiMJMnzJgPF2CONiXhNPOPS9BsIVaV4JJm/MAFMSWvJNrL+Q1oE0VbQYcDsSSYn62JgsF4sI+hlcW2GmVr/shCaB+qWWluZbIkiDNiEFEilS5UhVY54h6h5ZffEymkhBVrUNC0jazQVMh6SP6TNurXpVAMGnT95RX/gdR4EtVr1Ml51mXU1zZQQPJuM0rpneIkiURoAU5A7tRbrVZ9KeTFLyRyH/jSOMVtN8fpYuCkiEhBnlV3zQwGmRuipk6kk/1FHIjUt+EZAmR9rzFSWzabFmC+9M/Nj2gIJuNXZeILBoMZNiEAEwKrlFcSoZsWw2oZ8ITfCbLFxfh1zRWJas5inGgZO0rTRI6g+nCUtSYCV9YTlajV/HJMDFGJWGy35XBYjMaQqhYQ46yJURLTZbCPXz7cdVEEvEFWVZUr5gcwgFYOUyCSpbg1kQQj6E5OrJQDUMiZTFmFqljpQdoGlCDE3D2vUCyLpeISZqHuXVsCxtlSKVeWwRIeSTeAnJaJJRFJGNP4q9BI0q+YgYqsmS00kM4QOXraRMRTsJlCFTN+N8Zr56AD1C5pVGo7Mvi6bFkRGJwVNqxmr+cnSiKFF8yO6CXepJ2ubT6wipJJWUxgMz5n+v7AmlU1mWtgGC0w5JRzvQZFItTIsosrQEnanSDBvFbTQFvQMA+CemBo3GBdNMJZki8m19RZtYpLNw9JRY3ClO500F6Vg07dMGbO0mpgWsGJr8ag0OjGRNoNni/majAnHCc2HpxiowzQtMaWEExeap2no7yR5bX1HS5rJWKO7SDzGmTq533sEJVgHInDTIOKsdiqVmW3/JMsuLV15wTREIxo7oMeljfqpQMo6cSyRamSdAdYmESjJIKoqg2d0oNnO1Sog8ayIN586E0Xr4SypGFB16hnpuXc9DYtV2h2ziFHKmRp2CG+ZZ5LNb5Zed6rRRYloWUtmhKTrlwXYGKj24zAiyzbgAROLIVE731YaBZuVrVCmYAWeUQ8JPp11eW5sHCv7YmFFUzHYTqzLRSyJAifHOOK+lBdpgtEAK5CXTLwFrCgnmssUA5VVk9mQRsQzDtkzkXQawjbFK0KDF8gRv01zq0xsIW2e8UP1RjXIQqe/IiJwG0hziRJwyNSzBxyNwgWhebtAW5wecTgZ8lsPrVUABwzOSzFjtVE6E29kEWLUmVhSYE5Lx6iZzaQqlPKiyTVfNhPbYj1PLL9YWcAayAyOGb6rJZZXn65pEfjHawPWxuOLMh/zUlrDryoTK1vViy0g5vC+D4wR+qyogHSxBlbh1aTjjZkbFsqwW8a+T8hQEzvZUNRwoPQU8c0wworVlNcvMV23/LJ7L3dvvNE1c9UAf0AbH3crkT5La/hUSJcRbQHsVJlVYQ6lfFJXBHxGECKb1NwkryCUKlkDUA6UV+hFrGnDfo6Vcd1CY0wK5zWpVWxR27w4EbPSl4CQWp+uqGjMkv7AY/BoCoTVNNg6MsOTWjwZwINdGyoOu2v0RIpZUFAsHlAcqIb5XTZJGjFhsFiL7OMsy2+IhmHApb2kipjF/1bDQoOUpYZ4HRMRrQAtRg3xXVGYRWgo1YVtA+mDVIoo2cYOHEMTDedSM2T5qvImx70M+xW8KndhVP/dojJnxR+NVZmJL/ToZwtILBPzqMyYZErk9Vj1dzYpckx/qjBkxiZPHa1Ly/iFyUgDGhmzUKmSLShLamcT+25tZrCQ1fF5YpLC3ObVL8gnmcC50gp7f2qNmFZ6MhipaJFsILnUD/HYuhTVmChoFiW2WpMR5EtEoBqwQ4SgQkUmSyaTAIMVRViA1ccbL53PIIg0P0DmZgxDVKXZRZhmN1yJ1orn57dWYZzThWpDEavHAYAY6bVFNWLFwOpyyhfGoy3lDEjSYtmUjU4QJf5+JroHlEBm9qrZfUxpfGLBWqTpjLm6YmEyPY84GSu2tPh5H8aZSgvVH1xm+ozZ7q0zZrozp9w2Z9rEme6cWVNmzhrrzspWG0nFPEpYgXG8Sq/OGAbXWOdKdxJxSPqT6LSKft3OklUjKItymC5XupotErOLElVw0kKfP0Wydz6TycPyB3+alcjWVbmmffwMyIV+Cl5e6c4Wy6UqD3floyoxR/ly89wj1DorRTUJ9oeOHamcoSYqdDdRRArf+I8Wh4QEWZEVhovSMopZSYIkDpcayRGGFpVUBmUFsWdWTtxgS4IgXU2sUVUQNVFKKBc1eLgjO0FadWu0NFJnIukZjUxdaqmr3Lnjm8c3K2197pRZ49zp45PjiZHw7+kzKovUIPplLbCO1BnlQBKBcjWjno1FVKomkfUI06mVKJu12li1UHGiGP+s4UVjGeKRGIjo7rq4MzFdlwVvZ1smAj1JPFC0vPGkZqnORM+jci7PBqoXz72hMeM5tyi7Wg9ABsx+4pp4I5y6ju3raRK/hG3eMEsiXNUrTqKAtzl0e1Ad40onZfN5nJvi7QkNNDE6aM5enERApTNJjBU9GBp7vJZah2TVG3oBkIgHqhps6pDuOoayYlSE2pqgB22mLIaQN5ZqcM83kXWHIcxjNb/FdSaR1EobbwlDiPgZe0uoojNJiV+WEPRDjO8YcI1qeIGT1GTeRH81wkrVuKh8NjEl2cHYWSNQLv5YMbktzaTjdfUZd8ykse5Ugn08k82I1J2Vqs2whT7OuOom1pBN0dAsmTXiIPHcMVNnTZo4a2ylbElKSwGjrCybRjAgTlrlTmzkmVZcU3mVe319JkMcffz4pqamyoh8r0yl68YnUNYbP23qpCnTZ03hwjeWOZPYhyt+M4CuyCzc+2uaI8kHyiFiy0VeBhwuGgOHY+/taBorUXVyjJVLNumSEJLN/IkaU+q9gTdBO/YIO6TEdB+jMKVKOzGCkmMd0kFEOylKo5NToo4LOUdjFtchColHIWXYeVBsWVVdtU8RSSqLXBROZn8NkfR8tm1Ya9GFG9OEKdjKrDd7n8qb1hhLs6sgJoXZWogwhMSIZrPBciPbvmPjrLvJmTLjVnZzJWLSJfxuMbZrG7NiKxIL0faAFoKgIHchMZZkqsWZkiwNKpVHZFLNnidMm77yKinNdCHWDAqYWd+415iINM/TqtnYMmeKWjpRB12jHgkRTZ4ya/bkqTOFRbG6R3SgBlPBLZDZnK3Wq8vs49YYK9usXBLiZGG3SbMp7XlOCQMThsdyAhVUm6Srwr63sKkAh5xbUyyB0rGKkGeWRqY3aApNCa7TEJGteLCtbIPrKf849aE9p1RTlwzYSiH3FMiQqCRJR4vuzp5x5zReGuYByYyMxrlNtxgp7FAFDKjmBD5BuINWy9G53U5Rwo1GRGQqdgc3Hf3SQRtqu0Lkzm22Jsj9GUNEATDpTpo2dQJbrGKze/OllPJyqWnZbXCnRn0USgDyeygm/kFQI49E72FU1LKjOUrYAE+chC008j4EI7QzdbKj/OXVsUQ8tkDtJIhDqzpbZ0IeBGoRtcEmDEypYprV1hGuZasrSWSPZ03Xi86XdLxU8MS9NlXJ3ZCwNjqtbWNSUbK0GCPh9VIWAxOuKElqgyckSXUVgoVQk9qm5qW0nGyWf82Z6tlOWGJ41E1tHN5Z4+hOpppucqZxkIv474y5p5bZU3nJKPyMVsjMCNNDnaAtozsmR6pgZRVwqLgeiYZV+BeiSdjJ2twYq3QnyRaKqFzUmMTS0DrLhpHoGxLUE3ymOl4lGgzmh9/MBgrmUVWGTGWzWjBhtuTcKT4FEY/lnjZvoOF6OvNSc1XFlAtf/5QpzrR2cUFGKldcDQEJEQOpjaVFx6ASLK3CIU8RT02Acplp8hqLQmvhsKfyAt+4LDpMcoZ7E7MIUbtkppmIQl3wOmbbxJSLtREU0gZ+fjFtTytmhMJKb8grKp4WVlUDVj2meXwmnY2Nv3osm61QA0xecnxtJOHFxl81Nr+lUc1n+gxnegqg5K0FiMBK+lbpyI5oja0me8SpMjWiPPDOPkspV2xlQUKiMSJrb4Iyp4xY0zaVtMfuRK3Bh+MP8nfPSFQnsrEH2KCDa5LMGN5HyNDIioWiCZsNNnedGRykRUKB45cmqxCt0N4Kdi0IZ2aQmZeqrYDDWJQQwzLLF7JmMk8+ii5ZyaXph1fQFFxnSS8l3DdqtCotehGMN9kyFbTyExFPofEZecz3lUtJtvcayURiZkd9EzDSWt2TGJ1ImtYjQ1NmVwOZ4CQ3WNdiJOGYE7aWQ5WUuUUs8SGqVO5pORToEKHihCkLqJDYItohoOxwz2xaacmM/aq7ImyppFisFZhRBIVUCroL45VVNl+VMGVsS2usqWArBAr+WpkYqYzRZgIVI5UOVSilQ9tlFAwr2SEHw1Vt/tVp4c7RSUZxWchxRfPIeqhpqdIl4d3l7VYaSVaWTHnesfnI9M+r40VqY9jz4z0C564071AoSrl+yvTZU2beqKJf1DYtWVdUinV37hp72UTSySBg6JFYTTYDSrJKal0yoCcOhlQFNMFaW4SUI9FKoHC9QXiT/ix/iCLCgTleoMWFYAokFPWFx1nSeCVelRLMqfUsRDXbfBytR4CpvwybyFhiY51PZdnPIcgeIYYjYhKCKSTunJlh+Ui/lUjSaAF+LqulrcO80J/y61k+31h1PdPTjeXUhGBBUSvG5BndWEPFGpspxU4H+oNlMFOc/d3a/wtUaATE2sVKbDJwZ9bOQgNvBvAoC7opUr+wkzxubWKHwiXNJKUjyrOChULjEYeJhRGqPu/Rp7JeuKwOHUkz94o36N1OR4V6FcZ3OTN5i1L5tyq0Oqv9XOHdTKqzgNkKWc0LCL6QvqwPaA9DsV0SeI4DS54FgEhDFXGgSZIl0DgSol5zA8ewao9kBSNEBU1eIn0qKhRLD2eDaDBa4xotK5updtxovvD5Vjp681lzeShiacdEvCgxVON6HPSalaBsIVsaLuNziFFP4K/adueVz6sEbxx4USyqOolFjdAnOpunowspl/sXF4GEX1RbVqHixSEhweXDfEwUZQ6MshQEKLhSVgLp4qQFZppB/kXoURccmUnMUrI67Y7h4Y/VekeMNHT6EMQKgS2WLG8VU4Q1mqLGbL1UYSUOlS+hZGmNmeFBS5RSbWRBKq3LGau6QvmnoqLnhc1X0wl3Lv140pEMIm9gkWg02Hq/hGQu1W5oYHntC+hBTqWcoSx81fTDWvsjcU9IOmA7szSYLC9/VZnr2u5swRXq1gsBtjqL0w3au6u4j1gKeuTqjAGb+VpfrqTG5xDvqKioSTU2hyme1GeoA8U8wpEkXOIBsy6A0Eht05Sh1sNPo8MnTR+B23MkXjbBRVglS0X2XdRGaPa12YRL/09UZNiVyF0HAynF+GQrIqU1mBLe24i1ZIXj1FoRd6isC9PtCAwV/omQXsL+oHqgC83Aq9LcoTEu5pec0ahJpzyvAlzTcg2a4BJFPePInqq+kg2e9HjziX8En7168cVXx5MIHPCCv+bHG7U1jDAddmcF4Wwi/3mSMxFUxWGUSnx5lye/JHwzouevLEy0EQFSsAEDfUPPcHwsU8ODJ6VknD7gIJtAarB6hUwQYkqCdrS9wmyb+XmlMzusCCn+yxjxMOtzI+R7scwIuQQqlR8ECZCh0xD1WqwtwFoyfEQduWvi7NvHqZDN/AgcFafkSWuWSxW6v3yFMMGg4BXUA3FmF/WPMBsghhlLI0hEKTeVKB3yLfNZKzVDrEsNy0G1rRJFjJl0eWfq0Ti17t6VraZcvUnj8ubMGO0xbEAZ2Zm5865p4ylz/NggsLlOjZ4jO+VwU6wsMDCxLWq5Sizbk88DxQmt1LEeOSdVE+ODdvEqt4i7UuWNb2wmsk1WqJ9lzux4hiTMnJnTcMRMtNRyswOND6xKlAcqm3gyJLpX+C3VdeYk4wykyTH+dwofrKhi4y1T38JZyiPKS2L9LOJQn5NkvbYuGX9UVNZLuBPmMMIVVybmNEaZkLRmJtqwuK1NTwjrCEI58/TrZKrJuafgnIkJQ4813FQsO0uqcQN7Y/OOPng3OXOnzHLm6vAwRluRvc3MZ9muzYiksnzs1JCwyiSTe+gIzYMVGbcuma1oTNRkHmQlz44JmG6srCoJ1LLz2Ablr+7CBo7Rq4u1OEEYcy3NOoEzMUXLqM6bIulkfj5aD4wlGZPuUq2xfON+kilmvRwgKPsASrixJi1FtL89fNbFzkoXDXpy4CqBOhCPuiraRHuQRI55TkSdMc3bpXBUsK5Cf0LoKo6Y00SQTSdEmZZIf+V+EPspGthzoFEOvXRCK0pEJbTFZgbU6AY+xFKtAhuiPPKMOtGJ4iU0k3Gl1IqR7RtG+pHlMZ9x4QMr2lXmCOqZyZuDSnJyL1khkOQly/enoh7Kg0UIuzCkkL+b0ezwYbaGRlESSTIxMdA4MyDHukSqWmwOoer5sWaOo4k1tzhQAWSH+op0i1GkOM4m2WJOGpPocaofjTdeI4GlnKtD81ggtdAo+HwL9UMcLsM8q8VRIkq0v5DmF4TjIPqRx5u/sU29E+lcRiNV3DOf5zV1eC+mIggBI7yScNom1hl5O5O3RTPE1Ez5uBw5uW3SJLO1/SHWfkIRC8VskVADnB8w4qCqcTop6+uyKplTHXrSyjF9WY38MaM2fFTXwlnoksdNAiT13GrS8zWLEq1QhLBuCLIvUqj8h8PgDNnk1UsWmrOjrSe2nsbi0VVWZP9Ho/Oo2qliYg+U5XyDQbaJ+MyGKIC6ZcVNofBWaJYI9AcLh6unHIwWYR7lOnAgr5X8cMFRtSCOcrZjHhSdj732D6pAQBynLmo5m+33SIK3wDL1DSrMT3iigYLDQbZpHZBRJGKpJoGjFoAoR99w7PD8G7TiZWtgTo056C9EUIrKmOwtLovjnCxMXd7Ws6JqSlBDkRqKfJj/K8oYfd1skstia30h/ytBIo5WF3j2ImEl/k4Q0FHuYEsLrZWJydQFfdQ1E24UNsCUGbfmf5pfcEuFYzRWW84XSPkwoUFcxIO9KIOjfEpA+QRMHF0KG0Bqi0DBWQ6lBoEy4kaW6YxwAM6B2BKHDs5WKJiwM7AhYvnyOeg63WKJk6Amy0aGOupIdyp6Zx4704tXKnpEyqjNQTmRSURhsmLlVfBUIoyT4Kqu0kDxgloaCoFULhc4lxeTy6UrCza1qE4Q1Cl+SBBnQUWhsWDpgXVFYSDzVwCXWjBlipbliyiMxU7itybd3Eg2qKB7eYDvlvkUaWyUGEY5i8lhG9VOQByCa3zSjxC2yuV4eRY1JI/q3DELoabOkyh690ZaTIXQ+DKWoyn5/EayrmSDegJ2UX0OB3Map/YO7eNTCGxkk8hYJOLLMkLQgyAiHa42BnaHg5aZbCO7MOQEbwqqI3Mn2ckuwlW1+GZ7ozpGGBSHozShnFA2Ko5z2VYLhZ3LVnpFBY61c2M1sssuKAUbnmB+9QQ79ladkmC/BF8Q45DB7FanI8ka7FjyaVj5FqCNU5dNkmI5er0yz5TxnPosVagwfhvZAAh88o0pJ16XTAkU75fYeO+BBxgZabaQZ1VuvoVRjuGVs05eLlZGuTEz2CdzyQYlRlisE9l+LQe+l+vbOiLVXiqRJdWzZEMwu5IIX4gTWNDyJSro+NbAYAsdCRjNWQDTQTQr7nJapOJji5ohEf6lxYNDcg17naz1kSYRS1c5zPHSWRWzyVuHMR21US/HMInvOCJRksZa5gA3dAWPahNHLkSj1k0TEcu7QHKpoAHYxOyWtQKCBCVARkZG4fc8ISsRE0Va0mecxSjlNrjwZTRBi5JKLAj264AScQl3kL1hvR8igtA6Muvo9a+oWED8jQ1KdyH/ewXLJ7b09OxHKqZl9B1T5t5w98Rpc6aY8oW2YF6OchRSrvqroASchrVykr0ZjbhjWBa6C+nflrF84CGSCKphF9VCHf0ZF/rIAQ8VVCm8FZ+D+spJyR2YqMe6oG+qo1i6yONxwewD1i45DrEgocUmyx+mfCfzTLC2gzB/lZA2Teq2xEw4xWhbMwhD3FYhFXlgxefyrgXpqnWCAWA3TuLRj46eDTZEEoh81lBQMhxOwvzcqvxD46xoLIBLx1OnCQPfgicHGJRmJk4H5ZwitSnOGprVvgnLFJ3AJvdRFapSBi6r8IhVZoDSr/DRJhzlNk5vpRdGY0lWG402FXQY8Aex8atIaxbhIBERGb5HIxnTR9H47Fk00hzc6aGPK+socQeBMdB85R4peL/t07+RBam42tlPpDwaijkaqOwkY1KHbD2SzMZm1N+SsSZhJEEkZaAWGK9SCQXBjsFyeJvSbCYL6TjJlItg12LePclN1y24if+wOKdqGzsNoDsgpxlyNRGFjlXnygKHYKy11vFIzob/JR3aSNRHPqH5SSkJ4strBhfHNcZweNmxcTa065xMFfVfmt0S2cJXkStsZ2uVXk9RR9AQt6gKa/lc3FwOFFQo6aMpcq+Nfb9BPrLxNJSb3QSM6JtqbOczzkQtVMirelKme8hAZjBUuWB4eSIHjv68yBXWgSDoA/tYjpo1sjNT9FSvOVlj1Cu9Y0zNT53ssIKpQiKL3ydz6SORGpLlBYdUy0UXCBxVygIRzwQnVKBaHRPJO6EV9xDeDoJhd6tohiWsxSCuMOxdlzBHRKmMrmoiVW3sq1HV0IVH3R1rw3LBojKOgUBiRc5jQ7ZkRXvfPUBa9XUeu5pa7MUoqK7unWPExg1qpkF1/C1jG9UlO1A7CZUlO8LtYwWHRia4fHZcnx0KjplYHgvdJ8f8BtdzlezI3vwoUUaQbF4mUtdyueArvGvMKQzskS0Y2WjjEbAjUh0fg4ahwB7cOdPAMa+xqH2uolyfYRX9pTH/bh0yygK6Vfc+eSbu06oCmzVEqrIHLlycNE0+PahdLsFtnpKLi/Y4JE+1ZW4xkYUiWs/PkAPzOARjxZROYWmrZKNUwzVSyJyUSSc+PEnlsqdKt5kWORS6PEAdQsnrPLgPYUE8Ate82sxWW9lp49O0FD8jTsfUkw1PZW7QukiMLxJriqVpYXHzGO4KSmQinqNOfbBqg7+IUQW+N+UNjkUv5Q8GC+ZYZ07nqZBn5tVxtt853KKR89UfeQUcuWwhqnfaRERHMkZ/dXCIWoVqi1qFsfLW3UKxsXkXeR6vdYsubDvvFD5XFRZO4748dSgnCIkxEXRmqpQ7j74qwsddrzDJTRF9nFitvUcGK+90qeNO1FctgvPV4dgIB3w0xtR9K1r+TZpqBSGr29WCFiS8Q4lQEz7N4c+Z5gn6ZFM0laWRW/cMAbkKr5qUwErPvl3SMTK6MA41yLOWKfhYLIA2yC0SAGDyJKSfdeGsnOqxgodNEdGU8zoMaRHhz8IUIBwda3cApvoEAX65lAFLK5fVIsUhVNjIPlHRUsl5DLZ5uNggKTIEOlplJtWQmBDYc0phDTWFrYho6CPuqgl/MzSsMGyh/tASKoZNEFU1kGRKsbeu7HNsDq6Pg6WLf1UnWyQKsWQBDLp0voraCfKZRNV/gfrJVM0cXRDTRh53jDZ1WxgVFmZShDUtOMY11vGIu5D5LklLlQp3fMTc2yHqaj27XMP3gmRSUdwPLAdGMlpg5zdXqP/WW+dSRJExijDf9STKo7T9x7QkDTCzW8hXkMzD93nqJhPSkYRhgiBLnE0t2E8wVUIhUdWxmog43IzqIaaO3PWgxDdOjJrqBKuKy+hNNG+xg9i5IO3aW3OOhwtuRoy4w7ahEhIT7DgoSwSpKIZ40mC5F1zs4fAwjG2ru9YnHmAYkwIqEdaZ4jvYJgzCuPH1uTBi+CO3VbhVbNrQG/fUhr7DN2DweZdjmLWAxlTuwfOQYQ6hb98N8oK7e52S57y0S8lwoqIHb9UNdFxQc6e8CPG40YH0DZpVypsMpM8LHGfHhcpRR3tpznxLB9OiAV/pTlK1tXq7uTablg240LWqJqZrNE2YG/aCSLBUcDmmulNeDqLE057cvTZCq0lp1L6/1m5XNMZip/qLtMjQVuy+ePA9D6lcOxzLaXSxRNQ+MMSiugGuH1v4ktbFgaNyJET0GyERx4jNAFcbso9ooylwrTjqpB777wxpsCdTMYsgfkddSE1jUJc4FnjBWMHx7AbZLVrQaOBDY3CIomm5Qia42v/vFD0qMAL5KiZjXwsaOhAYVKWvwtSABZb6h+09GDrBOPmGOebwWGMVuuOUuGTSeFOS1oXNwW6M8KsRt2TyIFBK9PHNFSyaigTR6ZMk6HcUkFNKtDhLjM2izX9TT9+HNC48wMtYGRxU1tcJ1sYivC5eEGSkjJRAg1C2il6r0l1pGCp1sKJwK2OU9YM4o9FVCMJ/QhXsa+fMuSjZLLVM5YXGSIdFrMWMdk7qW1sQGFnKTyl1844DFiphecNw7Hisy6xm7wSX3oWyIyz0abzqZuuFCGvCgXtZOljIuDyPsLhFobLMWu9uwEevGHR5sK0S3HBDvKkplZ7v4D0GWanA3ACTnDpZKw0jFEqpQ9ML6Y8W59KXPssFwGHGrzi+ribyZFxwZR1uBVTUfQk3bWmvLA/kEo7Z4oMvFj4/Ds7nRyKsdI3TR1ZUNIR1MJn9E+PyNkUsnqfurI40l+wa2pIrV7vyRhROho6+yWJHXHgFaB0/NeXOORUSwm3khy3x0pGkV8uCiw/9VbPa6TnZpBVLJOEFssfHLqWGqI4tIJYtf8zDHvtC8cYHc8lUOnl3pUh0MnWZ91nfjKJumuRTJ5OmTR2nHz1gzmpq2Nfu5yvlVIhIyC1u/WWTcPflcxMna4fksx4hsTOy/8C+E/zRQsXkRPdodwMvM4ZCTPLAxVFwwlC/Y0ID0n/O48sPHOVbKSHj9aM2otoZtlJVRPablxmKbemJu4UfAUHkdN4LD04T4VudDg/UgYNVuN16If/bMs6to0YX0j8tziOPjh4s+obbP3YHo9ncIV2hLsstaQlcXhfW9Y+j7MP7o3owEUtNkdA7M86jXiY6erguDHsii24saXWDqdsdw4hSFSy9Qpuxoyo1TnyYnvuwuh7S2lglQaXuipTW1Kn/iqnRCnXjbhXcWldVfvQTlVdVRBKN9ZHKa6665mNXffSaa8pmyiMEFXd6dfFoxS3ZOq9idqro2aAiVxmV3TVjdgVueSNBM1n2pLnhiquurbj6E+7VH636yMc/fNV1V11FBStmxnB4M1Tu6oqrP+ZedXXVVdei3DSOb5/N3FR4bJV7z8Tpt7n3ZmPpbNy9nuig7hH5++a4VxPxKiM1lTXJG6lWsi4rbtpH6+fdTpXnTZpuPlbMFpoPctzrp027ORHnw1Y3lt2VyKYjiQq+eougmmyUn94NV7NPlP+84aoJZXdOvXNKAMurK68q45sBad0rZstOaoZE3vjGBHGFCXz4J00S+oZsprbiuqCckg8VU5KEPHx81b2uOp4p03dsEeSbq9xbItWxhHtN5dXXURdOWVlu/Sm//ejw+p7BrZsHzhzJbTzy+rlVuSN7/G0Hhltb/WV9P299HG+lDfStGrq41W8/xf97YpF6Qc0/vddvOz38xAW/bXFuw9Hc6h7KQH1u7shuqj/Uc8HvXpbbdtDv2DTQ3z9wfkPuidUD57dRVu7xA35HLxe+2ON3ry76aJrfexYtDHYtGepd6rcf8jvXDC9bndt41O/s9Vcc8DvX+r3nZKC55a25bcspZ3hTf277rsGtPf72Zf7pp4d6+ynf3/f40L5FuR17B7etRBm/fdPQrgM05aHe07nl+4d2rSr9nho1wHN7/EBuc6/fuY9a8ntW+W0HBvctGlx/YHjR3tzJla+f2+GUDZxZOXT+PM1raPF5f+tOv3PJ4P7V/pod/Hf3saETe6k/v+0Uwcfv3u93dxHckDtwZrXft37g7I7cpp254xsG1x8ruKrZ714y2LmUhkJlhy4uGzjTN9C3lMZEsMltPDtw4SK1XcaT2dNK8xzo30uD8c+cGdq/CF3nDuzyz3VgBsOtj9PAB/rWUH1AJ/fkNtXW4jZ/6Un/2JOUi6EOnF05cKYVkBpetszv2Di49QyNNrfmYG7j+dfPdfG5P39HX27b4aGLT+bW7B3e1DO8e7N60EtPY/D8Wr/tOE0EI2AY9VykkgS3vJI0aBoclR88tGWk8irjyGZCotCzaLlju3I7nqCqXPZKF/Ma7Kf/LXP9davG59o3Ek6vG1q0njrJbVvtr9iV6+gcuLDV37D09XPtU2fd9fq55cNbOgndQ10Rfuluhjc/PbjokL+bBzi8uZ+wHc6L3MlFw8s6rDFK36FX0tQKSCVMiSfZ14kJY8gDZ1bkzpyhhR48cNzv6VKNDZ3vHeg/5a+iBWob6jnqn99AbZsRPXGBQK9g0n0st6EdAKAJ0boRoagBdnQOnTzNtLt6A+HI0JrTtJ4gwdfPLaJ5GyI2TKAUETO7ECIe7urIbTpVii7DjRmy95ctpVb9vecJvZiqL7uNEWswibZv9/ev9FdtzB3pJm6Q27kME8Gwh7e1cpmjSw2DYUR6nBhC+8CFlUQQ/Dqav3oX44p+HI2gk+tY57ftpRYHzvTntvUNHu8f7N/BLbZvRBZRysCZg2U0vsH9/TSmwT2LmL/278VAczvPERHSoIh8scE10L9y6MQBzGro4maMi1aXqBdkxrwPK7BxGS3/0Ole/8IS6lINUz+K9vPWRWX2q2g0EWKfTMQ9q3IbTmAcNAMMdqi1LXhOjaqG31ML6rYv9Vf1Ua1g5ToeH2pdjL7Lir+oNnThCVqnwSPclUGxwQOreZVldjSjgYu7cot6A766+Di1R5g12L9/sP+I8FLi1ISpue17/fbT/tItxMqHd/X57U/xfNo7uWroyYXBvdu5oRWbh9ou5JYs9y8eInzGQwGKse3oHOjbSs34PcuH9rQZYmKpsfUErQ0zieXtxMV48dYfYMZGiKSLFT6bRk1RXVQZ7NuX274DqGc1TEUGdyh+qNpj2cH987LqkmiDCjhlQJOhi50FAsl6Lo2g6q9eNti3nyFM0oDavLiZcEYRQ/tWv78PlEok658/5j+xmrvb0pVbuWNwTa+/e7FNVlJpuHV5buVTeSMKPZI20N/GqykjI6lgszVtJGvwnWZyOr+bxc22Vv8o47u/7SgpHMNblw6cv0jAhcIABPHP9Pgrnsq1rx3oP5xbf5ZUjYG+/aRn0BgH+zsY7TpX5ZZ0sHgzYzm7gxaJ6qJN/+wp4pK5bfRx7XDrouHd2/3ujTQGQmwtTw8QIg1dPD905iD/3X56qO/QQP95xkRrPIzl8gfxBRLBA+dXEw2wsN56nuT18JalaLOMxbMeir919/DmHYM9u0g4M5aJOPG3HACDJnFj5LZ5FG24nxarG4KWyFygIIu1tM3vOTu8+Dyt6cC5rsF9/f65ViDvELXfs4k4jjB1ZmAGBFBFSLYObzpiTwag4X79joOMi9sOiAazZnBXz1DPGUZyLe0xiPzVzC1fTWqMgvA6WvMVA+d2EqmQ3pIPNOKnwo0xouHWLlJQgCmXfguNAHqJx9BcZsKXsiBdJuL1rC8Onu8RaAOjiKfSahJbzh3elWvdP9C/RlD0aAHy+j1PMlsTDCLhSDpkbsVef+0KICnpV3lLzpDYe57wmtSwof17gLNALn/3k8MbLgK2sgBavwKVsbKnUXro4hYaHovt9b00yKHdhwBnXhtNI5iIQor1O4j7EUAGLm4f3LAFiDN0egcQm4ly6mTuMrdkx9DR3Uw8NnU9uYQ4AwYuqP/E8MFVxCbLWHPtK2R1Os5LD5tFm8WrFEcV9pw7snf4YDf4O7DD3OwqZkU3YQePpm/9UO9ewkAeviWUc5svDHb3sTXQt3TEN9AgXIgmeZKk2ASBWlQZ0oMVCkLdZQcJ/QTXu40KT31rbWa50pA7NhMYWMsh5VZNk7Xi3PKVfveJwV2HGSS9ZyGHUVjmDl3DP/8EdZVb35tbtQjtoXm7PU3A0D26RBwhx3xGu3YzUCyG21YDme3GSFYaIUIGHFogpKHWUB6dbFtubnhkVBbxZltHEEfAC3/1caGRPnusypZh1spdqelpHEEnqE1yEEyCRnPquN+9wwh4ERhbaR2IuIa3tAFVmK+vOcrsaHMvwwKKkVgt1BtWjHDNb1/GAoqMAeaqraQ9EPsjCrBoG+NWMxH1hEcJ7Cv5+pg7vLmNjEK/ZzG1IHKw2GNkamEAZVlreRyIyJVGDUrAZ8buTQdYoQWhkRZ3nkB8gRgDOK4F0BVK9GutEXyFR36u1T+2gf/tWEu6OSzkMpkTaai7D5EG4Xes4G77tmptYhUR8VDPU4YrgQ3ZHRLTZvjuJvDtsLBj1eCSUwRNtBbYFNJRsOhQutqPluUpyDQLpQz2rSf0H+hb6be1syTe0Um2LlmirN/SxMMqMhs0YmIRBaIdmSCbq8cPgBOywO1bAyuFeV3/3vz3xVwIbQgWBt/5pRjm8IZt/uIO/0I7LG+l2/dt8I89zkqdqL4wtXLbF5Nhx6qEmQHAooxE6AZkvHfvsAegngFzMb087UweE3NJ3/UvtnG/a88P9HeH2L6tK+mm0C2LgjZS98+I9r9l4Gy7v6iL1xbgJ6I81wXzHQzMRlUCKPFMUQi6Auwnu6JAsnWyPqteHyO+SGbJ0K69pDQw7m48S/oRyx/qS0i8jOaWO022y05a/YI3xmhpjrB5oKdG4jX39C5WL+gLcXmxDEn6i6XR9f/gvTFb1YERoVRQsG8BNhsJYhWYERT0/f/0zbE8NRms2XpvzAhfmCP5rhxlmJRB9rNGAuRTlHLEX32GiDC/lvHTMK+RaUMRB10R9Rc+EcYMpe04Y7vfvgWKEFmlw1u64SwztpBtixDTKHA9UXGj4A70P+H3PWFUvzAN5+G6rm9bHrazxri2ZMK0tAAfDZMkon9hE1OH2GKYvjHbIduHLi6i2bDlfaaNxmYYun4xDFUBKhZX6iuZtB1rYUvT18L3wtgzd5Qm01X4ZJh/ZNPQisepHFEAGxZKkC4Pvmw7SKLF/j7i82FUyVzrqYsLfEZ4PUzWp9TrYWQYrRzedEIagpUPlGH2e3iX0uBFfvlrD0BPorLABqqXW7OXfQ5tx5nbkzoMXcd1B7dyFkvjTnbiEaKO8B2vexEw8FQYyvgdq4vWzfserlvhFpPlxXR/uIbgiHUw95GrBuaHC4sICrl+IYxhJPo1AeL1cyS0TuvmcCxYyYzte/EHDDpQxiWfBiMyUeWJVVuvgxEyDG9/cnj7bgJIbksPGVGQRoQYMF9BghCQINdcXyc6FencCVR3gIqMT1e6g/1LgOcOOxVZHhe8FobMgKUd6/efXMmUod8MG1x+Ntezgv9t3c/fw0+H0Re7Q/Wp2Pth0Pc5s+QDYvB7GjeCanvbQX/PUVom9FBYxn41jL24JNbZkdtV8uWwgf42VhPaCPfa1f32bm4VaXtdRZ4OYwcLm+RrYGuZIBRaEYI/8f7hXWcZezadAo442P2A2gT8cIgVCbJvYjVMpAUNT78bNrTrAMnxV1u3Eda92rqd8EM9IGYy/M59lMG6atGGSIK4V7ssrcN41u53HmLsYVPyBHFsbgCSHQqsWXOFubz9IHZYrrfDzpbdl32D/Uuh1Dts4p3bOdy6brB/S65jHRcSzBMLiT0oDllMmqmwsjrMuyFPDh5Zbybp0JgxCIIQ6yDCj2Gfmn4daPmgFhYKIqwKB0fWAds3yjAVtZBEJEwz28mi1QgAjjc3+jeilkBGLJDAe3emjb0AMi724uhNLgUrlkKih3D/UpPh3dFL1oy40rcaGlcbU6rMckcv2478d8WoRxLNoO/BAyv9vo7Bc0+QsuwAun7n6sH9Rx1l4wqqscbYtxVTUBl+5yb2nrKk799CCM37YOc3KH+dRl0aoezmHbUFM5VxuKntizFieUUMzq/B/Wv9I/uGt+wGdAZ3rh986hSV0i+GsWzGLLcv9tufAusUA138TEO9iwhL//Bnw+CnFMGYd0mUC1+n4onWZAqfEWNf5h/QkFTrIJX/qDL6RCw6ZGn5a1YOnmMbHnwNgGYmLgtPEwYG6u/LHewWliA90nTIaNoYWhH2B8ouSZjzcGHlBrHchmJOtolDhYbah51PB97h3PIn/NVHMXvsdTLWiqLsmF1NSED4UaEphF21Oxy2mjs2KhuJBtC2lzdgFi1jzjNlxq1stixiJkrdAwhAdfPsF+yQ4gBAHtgMJucA1IVvfhGMSz36JftXSrM4ulRtmOil4mHqZ7+GWtlxzmK+PyB5ojMmMpGlWDaHJYXsX9hqIyqDGznIDtiR2NZ2nzYaOP65PqI85aeHc6GYEs7sBZ4+Xqidu4YPriIeNHhoJ6mf8oV3kSwit6rafMvebRrav2Ro1WLmAvKsF2QL4UefacKeonIzWm0VDsRBoWGyS/rWE/3TagcxAetWKR1JV7YR22ES5CtLmC+JvwcWALFNICzbFGS7S5YDxARKQsUDSjqyJ/g43J5hb/peUnT8LWQSr/T3ngcUFFJNnewEtqvwZ9ulwy9xYaPbNlNG/fIW764O9HVjd5VlCkk53rcSSBjbKW//yRHVgjOY2RL1nF1u1G84z6jJgb51DhJxrxw1m5usyncoJ6C/qMt2ITnKc6N9bRy5sPtQ8OCWiqhoZwe2yIz2TQQhZRqK7WQvocr2z55S/jM9LzP2PAejrlCsHJoPQJBX1MZnzR9QAtxXjfRIN5RVAy42kbEn0t3F9o4oKexxEq2Ev6OV7q7B/atRV5QqNBcae3iAOlikq4w5giovtl7+pG0l3fjcoCSqvJFmiRJYN10c7IPoVtpUXAd5MLdBE/6iDnEIXhB9k5hfQTyMkQ0YueOvOksD4T0yKH1SwVoTyUYgCCn/zCMIYu2H7GGbcBaTBS2FQdZ5CJyFjObBw/sGzjytWuSNuGUHFdcpaFEVIppQtWVhjVVaMDzNPUko6He2eJu+fSNbDTrHvLIFbbigkdJjDpfu3OfA2cUyVYBmll/wqK+DDBLGEBWDc2YNe63JqKbVC64HG7q4LEfqHLEn6T0Mz60j2Cb52zLc0bblSi8RhDWbM+HXt0QpZlnu98jOr20u25uFLHZA3XlozQsrk+OdOGKhkP6lL8Z2h061sX7St5Rkjr/1hECHBXLQJPgxcZ/cmv2sjHYcJdaSp74EoUnbDgIG2rWiTGgMVqG65Xvh/ob2LR3cuhFGb+5Jti1YOWvt8iWwBo4kVstQDqYHjBGiVcxZRTJAdLPjVLizLckMxjsBrxBblfex9P4LL0/Y61TGkAaScAth2yK38yyCPEKFigp4/q7iQWzjY7mYJ9gyNjI7T8DrmmLNEcDbisv84nXyFNUgAwBUj2sV9ERSXZyV9qNarjWHrkA2s0ump4tl887TJNdYfPUs55A7dsggZm8lh9eIO1OhdW7V8iKva3GEo4oq6XKUJgw7s2eX375OaYKiFVM7qgS4rqIQCZxTGaAwbQhjbwu+ehheIStMGVZCekYYKHFKqkGgeXWsZZ18ebs+FwOUy63Y4J8/hsGKA1/p/yNZCmAGbQew4aerWGbJcoet3F4Vr2bLJx5I6zlH0ZQt1HgrJO/1K1fFd4hppJg01T/HoRYOVqWEpdO/xu6aZYdVWmuWlpc6d+ok0afZD3fyPNjYscrD1GCHYttyMb1G4/WGzNZ+7qGL2wYP9dre7ku3coluTcyezXmL11HbYNK7KYF4lLxxwkdgzPz8Jkw5xKJqPrPX3/okBwDLVtzgU33sNLYDgZRPQUf7yNKsP6acPUZHhb8ReonYzzZOskNMQhaI5nPbF9tuVKXTIFaC/QP9Fq6SQlL6NnlEr13me1msy8Pdyc5DzIn5hYnnILMNutaWA8xEwNE1IM4qA0u4BYiR7G5/xa48nmt8PsYUAVNRelVBdYcVQe3CK/ZwFmtqllSCaqeBtlnCTMNMnySszbFQgeOEWU84bCKwuOGitKkqnNvst7UP9u138LskozCRHkybB3bRwnPYk+2XW7WMVCRSnIcuXKDx0HB5bko8FYmCu1QNsREvp4btpRl1JTMn4y4bVVVWhfc9bgzgALRndxB3ZriSRmAccO1bAGaJz1wss+tcm8fU4apFMJvexVoFl5/xt6pN9da2S4/Ry59f4VhHrssRAmGhb+ZDyjIphwhBJpXOCDayPTlEEgqUfmgLkgpb6awLuW5hQJxiiXlOhXw4UkHwyXMqIo1svYCNPE4tq22bUk9tibW4xu/Yp+SmxdQLoQOvaV77ZA2S5LNcraypFGNvCMMNdg82nWJetnz1CCMUfscREyA7DgW4uJNkLQbCPv09J1WnO9fmzbfkq1sSSP9UqYVkLbtzLSJbg9VVLsxVyvEF73HbKTge0bGKF85XZJhZq4e1zEihv5mRjsDJCTv0+irgq9sqSEbwa1qUWK9sqV9WhryzRX9Xx5P0L7+yJWEaciiC/TQdy5VypSdIJsjQ6eNyumI1Vo53EvnvTYjOEruINQzMurToG+zfzmqliD5jxmiokJ3J8LWe0+JxDe5fxMQiUQBajWFIYbdbxcvnS0sIROWphb4ozB8W0CLoesZ7EHJHiI1wAY6I4tl+x8bcqfaRCrFo5Ve02JOXW36BQdV+FNxJvbsFtCmISVExOrJnL95O0VmYZUADtAcnwoYo23KAkDJCXHF48QEIbtH+j3TDyDZICIueldfwK1nu6F/JKsut4R0R8d3tGdy5yMTBc/gJafASoW/sRb9nM7E1HOSAX1h2aLmiOibzhzyPRUbx8O7NDhTSEg9j8erwodiBM2sQ76V+rYaPQT+O5ZJEpMEMr98y1NvLO6S4+lYFnMjjWCoWo6hXWkzspcpjcwkXAHYrA/wpgjwIuw8C2OAdXbcq6HDw0Ep/9XG4fk2YYZ4C7SAmEuG/QChlfqlgSWO3h6N2dKHNvQ4YtjrPcrFteFf/0LLj/tG18Dlo2aDkgQuBr89fjOpJrLzAy4JnsWBPIqfYy1hDy04QUZllK1JKjWLoyD4JWc4rgl6MtWS9jmUW2ryOldu0E151QEVF8REB6FOA4E32e1h5Jx2KPZXF5s/uQ0AuB/JFudynTnY5EkJFXhQ6RUneqy11XVtiSRU5EJrTbAuf0FA78kSiOICjzUxlLoXjmOTtOdgVuPQEyw8epeKrZLAOZ3R3+R0HOTJesKOUbiFmTFGZPqKBI/E9I7+WBRe6GlCJl7KY8uVgnO0JDT2PxbHW2iPPJeAJbT+qIsoDp+fR7UO9GwYu9jB1dWwauLg9t0J2+tsO+MdaFT3vOisBI3whum3iBG9kITyPCyXFH6ZK9e0fXP6UeiVLH5DsssRHizt0cT2ZqtbBaBzoIF3UqCfsUSh4UchscRds9fIg5GUhtDRC7UDGdplXN0z3iltdPCREcZR1dxx0IhG24lSudZHIxgITwpaToYezeN356SylLJtCxrIS+SIsOa+EFbef/wxPyTpFbZviRQ3jKygBUMjxlpJnBwh2gedy027eaTOniTvaOQK8r5MkkrWshchscLNQJx9dvULrdHT1bNtrxKoab6CBSQxuQYABwclW2y6NhGjMqsFe8OUXGA6WOpqnpasGFbNbf0ypk8LDDNdTzDYU2Zb3OBXHm0m8gvhErTbtWLfLbUdOZeYry8qdS8Jjc1voBSxs+ihXa+DH7tlEY9F3WfB28e5DAIYK7ygZRqPOpjOoR37lyvXbVg53LXVCLPLS1MbBFiwDFz2Bs4zG+1CSOPJrgJaGtz9paGTUVbkzojARzfYbVyrkhZAVL1ypAH+OFcNDVwFSqXCLvBevHBWXP2XGrSrMUn2xbw5QGVA9LyGZbaJSTJ6VB1LotRkeMAxBW7bL+DyeUkhMLjZuBD/VrQMjHGEyi1H0HSwHpwMAAhU1AS+nEBUeuAoklJEGg+c2DvWuDV9275oADTRRWEutwbpVeZEAKKkiSsy7VkG3yh/e1ynKPV9vYFew/d/5slS/bVUgTUeoqp5uIvzAeT44MHKt+7ElxY78YKUVdqnmCmdMRAdbw/VX7OTJC6QLC+bW7CbFHkwreMpKgcBR6B1602rg3LmBs2tUMA80Sn7OyqYEQbDc6h3+dvY3Ib6bJ9S9nwOwR37RCo5dIBgY4AhN62nk11A7RPoGCGJ3MGwASfvIjsMIJ6YIDm1i295vW8yBvHJs0HZN4YSmoL7QTTiaz287JtGDekuNo7u2XvDXdw+dPM1BpVa4h0HBV1u3s4fxTGtuE6lm7eL871MRMaIIjvTcFYKbgGMk01kMXg0HFjYOVPD+7kO4b0BsfJxcJDF2W5zdokuJlTv8Z4Ba+rWr0eqHYVvECQKkxXWidmv9i+cHN+y13mmCbWYEWpd5z4r1Mv2gFcwC45pQKvgl24LzHZucwS2KQNnB/u18ywk2SNESrS2TOcncDe18ybOcqMhrnEeI6+DsOgi8tN7uKF61MJ69eCS73TR2GS7RtMQPdHUOberggzgHDvpn5ZygcoXQJxNJJqeOhnpPSFR3l+MvbRtefMDYtxwa5rft9Tv2DD5+luW7eML5BCkxoy1L/DP74AAgEZRflUpwTJuQzQh3/WG26smr/DZAbiPURhAIOtGxaOEm+CTek51mjwzrO9R5HidcC732fBaje6ODRWfQ4mUqBO2Zt6moYZoyz++pvtGUFXEMWcTEGLxnZeoWsd/CWXDHca550MrUUa9XwUOHesRQEVeF16xwCgwPWpmGle4Y3GjI7nMJFgmmZJ63Co4jBE9bFR8ASuphdIWetWLXDkFi29aCV620k6NvxFetEGGuEgIrx9idIb2jKGHDg5RP23ZZOe0o1+9svTjQvwe8zmSJy3mN/ZEPhp9UJCtPXo2SF9oBB0CDfM+fKhHOsw8lW69esfdv36LcscfzXAQIuefDA6RKwlmCW2JOtZPmTr3ndrU7lgHMyo86dih5Rl/CAC6jKAOgczW8R/oFLHMIIXyGZxWOcRAzydMfHbAS2Ox5jSu7R0JR1OGpTXuhjmJrW8VJY7pgBMppJNEp/tmnCVn4xMGii36b3nw/s5uWL1DMlYDCBr5tB+qHrnDOTVwZ+e9dKQdl31LF/6EhgC9JyBNDo7vLdl86iAwxu7ysr7WeU3Fthc643Am+xUYJBFWIH7wS16kxHYCFRd+2coPT8ybwUQbomKAuhpcc4bOCPMypYRwuRXQbe71EdeZ9emH00EAQyq4a09F1Qahe4cYxm3bYnzNRKGYyl3RI5ptMJgjV1vrFwLefwBqluwa4pMIvRnl5CUxkRqW+NSGvc2+HfaeqcqO3Bx73wltLxKwsEoHSxY51O/zEjjpRsosw42rx7SHaVHmH2JEmQbOdq3JH9irgki7AHFvLAodYnrrOybo65DKP/QVwLfYKloT6rzFuC/X0ldnjAA+zDxIxcWqD1HgBnNIGJvBRnbFT0SY6FDDkOB+5DVTBM1i2WfYH9TzKBuCbD52TsF7DGrGeuYZihAedRhw5dyxhUQj61CG7coir7ZQyv0s/hQUnt2x2luwFFqLV+FY4MJgY2vbaF46En71SfgdEGJ4NH9pgZ9Yl4Kl2K0oWs17GulxA5ts4eQyKTVLZWnOUalL4KJY6Hbfqceam1lGH8GNYoftVINJgutnxQqog7lYcxftX5kowPssobB88X0L4OMyeW1PgEKiT1Wl9wgbf2VNWsKd59gqF1XVsQX7w8pXyU7EBTjon7g4Q6mDzRk7/4IJJ1aVyfFgbzRy1jXUNtEAjSjnK8tBmYmzuDa6KPj6/hn5K0CXEvTj49BtXRo7Yepr94hVvlI3ONQw+q4KO7aeuDD9mIx6hDVwo770rU0q25uBANfqsCTLUGo6tVS5vJYVHbk4LHxLOf9AqwN3lrcYbfalKTH6Ib5V4t+KPXhEW2up76LUr+wJLPhW0eQ0fdJAtfcQl8FktvtUyuHTNya1cx4r7tgMswawKjJ/LOaJDRSjxEaaprvK28JGTjYzZp9oIEWwprVoVA2cNYpiHWhcPbeEwGHTFlxIp3Z8tc3/par6qCvfhGGxkMX9+m31loDpGY90y6NhOJ3X+NrRRbmWHVtTOKBVSm1+oWHiA3X349IA6VYCNsdA4oHcXjsPWP8IZKsijV4VXqtNJhLfiJcjjhdYWBZSK8ANbvHCimDih4rw7Srhjv6QlR2FNaH/B21sc/SnmdqA68piw91F8TJyvjj0Wzy/+2Q1p7xxVrMI87Ve5uGuBVommbaND2ReyQ5vfJx/I6F06eHifxOjIZIzWJhqvlY25lMw2ET5Fso2aWfpNLnNZIe9GC4Kyr+HwYTd4mYvIRg4kHTMPdMmX5Y56AcvlA5AbTvCJ+TMrzX0YfLB824FQNEb/Cr+b73NBfL+Jv/aPwr3EtYo3a6vjREekY0hTqDL68iUe3XJVO+DjRWmwYJuotKoTembLSAd9qlV+mMiBEbvgfZeCUOP8vUZXNQ9iY7RVcXZ8CdUlo+3YoS4iz7+wieNL7bPscuWTnMfl/QknuAdDiVzpMY+w5HhpOJCUFM4illkooKLzkDmCUaKFIuG/oXrih1hPYIXLK7eZY2xD8ZdyqYSjjpR3LQke2MJVrEEOzZWD8sH4+YynZjZFjnUhG/RpZyt8soPGrb1rff2i3DChllUFGkqzfAprS/fw9ifVnZICEj5I3r7F9o6Uah4PYAkxyikBaRPXS2pF4xIVh/dtJCoGXydJCSdP0BTjUuCeQbBqyTaTaizWkXdqSfaWQzeMFpkToFsy2l7t2nV0+h0r8mRqEGpuvaPlH9mU6zkJF+Fw6ya/4zRVGe5ar85ZmGDobo4wJn2UfeK4ZSTwixa8YWUidwAkvoZx9yGlpig3WOnK6gaU/jW2y0QdhWfz7YJcK7cV2wVOYbBFcXJSJG45q4o8eqF04mLPXcHBwAPAjWZiUzh51xTiVjt9urxLeWGV+XLJDZlgUoUCo8id8RjVKS01upyRIBCy2guerHJtR6oJ/lezwZBGBd6QZVHsmSrXaPDAUyjjlxq5vnOspGOj6Ig29wZeyEuXNXiUr5OY2Db2RhU+TaXuDrB8hcG7VOZeGO1fVPeLOaFzd0VelTJ9OuFwmxGL2pu3I+wmKfeLHraBEcwWHH9zrCem8lRfbDcoP/hIr0yp3WLcDVDS32uf+1POVzn5KwomvytVWGrb4SKX6Sq1IXQgSTML215TnHeVufptNE7My3OMjujxFJThLe+DhddZh4PMTcwvkMc+NWb2F+TC0y3BRZjKB1S8fcVwVh7maAA5TnmJdnJHukPHDDTnZ7o91j9w7pw8JOXa57F0bMy5nRywTYzt2C51nxD25MNPRIm3G/v34tTmC8nEqmHrHNEtGDQ/HiUR3jpaHBG6ONPGThO53kNdKaO47yqcjQV71NqZ5JXWLXErchCuaywG1qHkXHQhlYfC1CFuETCzumOop0e2MtWLURnZNt1jQm8uuaF2eZEICEcTT1zhfT98iRmPJfxYFOM/lILDh/Pizs1NQ/owe1ehuCz9BJRrbrdXclNJfdnqYg1EBdSZODvZS8eWqTwQxeJHlO/gkajRwkEfmPijXfx/QEvWRX/mqYjiWvD/78aDdxwKXtLA21CjD3CxbxYosttiv/jE9+cZfMl79Uls2FGXXaUebwj77Eq9FMVt/18iRRRS", # fmt: skip "meson-cross.ini.jinja": b"eNptkMFqwzAQRO/6ioUQ0tJazaU9FPoLvfUUglDkNV4iS0IrB4eQf69k2ZRCbmI083Z3NvDDCJbO+Ck2YgMDsnfAmMYATWOiZ246sgi3G1zQXVT0PsH9/jYbq0GSI5BSzoBvxJYXzF6+f8j9ixTicCKnIyEfhYEv2GWaMRmzEyaEVZimWbltG+h8hDNeXyHo1AO5ClS6bSmRd9qqFSgp4cBPz7C9iwzJoQxZiHN4RaJrC7XYtkAd8JXnW7JwOI1kU5PH+FDwecsQsaNp4azWiiqknC+5EH3AmOpdgz6jSt5b02tyam5tuezR17rX/1Xys1b8aHLzN1oces9JDdr05PBYcrmHkrLkxqn0OqpOD2SvaxnRG2TOFdTax4d6GaBd5aRkcSd+ATx1t64=", # fmt: skip "prompt.venv-created.en.txt.jinja": b"eNp1kTFvwkAMhff7FV7YStjbqWLqwMZSISSOO4dYJOfIvoRGiP9eXwR0KTedbOv5e8/bBmEkyYNvAZP9OHWYMpBC4gsEQZ8xVs598wCdn8CHTKPVgDIcJ1AeJFA6QTYh3/fCvVBp3+eIE2gQ6k0ylSG3Owli2h8p7VZ7iCQYMsv0Bj5FiPjUN3FKI5+L+EGGiZZ/zUPlroslUA06qTBnWNyc+4RaUJtHbdUL1vRTrPhWGYxtJDUgjHeW/5xX7mt270dPrT+25iTPszW3LV8KTe9z8+4c2LubuV6fILeb2ZrpMEUDLGDbFyHPWIE7VLhQbsDCjtOy8xFhvfFnhMzchsYbbU0tupLQBtUiDcKqc7GCdYPh/MoOzFA1iw2w4oezdaWujwiKhhpEymXNoP5kp02aZQjleFq5Xz21xdY=", # fmt: skip "prompt.venv-created.zh_CN.txt.jinja": b"eNpdkc1O20AUhfd+itmwRDwEa54gyiINk2IR2ZUdoFEUycofTgkmqG0KSapWKEFeFDtARFzbSd6Fzp2xV36FXmNAiFnO3DnnO/cIawaLOzBHEPjg9LjZjy6H/OSXsFy4av4zGpLEGzacuY/GWFcPtCJ9NH6S3EeNUiX/QVZyW3kiRg4svzPvBPpmbDTEsMXXBp8HUWvIx3+SsMeCaXbDbyZv5ZNwFBk96FuoH82a4ptNclVaLqtHee2gKm/u0kKxIh8WKhRtJFQR1wGEBjhPYLWNTSKXiF7VNVWtkI26JMHYfmfBvJsUwL9mno9B4qYtlnfM7zDPgOkPPpgh7YvE1ieNluTPBFXQC/NECxdWrSQcShLB85y6Vnv1rNdTsBSEKrvIkjK8A4jWF9Bpi6WDpsgunFs4vcekqe/2TmGfElhMob2Iv67i9inO8cExCx6S0MxeK6paLu4VZIWU5DJNwi7zLLJDdVWRmD+Bs64IB5F7/vrr6YkUNVXXX37gsrIOMdhbtLSo33+z9nh3BeYMN8sCbGyUyaUD93Z8/AW3yMddtlwjeOTO+YWVFvAf3zNT3Q==", # fmt: skip "ruyi-activate.bash.jinja": b"eNp9lN1u2kAQhe/3KSYGqiSVRUnuqIhEGqRESgLCBClKI2uxx/JKZh2t126o6yfqI/QuT9bxH2CHhBvEzpn1d2YO7sDCFxF4IkBYx5GGFUIcoQu/hPbBiMJYOQgrIfsq3giTO1okXKMBp54K17DikX/KOrAJY3C4lKEGFUsQGlyh0NHBhjEmPHgCo5tejq1r25o+zH9MzMyAEZ19M+D5O2gfJQP6oOOHYDzSZQVL9XSdE0aOEi96CD+79XHefPHlrGx8pUeenzNPMFaAulijwvEJpIWoAwoj1BAGLqBMhArlGqWGhCvBVwFGleqIcM3f0E2X4/lXO4Nn6tOxkhFoFSOQHSoAMbnoBFzRsLgGHgRFO1WrfnJszx8eb+zp7ZU9Gy+uh3QZ+YU9w/knL42MblNrbMv4+hIqXai2Z7HMfTQ7imLuvzQhZMID4eYDoKcV7bQhx8ei7tPewFRwdtF3MenLmOg/xbcGH8Bbgya7NXiPbg0+Jq9qNXhZL8rLyf3y0Jk9m0/vZosa94mAiXVQRUqG0sWIFpWvH1vEHbAw8KAWHLWwTA9a4anZMkYhL0VCKQww4Y3gtDPXpGBsCz9K050T+AO0hixj1aB2tllztzTiMhTVj62wT//MYVXbDwr7LAAHl5/28mHuhnw/vpuA2ctYe+4NB6WstpH2TMAgwsN9RveYXhco+Rphz4JxYpSN0iWA3vth1Psu3yNlMnfFqxtrfHk7qURmO6ONpBFCSl8kYtvsvv2d0+5g/8ryquztHzTke2mmRPwHax2J0Q==", # fmt: skip "ruyi-activate.fish.jinja": b"eNqNVF1P2zAUfc+vuLhhwENAG29Im8REJyoBRS1DQtMU3OSWWkrszrGjsY5ftJ+wN37ZbOejcZdW5KX19bnH514f3wHcLVgBc5Yh5LpQMEMohJYJpjCXIjc7xeIMHqsYzBg/kfqZRTRRrKQKj+3+YzCAZ6EhoZwLBVJzYApSJjFR2XMQsDkoNNwkjAnsfQRSsRGI6Dp2TAIwHyYLAQcPhs3Jqc9VVmSRSLZUZxA20YPDQlGlK/mc5ngEn959cDQSlZYcTk8D5GkQzDU3ggUHJz7FRj5EKZDhT6bcBpRMKk0zQG7+CZ4jV0B52rApAVzI3ACKBWYerBI/MMgCFYgs9ThKKhmdZVg4lGmHBUU/IJ58fRjF46uL+Pb87tJt2s/tPv0EG4RwFwj7KFzF2w76MppexreT8fXtXTy+H04mo4vhds6daEPf9LVwh9gexqb22JoiXhr7LFWLtl8HjvAGUNLPCW/i3y3HNslrVl27K/1+eHPfG6x70ba3tjWVT+W3998rI3PBUxOW2noMSXviAKaYzaNmby/oVb1h0Faj0zkAza0kJiVmWFLPWpvW9nUEQeOqthhYrTqL38bU8PKyxvnOgtD5y7MmCdv0EzMZSI0JVvu2N+um3ZxfDyHaf/lfQt1PT0kFb+Ss9iPArMDd+YczWrgJAGtJR1UuT40Wk9tOoehXV3h8MZqef74a1kzNO57S0k4dhESbXnPPcu2FAS0cpl1vt5xn6i5Xf4qX02v3jsR6OtWzUMxdMKOm1ETkOa2N3to5s+MprtFh9Rt0eMdaLbVyJJtvxs7miAN5/TOx4zLcvIfXv0C6VBPTbyF3qFxKLJkw602l7iiCdjKHa7nE2OJ4i1Yh2RPjNDshBk82pW+/GX8AeL7vm35d7xD3Kv8BeY5Dlw==", # fmt: skip "ruyi-cache.toml.jinja": b"eNptUs1uhCAYvPsUX2nMtknjoccmve0e9tK9eDOGsIJK1r8CmhjjuxcUF6uexG+GmWHgFX5u4eULwpxLSHnBQH9LUpGMUbj3INqeB3C+GRpcztfwxfOihCQ5o7j7jL3BB57CLytbfOcV+ONz+Q1oGBwyjkhzWUU13R/tvkbUxhMbEqs6AzjxYINOZmkt4PEBHWjJDR5wxUr59q5VtPFDO9oMnTH3ZnezX+P/k6xFFREZU3Iltkq0gFY/9lRdF0lOeGXOSLlYHKPTFjnFUwo3TguSySP+BFj6XFMXIQfLXoq6VijW0XbTIz0LzYr7O9DiWZJgXklFigLrqJP0ZuaEN8Ch7LPobbtJSXHJFKFEEVyS5rjmHQvZwlHsUSZXpzR/S7PT3WDVNvoRuxpWU9epy/cHatoHPQ==", # fmt: skip "ruyi-venv.toml.jinja": b"eNqtkOFqwyAQx7/7FBIIbWEN7AH2JEHCYUwqbTxRN1ZC3n0atQlpSxnbN/V+3v3uX3NUnewZ0QY7eRH0gxbjSPNtmgoyllR21F6tQXT0WE4knyObb5E9UqFaz3uM1INw0IIDRr6EsRKV//JOQscBuEGqz32Tmb0+zM2N0NjINovUu/SwY/MEDk70aK5LPb8kQMEglmK4pcKikGrpIZWTehQL9nHvrFcXac/GSxcsELf1Kg38DL2wVWIYCRPWyz1pc/Cjw6Als+1UMXxewKEJvI1zPdKhoQ5ML1wDhp/eqKZSPf9VSScGu48BP9DOeBWSWfUNwdzvolfWQaR8tcK3M3Dvv1VeU7PnI9EAsb8rOcQLP4FUv4t1++1Vrjf+X4L9AYyLQa0=", # fmt: skip "toolchain.cmake.jinja": b"eNqFkVFrwjAUhd/7Ky6IMGF274M9dG2cZWlT0joUhJDVaIuajKQTR+l/X6zOWccwb+F+5+Scmx5MjIBNuRaPTs/pQb7lawGu68Lc3g5nGPiR94pYRgj2x14Ys1GI0VNdw07IHdNKVdA0D5VSm7zgpXSPFnOn7g+hXIL5Mi3Tb64MwzjNPIxZQtEonB4Mf9CmOcmFXFiHs9LGchwjqrujQTpLMxSx2IsQ4FJ+7gd/hwklPkpTQsH6f2iVC2OUti9csj7zSZTYVi2V59fj6bQL7PctUfcv+w1tzF/NKIwDRgnJWOJlY+iUa5UX1YzgOi9gaXMVgi+ENsDlwn7Ku+a6FAZKCVUhoOJ6JSqr3JVaya2Q1X1XbfutNN+eBYUyHfzffCwiATrs6oV6EcToDdHBDRiHz9SjMyAxnt1iw9jHkwCd2G+4PsQk", # fmt: skip } TEMPLATE_NAME_MAP: Final = { "binfmt.conf": "binfmt.conf.jinja", "meson-cross.ini": "meson-cross.ini.jinja", "prompt.venv-created.en.txt": "prompt.venv-created.en.txt.jinja", "prompt.venv-created.zh_CN.txt": "prompt.venv-created.zh_CN.txt.jinja", "ruyi-activate.bash": "ruyi-activate.bash.jinja", "ruyi-activate.fish": "ruyi-activate.fish.jinja", "ruyi-cache.toml": "ruyi-cache.toml.jinja", "ruyi-venv.toml": "ruyi-venv.toml.jinja", "toolchain.cmake": "toolchain.cmake.jinja", } ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/000077500000000000000000000000001520522431500172575ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/__init__.py000066400000000000000000000000001520522431500213560ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/admin_checksum.py000066400000000000000000000044331520522431500226070ustar00rootroot00000000000000import os import sys from typing import Any, TypeGuard from tomlkit import document, table from tomlkit.items import AoT, Table from tomlkit.toml_document import TOMLDocument from ..i18n import _ from ..log import RuyiLogger from . import checksum from .pkg_manifest import DistfileDeclType, RestrictKind def do_admin_checksum( logger: RuyiLogger, files: list[os.PathLike[Any]], format: str, restrict: list[str], ) -> int: if not validate_restrict_kinds(restrict): logger.F( _("invalid restrict kinds given: {restrict}").format(restrict=restrict) ) return 1 entries = [gen_distfile_entry(logger, f, restrict) for f in files] if format == "toml": doc = emit_toml_distfiles_section(entries) logger.D(f"{doc}") sys.stdout.write(doc.as_string()) return 0 raise RuntimeError("unrecognized output format; should never happen") def validate_restrict_kinds(input: list[str]) -> TypeGuard[list[RestrictKind]]: for x in input: match x: case "fetch" | "mirror": pass case _: return False return True def gen_distfile_entry( logger: RuyiLogger, path: os.PathLike[Any], restrict: list[RestrictKind], ) -> DistfileDeclType: logger.D(f"generating distfile entry for {path}") with open(path, "rb") as fp: filesize = os.stat(fp.fileno()).st_size c = checksum.Checksummer(fp, {}) checksums = c.compute(kinds=checksum.SUPPORTED_CHECKSUM_KINDS) obj: DistfileDeclType = { "name": os.path.basename(path), "size": filesize, "checksums": checksums, } if restrict: obj["restrict"] = restrict return obj def emit_toml_distfiles_section(x: list[DistfileDeclType]) -> TOMLDocument: doc = document() arr: list[Table] = [] for dd in x: t = table() t.add("name", dd["name"]) t.add("size", dd["size"]) if r := dd.get("restrict"): t.add("restrict", r) t.add("checksums", emit_toml_checksums(dd["checksums"])) arr.append(t) doc.add("distfiles", AoT(arr)) return doc def emit_toml_checksums(x: dict[str, str]) -> Table: t = table() for k in sorted(x.keys()): t.add(k, x[k]) return t ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/admin_cli.py000066400000000000000000000170551520522431500215600ustar00rootroot00000000000000import argparse import pathlib from typing import TYPE_CHECKING, cast from ..cli.cmd import AdminCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class AdminCheckCommand( AdminCommand, cmd="check", help=_("Check package manifests and metadata repositories"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: input_group = p.add_mutually_exclusive_group(required=True) input_group.add_argument( "-f", "--file", action="append", default=None, metavar="MANIFEST.toml", help=_("Path to a package manifest to check (repeatable)"), ) input_group.add_argument( "--repo", type=str, default=None, metavar="REPO_ROOT", help=_("Path to a metadata repository root to check"), ) p.add_argument( "--check", action="append", choices=["format", "parse"], default=None, help=_("Check to run (repeatable; defaults to all checks)"), ) p.add_argument( "--only-packages", nargs=argparse.REMAINDER, default=None, metavar="RUYI_LIST_FILTER", help=_( "Only check packages matching trailing ruyi list filters; " "valid only with --repo" ), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .check import CheckUsageError from .check_cli import do_admin_check files: list[str] | None = args.file repo: str | None = args.repo checks: list[str] | None = args.check only_packages: list[str] | None = args.only_packages if only_packages is not None and repo is None: cfg.logger.F(_("--only-packages is only valid with --repo")) return 1 try: return do_admin_check( cfg, files=files, repo=repo, checks=checks, only_packages=only_packages, ) except CheckUsageError as exc: cfg.logger.F(str(exc)) return 1 class AdminChecksumCommand( AdminCommand, cmd="checksum", help=_("Generate a checksum section for a manifest file for the distfiles given"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "--format", "-f", type=str, choices=["toml"], default="toml", help=_("Format of checksum section to generate in"), ) p.add_argument( "--restrict", type=str, default="", help=_( "the 'restrict' field to use for all mentioned distfiles, separated with comma" ), ) p.add_argument( "file", type=str, nargs="+", help=_("Path to the distfile(s) to checksum"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .admin_checksum import do_admin_checksum logger = cfg.logger files = args.file format = args.format restrict_str = cast(str, args.restrict) restrict = restrict_str.split(",") if restrict_str else [] return do_admin_checksum(logger, files, format, restrict) class AdminFormatManifestCommand( AdminCommand, cmd="format-manifest", help=_("Format the given package manifests into canonical TOML representation"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "file", type=str, nargs="+", help=_("Path to the distfile(s) to generate manifest for"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .manifest_io import dump_canonical_package_manifest_from_path files = args.file for f in files: p = pathlib.Path(f) d = dump_canonical_package_manifest_from_path(p) dest_path = p.with_suffix(".toml") with open(dest_path, "w", encoding="utf-8") as fp: fp.write(d) return 0 class AdminBuildPackageCommand( AdminCommand, cmd="build-package", help=_("Build a package from a recipe file"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "recipe_file", type=str, help=_("Path to the recipe .star file"), ) p.add_argument( "-v", "--var", action="append", default=[], metavar="KEY=VALUE", help=_("Set a user variable for the recipe (repeatable)"), ) p.add_argument( "-n", "--name", action="append", default=[], metavar="NAME", help=_( "Select a specific scheduled build by name (repeatable); " "by default all scheduled builds are executed" ), ) p.add_argument( "--dry-run", action="store_true", help=_("Print the build plan without executing it"), ) p.add_argument( "--output-dir", type=str, default=None, help=_("Override the recipe project's output directory"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .build_runner import ( BuildFailure, format_build_report, run_recipe, ) logger = cfg.logger recipe_file = pathlib.Path(cast(str, args.recipe_file)) var_strs = cast("list[str]", args.var) selected_names = cast("list[str]", args.name) or None dry_run = cast(bool, args.dry_run) output_dir_raw = cast("str | None", args.output_dir) output_dir = ( pathlib.Path(output_dir_raw) if output_dir_raw is not None else None ) user_vars: dict[str, str] = {} for v in var_strs: if "=" not in v: logger.F( _("invalid --var spec {spec!r}: expected KEY=VALUE").format( spec=v, ) ) return 1 k, _sep, val = v.partition("=") if not k: logger.F(_("invalid --var spec {spec!r}: empty key").format(spec=v)) return 1 user_vars[k] = val try: reports = run_recipe( logger, recipe_file, user_vars=user_vars, selected_names=selected_names, dry_run=dry_run, output_dir_override=output_dir, ) except BuildFailure as e: logger.F(str(e)) return e.exit_code or 1 except (RuntimeError, FileNotFoundError) as e: logger.F(str(e)) return 1 for r in reports: logger.I( _("build {name!r} completed: {n} artifact(s)").format( name=r.build_name, n=len(r.artifacts), ) ) print(format_build_report(r)) return 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/atom.py000066400000000000000000000125001520522431500205670ustar00rootroot00000000000000import abc import re from typing import Final, Iterator, Literal, Tuple import semver from .pkg_manifest import BoundPackageManifest, is_prerelease from .protocols import ProvidesPackageManifests AtomKind = Literal["name"] | Literal["expr"] | Literal["slug"] RE_ATOM_EXPR: Final = re.compile(r"^([^:(]+)\((.+)\)$") RE_ATOM_NAME: Final = re.compile(r"^[^:()]+$") class Atom(abc.ABC): def __init__(self, s: str, kind: AtomKind) -> None: self._s = s self._kind: AtomKind = kind @property def input_str(self) -> str: return self._s @property def kind(self) -> AtomKind: return self._kind @classmethod def parse(cls, s: str) -> "SlugAtom | NameAtom | ExprAtom": if s.startswith("slug:"): return SlugAtom(s) if s.startswith("name:"): return NameAtom(s, s[5:]) # strip the "name:" prefix if match := RE_ATOM_EXPR.match(s): return ExprAtom(s, match[1], match[2]) # fallback if match := RE_ATOM_NAME.match(s): return NameAtom(s, s) raise ValueError(f"invalid atom: '{s}'") @abc.abstractmethod def match_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> BoundPackageManifest | None: raise NotImplementedError @abc.abstractmethod def iter_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> Iterator[BoundPackageManifest]: raise NotImplementedError def split_category(name: str) -> Tuple[str | None, str]: fragments = name.split("/", 1) if len(fragments) == 2: return (fragments[0], fragments[1]) return (None, name) class NameAtom(Atom): def __init__(self, s: str, name: str) -> None: super().__init__(s, "name") self.category, self.name = split_category(name) def match_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> BoundPackageManifest | None: # return the latest version of the package named self.name in the given repo try: return repo.get_pkg_latest_ver( self.name, self.category, include_prerelease_vers, ) except KeyError: return None def iter_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> Iterator[BoundPackageManifest]: # return all versions of the package named self.name in the given repo for pm in repo.iter_pkg_vers(self.name, self.category): if not is_prerelease(pm.semver) or include_prerelease_vers: yield pm def fix_version_matcher_for_semver2(match_expr: str) -> str: # equivalent of https://github.com/python-semver/python-semver/pull/362 # for semver 2.x if match_expr and match_expr[0] in "0123456789": return f"=={match_expr}" return match_expr class ExprAtom(Atom): def __init__(self, s: str, name: str, expr: str) -> None: super().__init__(s, "expr") self.exprs = expr.split(",") if semver.__version__ < "3": self.exprs = list(map(fix_version_matcher_for_semver2, self.exprs)) self.category, self.name = split_category(name) def _is_pm_matching_my_exprs(self, pm: BoundPackageManifest) -> bool: for e in self.exprs: if not pm.semver.match(e): return False return True def match_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> BoundPackageManifest | None: matching_pms = { pm.ver: pm for pm in repo.iter_pkg_vers(self.name, self.category) if self._is_pm_matching_my_exprs(pm) } if not matching_pms: return None semvers = [pm.semver for pm in matching_pms.values()] if not include_prerelease_vers: semvers = [sv for sv in semvers if not is_prerelease(sv)] if not semvers: return None latest_ver = max(semvers) return matching_pms[str(latest_ver)] def iter_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> Iterator[BoundPackageManifest]: for pm in repo.iter_pkg_vers(self.name, self.category): if self._is_pm_matching_my_exprs(pm): if not is_prerelease(pm.semver) or include_prerelease_vers: yield pm class SlugAtom(Atom): def __init__(self, s: str) -> None: super().__init__(s, "slug") self.slug = s[5:] # strip the "slug:" prefix def match_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> BoundPackageManifest | None: pm = repo.get_pkg_by_slug(self.slug) if pm and is_prerelease(pm.semver): return pm if include_prerelease_vers else None return pm def iter_in_repo( self, repo: ProvidesPackageManifests, include_prerelease_vers: bool, ) -> Iterator[BoundPackageManifest]: pm = repo.get_pkg_by_slug(self.slug) if pm is None: return None if is_prerelease(pm.semver) and include_prerelease_vers: yield pm ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/augmented_pkg.py000066400000000000000000000131501520522431500224430ustar00rootroot00000000000000import enum from typing import Iterable, TypedDict, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Self from ..config import GlobalConfig from ..i18n import _ from ..utils.porcelain import PorcelainEntity, PorcelainEntityType from .distfile import Distfile from .host import get_native_host from .list_filter import ListFilter from .pkg_manifest import BoundPackageManifest, PackageManifestType from .composite_repo import CompositeRepo class PkgRemark(enum.StrEnum): Latest = "latest" LatestPreRelease = "latest-prerelease" NoBinaryForCurrentHost = "no-binary-for-current-host" PreRelease = "prerelease" HasKnownIssue = "known-issue" Downloaded = "downloaded" Installed = "installed" def as_rich_markup(self) -> str: match self: case self.Latest: return _("latest") case self.LatestPreRelease: return _("latest-prerelease") case self.NoBinaryForCurrentHost: return _("[red]no binary for current host[/]") case self.PreRelease: return _("prerelease") case self.HasKnownIssue: return _("[yellow]has known issue[/]") case self.Downloaded: return _("[green]downloaded[/]") case self.Installed: return _("[green]installed[/]") return "" class AugmentedPkgManifest: def __init__( self, pm: BoundPackageManifest, remarks: list[PkgRemark], ) -> None: self.pm = pm self.remarks = remarks self._is_downloaded = PkgRemark.Downloaded in remarks self._is_installed = PkgRemark.Installed in remarks def to_porcelain(self) -> "PorcelainPkgVersionV1": return { "semver": str(self.pm.semver), "pm": self.pm.to_raw(), "remarks": self.remarks, "is_downloaded": self._is_downloaded, "is_installed": self._is_installed, } class AugmentedPkg: def __init__(self) -> None: self.versions: list[AugmentedPkgManifest] = [] def add_version(self, v: AugmentedPkgManifest) -> None: if self.versions: if v.pm.category != self.category or v.pm.name != self.name: raise ValueError("cannot add a version of a different pkg") self.versions.append(v) @property def category(self) -> str | None: return self.versions[0].pm.category if self.versions else None @property def name(self) -> str | None: return self.versions[0].pm.name if self.versions else None @classmethod def yield_from_repo( cls, cfg: GlobalConfig, mr: CompositeRepo, filters: ListFilter, ) -> "Iterable[Self]": rgs = cfg.ruyipkg_global_state native_host = str(get_native_host()) for category, pkg_name, pkg_vers in mr.iter_pkgs(): if not filters.check_pkg_name(cfg, mr, category, pkg_name): continue pkg = cls() semvers = [pm.semver for pm in pkg_vers.values()] semvers.sort(reverse=True) found_latest = False for i, sv in enumerate(semvers): # TODO: support filter ops against individual versions pm = pkg_vers[str(sv)] latest = False latest_prerelease = i == 0 prerelease = pm.is_prerelease if not found_latest and not prerelease: latest = True found_latest = True remarks: list[PkgRemark] = [] if latest or latest_prerelease or prerelease: if prerelease: remarks.append(PkgRemark.PreRelease) if latest: remarks.append(PkgRemark.Latest) if latest_prerelease and not latest: remarks.append(PkgRemark.LatestPreRelease) if pm.service_level.has_known_issues: remarks.append(PkgRemark.HasKnownIssue) if bm := pm.binary_metadata: if not bm.is_available_for_current_host: remarks.append(PkgRemark.NoBinaryForCurrentHost) if _is_pkg_fully_downloaded(pm): remarks.append(PkgRemark.Downloaded) host = native_host if bm is not None else "" is_installed = rgs.is_package_installed( pm.repo_id, pm.category, pm.name, str(sv), host, ) if is_installed: remarks.append(PkgRemark.Installed) pkg.add_version(AugmentedPkgManifest(pm, remarks)) yield pkg def to_porcelain(self) -> "PorcelainPkgListOutputV1": return { "ty": PorcelainEntityType.PkgListOutputV1, "category": self.category or "", "name": self.name or "", "vers": [x.to_porcelain() for x in self.versions], } class PorcelainPkgVersionV1(TypedDict): semver: str pm: PackageManifestType remarks: list[PkgRemark] is_downloaded: bool is_installed: bool class PorcelainPkgListOutputV1(PorcelainEntity): category: str name: str vers: list[PorcelainPkgVersionV1] def _is_pkg_fully_downloaded(pm: BoundPackageManifest) -> bool: dfs = pm.distfiles if not dfs: return True for df_decl in dfs.values(): df = Distfile(df_decl, pm.repo) if not df.is_downloaded(): return False return True ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/build_runner.py000066400000000000000000000226521520522431500223300ustar00rootroot00000000000000"""Executor for ``ruyi admin build-package``. Given a path to a recipe ``.star`` file, this module: 1. Discovers the enclosing recipe project (via :mod:`recipe_project`). 2. Builds a :class:`PluginHostContext` in build-recipe mode. 3. Loads the recipe module, collecting :class:`ScheduledBuild` registrations. 4. For each (optionally ``--name``-filtered) scheduled build, constructs a :class:`RecipeBuildCtx` and calls the registered callable to obtain one or more :class:`Invocation` plans. 5. Executes each plan via :func:`subprocess.run` unless ``dry_run`` is set; on a zero exit, resolves declared ``produces`` globs, computes sha256/sha512/size for each matched file, and writes a build-report TOML. The runner is intentionally I/O-heavy at call time and light on imports at the module top level so that the CLI startup-flow lint stays happy; all subprocess/hashlib/tomllib usage is inside :func:`run_recipe`. """ from __future__ import annotations from dataclasses import dataclass import pathlib from typing import TYPE_CHECKING, Any, Iterable, Mapping, Sequence from ..log import RuyiLogger from ..pluginhost.build_api import ( Invocation, RecipeBuildCtx, ScheduledBuild, ) from .recipe_project import RecipeProject, discover_recipe_project if TYPE_CHECKING: from ..pluginhost.ctx import PluginHostContext @dataclass(frozen=True) class ArtifactReport: path: pathlib.Path size: int checksums: Mapping[str, str] @dataclass(frozen=True) class BuildReport: recipe_file: pathlib.Path project_name: str build_name: str invocations: tuple[Invocation, ...] artifacts: tuple[ArtifactReport, ...] exit_code: int class BuildFailure(RuntimeError): """Raised when an Invocation exits non-zero.""" def __init__(self, build_name: str, exit_code: int) -> None: super().__init__(f"build {build_name!r} failed with exit code {exit_code}") self.build_name = build_name self.exit_code = exit_code def run_recipe( logger: RuyiLogger, recipe_file: pathlib.Path, *, user_vars: Mapping[str, str] | None = None, selected_names: Sequence[str] | None = None, dry_run: bool = False, output_dir_override: pathlib.Path | None = None, ) -> list[BuildReport]: """Load the recipe and execute (or plan, if ``dry_run``) each selected scheduled build. Returns a :class:`BuildReport` per executed build. Raises :class:`BuildFailure` on the first non-zero exit (subsequent builds are not attempted). """ project = discover_recipe_project(recipe_file) if output_dir_override is not None: project = _with_output_dir(project, output_dir_override) phctx = _make_recipe_phctx(logger, project) scheduled = _load_recipe_module(phctx, recipe_file) if not scheduled: raise RuntimeError( f"recipe {recipe_file} scheduled no builds " f"(did you call RUYI.build.schedule_build(...)?)" ) if selected_names is not None: selected = _filter_by_name(scheduled, selected_names) else: selected = list(scheduled) reports: list[BuildReport] = [] for sb in selected: reports.append( _execute_one_build( logger, phctx, project, sb, user_vars=user_vars or {}, dry_run=dry_run, ) ) return reports def _with_output_dir( project: RecipeProject, new_output_dir: pathlib.Path ) -> RecipeProject: resolved = new_output_dir.resolve() return RecipeProject( root=project.root, name=project.name, output_dir=resolved, extra_artifact_roots=project.extra_artifact_roots, ) def _make_recipe_phctx( logger: RuyiLogger, project: RecipeProject ) -> "PluginHostContext[Any, Any]": # Local import: keeps heavy module-load chains out of CLI startup. from ..pluginhost.ctx import PluginHostContext return PluginHostContext.new( logger, project.root, # plugin_root is reused for the recipe project recipe_project_root=project.root, ) def _load_recipe_module( phctx: "PluginHostContext[Any, Any]", recipe_file: pathlib.Path ) -> list[ScheduledBuild]: resolved = recipe_file.resolve(strict=True) return phctx.load_recipe(resolved) def _filter_by_name( scheduled: Iterable[ScheduledBuild], wanted: Sequence[str] ) -> list[ScheduledBuild]: by_name = {sb.name: sb for sb in scheduled} missing = [n for n in wanted if n not in by_name] if missing: raise RuntimeError( f"recipe does not define the requested build(s): {', '.join(missing)}" ) return [by_name[n] for n in wanted] def _execute_one_build( logger: RuyiLogger, phctx: "PluginHostContext[Any, Any]", project: RecipeProject, sb: ScheduledBuild, *, user_vars: Mapping[str, str], dry_run: bool, ) -> BuildReport: ctx = RecipeBuildCtx( project=project, name=sb.name, recipe_file=sb.recipe_file, user_vars=user_vars, ) ev = phctx.make_evaluator() result = ev.eval_function(sb.fn, ctx) invocations = _normalize_invocations(sb.name, result) if dry_run: for inv in invocations: logger.I(f"[dry-run] would run: {' '.join(inv.argv)} (cwd={inv.cwd})") return BuildReport( recipe_file=sb.recipe_file, project_name=project.name, build_name=sb.name, invocations=tuple(invocations), artifacts=(), exit_code=0, ) last_exit = 0 for inv in invocations: last_exit = _run_invocation(logger, inv) if last_exit != 0: raise BuildFailure(sb.name, last_exit) artifact_reports = _resolve_artifacts(invocations) return BuildReport( recipe_file=sb.recipe_file, project_name=project.name, build_name=sb.name, invocations=tuple(invocations), artifacts=tuple(artifact_reports), exit_code=last_exit, ) def _normalize_invocations(build_name: str, result: object) -> list[Invocation]: if isinstance(result, Invocation): return [result] if isinstance(result, (list, tuple)): out: list[Invocation] = [] for item in result: if not isinstance(item, Invocation): raise RuntimeError( f"build {build_name!r}: expected Invocation (from " f"ctx.subprocess), got {type(item).__name__}" ) out.append(item) if not out: raise RuntimeError( f"build {build_name!r}: returned an empty list of Invocations" ) return out raise RuntimeError( f"build {build_name!r}: expected Invocation or list of Invocations, " f"got {type(result).__name__}" ) def _run_invocation(logger: RuyiLogger, inv: Invocation) -> int: import os import subprocess logger.I(f"running: {' '.join(inv.argv)} (cwd={inv.cwd})") merged_env = os.environ.copy() merged_env.update(inv.env) # SECURITY: recipes are explicit Starlark code evaluated in a trusted recipe # project, and the invocation takes an argv list with no shell expansion, # which is the documented threat model. proc = subprocess.run( list(inv.argv), # sourcery skip: dangerous-subprocess-use-audit cwd=str(inv.cwd), env=merged_env, check=False, ) return proc.returncode def _resolve_artifacts( invocations: Iterable[Invocation], ) -> list[ArtifactReport]: import os from . import checksum reports: list[ArtifactReport] = [] for inv in invocations: for art in inv.produces: matches = sorted(art.root.glob(art.glob)) if not matches: raise RuntimeError( f"artifact {art.glob!r} under {art.root} matched no files" ) for match in matches: if not match.is_file(): continue with open(match, "rb") as fp: size = os.stat(fp.fileno()).st_size csums = checksum.Checksummer(fp, {}).compute( kinds=checksum.SUPPORTED_CHECKSUM_KINDS, ) reports.append( ArtifactReport( path=match, size=size, checksums=csums, ) ) return reports def format_build_report(report: BuildReport) -> str: """Return a TOML-shaped serialization of a build report.""" lines: list[str] = [] lines.append("# ruyi build report") lines.append(f'recipe_file = "{report.recipe_file}"') lines.append(f'project_name = "{report.project_name}"') lines.append(f'build_name = "{report.build_name}"') lines.append(f"exit_code = {report.exit_code}") for inv in report.invocations: lines.append("") lines.append("[[invocations]]") lines.append(f"argv = {list(inv.argv)!r}") lines.append(f'cwd = "{inv.cwd}"') if inv.env: lines.append(f"env = {dict(inv.env)!r}") for art in report.artifacts: lines.append("") lines.append("[[artifacts]]") lines.append(f'path = "{art.path}"') lines.append(f"size = {art.size}") for kind in sorted(art.checksums): lines.append(f'{kind} = "{art.checksums[kind]}"') return "\n".join(lines) + "\n" ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/canonical_dump.py000066400000000000000000000222301520522431500226040ustar00rootroot00000000000000from copy import deepcopy import re from typing import Final from tomlkit import comment, document, nl, string, table, ws from tomlkit.items import AoT, Array, InlineTable, Table, Trivia from tomlkit.toml_document import TOMLDocument from .pkg_manifest import ( BinaryDeclType, BinaryFileDeclType, BlobDeclType, DistfileDeclType, EmulatorDeclType, EmulatorProgramDeclType, FetchRestrictionDeclType, PackageManifest, PackageMetadataDeclType, ProvisionableDeclType, ServiceLevelDeclType, SourceDeclType, ToolchainComponentDeclType, ToolchainDeclType, VendorDeclType, ) from ..utils.toml import ( extract_footer_comments, extract_header_comments, inline_table_with_spaces, sorted_table, str_array, ) RE_INDENT_FIX: Final = re.compile(r"(?m)^ ([\"'{\[])") # XXX: To workaround https://github.com/python-poetry/tomlkit/issues/290, # post-process the output to have all leading 4-space indentation before # strings, lists or tables replaced by 2-space ones. def _fix_indent(s: str) -> str: return RE_INDENT_FIX.sub(r" \1", s) def dumps_canonical_package_manifest_toml( pm: PackageManifest, ) -> str: return _fix_indent(_dump_canonical_package_manifest_toml(pm).as_string()) def _dump_canonical_package_manifest_toml( pm: PackageManifest, ) -> TOMLDocument: x = pm.to_raw() doc = pm.raw_doc y = document() if doc is not None: if header_comments := extract_header_comments(doc): last_is_ws = False for c in header_comments: if c.startswith("#"): last_is_ws = False y.add(comment(c[1:].strip())) else: last_is_ws = True y.add(ws(c)) if not last_is_ws: y.add(nl()) y.add("format", string(x["format"])) dump_metadata_decl_into(y, x["metadata"]) dump_distfile_decls_into(y, x["distfiles"]) maybe_dump_binary_decls_into(y, x.get("binary")) maybe_dump_blob_decl_into(y, x.get("blob")) maybe_dump_emulator_decl_into(y, x.get("emulator")) maybe_dump_provisionable_decl_into(y, x.get("provisionable")) maybe_dump_source_decl_into(y, x.get("source")) maybe_dump_toolchain_decl_into(y, x.get("toolchain")) if doc is not None: if footer_comments := extract_footer_comments(doc): if footer_comments[0].startswith("#"): y.add(nl()) for c in footer_comments: if c.startswith("#"): y.add(comment(c[1:].strip())) else: y.add(ws(c)) return y def dump_service_level_entry(x: ServiceLevelDeclType) -> Table: y = table() y.add("level", x["level"]) if msgid := x.get("msgid"): y.add("msgid", string(msgid)) if params := x.get("params"): y.add("params", sorted_table(params)) return y def dump_service_level_decls(x: list[ServiceLevelDeclType]) -> AoT: return AoT([dump_service_level_entry(i) for i in x]) def dump_metadata_decl(x: PackageMetadataDeclType) -> Table: y = table() y.add("desc", string(x["desc"])) y.add("vendor", dump_vendor_decl(x["vendor"])) if "slug" in x: y.add("slug", string(x["slug"])) if uv := x.get("upstream_version"): y.add("upstream_version", string(uv)) if sl := x.get("service_level"): y.add(nl()) y.add("service_level", dump_service_level_decls(sl)) return y def dump_metadata_decl_into(doc: TOMLDocument, x: PackageMetadataDeclType) -> None: doc.add(nl()) doc.add("metadata", dump_metadata_decl(x)) def dump_vendor_decl(x: VendorDeclType) -> InlineTable: y = inline_table_with_spaces() with y: y.add("name", string(x["name"])) y.add("eula", string(x["eula"] if x["eula"] is not None else "")) return y def dump_distfile_decls(x: list[DistfileDeclType]) -> AoT: return AoT([dump_distfile_entry(i) for i in x]) def dump_distfile_decls_into(doc: TOMLDocument, x: list[DistfileDeclType]) -> None: doc.add(nl()) doc.add("distfiles", dump_distfile_decls(x)) def dump_distfile_entry(x: DistfileDeclType) -> Table: y = table() y.add("name", x["name"]) if v := x.get("unpack"): y.add("unpack", string(v)) y.add("size", x["size"]) s = x.get("strip_components") if s is not None and s != 1: y.add("strip_components", s) if p := x.get("prefixes_to_unpack"): y.add("prefixes_to_unpack", str_array(p, multiline=len(p) > 1)) if "urls" in x: # XXX: https://github.com/python-poetry/tomlkit/issues/290 prevents us # from using 2-space indentation for the array items for now. y.add("urls", str_array([str(i) for i in x["urls"]], multiline=True)) if r := x.get("restrict"): # If `restrict` is a string, convert it to a list, fixing a common # oversight in package manifests. if isinstance(r, str): r = [r] y.add("restrict", [str(i) for i in r]) if f := x.get("fetch_restriction"): y.add("fetch_restriction", dump_fetch_restriction(f)) y.add("checksums", sorted_table(x["checksums"])) return y def dump_fetch_restriction(x: FetchRestrictionDeclType) -> Table: y = table() y.add("msgid", x["msgid"]) if "params" in x: y.add("params", sorted_table(x["params"])) return y def dump_blob_decl(x: BlobDeclType) -> Table: y = table() y.add("distfiles", str_array(x["distfiles"], multiline=True)) return y def maybe_dump_blob_decl_into(doc: TOMLDocument, x: BlobDeclType | None) -> None: if x is None: return doc.add(nl()) doc.add("blob", dump_blob_decl(x)) def dump_provisionable_decl(x: ProvisionableDeclType) -> Table: y = table() y.add("strategy", x["strategy"]) y.add( "partition_map", sorted_table({str(k): v for k, v in x["partition_map"].items()}), ) return y def maybe_dump_provisionable_decl_into( doc: TOMLDocument, x: ProvisionableDeclType | None, ) -> None: if x is None: return doc.add(nl()) doc.add("provisionable", dump_provisionable_decl(x)) def dump_binary_decl(x: BinaryFileDeclType, last: bool) -> Table: y = table() y.add("host", string(x["host"])) multiline_distfiles = len(x["distfiles"]) > 1 y.add("distfiles", str_array(x["distfiles"], multiline=multiline_distfiles)) if cmds := x.get("commands", {}): y.add("commands", sorted_table(cmds)) if not last: y.add(nl()) return y def dump_binary_decls(x: list[BinaryFileDeclType]) -> AoT: return AoT([dump_binary_decl(elem, i == len(x) - 1) for i, elem in enumerate(x)]) def maybe_dump_binary_decls_into(doc: TOMLDocument, x: BinaryDeclType | None) -> None: if x is None: return doc.add("binary", dump_binary_decls(x)) def dump_emulator_program_decl(x: EmulatorProgramDeclType) -> Table: y = table() y.add("path", string(x["path"])) y.add("flavor", string(x["flavor"])) y.add("supported_arches", str_array(x["supported_arches"])) if "binfmt_misc" in x: y.add("binfmt_misc", string(x["binfmt_misc"])) return y def dump_emulator_decl(x: EmulatorDeclType) -> Table: y = table() # Prefer `quirks` to `flavors` quirks = x.get("quirks") if quirks is None: quirks = x.get("flavors", []) y.add("quirks", str_array(quirks)) y.add("programs", AoT([dump_emulator_program_decl(i) for i in x["programs"]])) return y def maybe_dump_emulator_decl_into( doc: TOMLDocument, x: EmulatorDeclType | None ) -> None: if x is None: return doc.add(nl()) doc.add("emulator", dump_emulator_decl(x)) def dump_source_decl(x: SourceDeclType) -> Table: y = table() multiline_distfiles = len(x["distfiles"]) > 1 y.add("distfiles", str_array(x["distfiles"], multiline=multiline_distfiles)) return y def maybe_dump_source_decl_into(doc: TOMLDocument, x: SourceDeclType | None) -> None: if x is None: return doc.add(nl()) doc.add("source", dump_source_decl(x)) def dump_toolchain_component_decl(x: ToolchainComponentDeclType) -> InlineTable: y = inline_table_with_spaces() with y: y.add("name", string(x["name"])) y.add("version", string(x["version"])) return y def dump_toolchain_component_decls(x: list[ToolchainComponentDeclType]) -> Array: sorted_x = deepcopy(x) sorted_x.sort(key=lambda i: i["name"]) return Array( [dump_toolchain_component_decl(i) for i in sorted_x], Trivia(), multiline=True, ) def dump_toolchain_decl(x: ToolchainDeclType) -> Table: y = table() y.add("target", string(x["target"])) # Prefer `quirks` to `flavors` quirks = x.get("quirks") if quirks is None: quirks = x.get("flavors", []) y.add("quirks", str_array(quirks)) y.add("components", dump_toolchain_component_decls(x["components"])) if "included_sysroot" in x: y.add("included_sysroot", x["included_sysroot"]) return y def maybe_dump_toolchain_decl_into( doc: TOMLDocument, x: ToolchainDeclType | None, ) -> None: if x is None: return doc.add(nl()) doc.add("toolchain", dump_toolchain_decl(x)) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/check.py000066400000000000000000000364001520522431500207110ustar00rootroot00000000000000import dataclasses import enum import pathlib import tomllib from typing import Iterable, Literal, NotRequired, Sequence import tomlkit from tomlkit.exceptions import ParseError from ..utils.porcelain import PorcelainEntity, PorcelainEntityType from .canonical_dump import dumps_canonical_package_manifest_toml from .host import get_native_host from .list_filter import ListFilter, ListFilterOp, ListFilterOpKind from .pkg_manifest import PackageManifest from .repo import RepoConfig try: from semver.version import Version # type: ignore[import-untyped,unused-ignore] except ModuleNotFoundError: from semver import VersionInfo as Version # type: ignore[import-untyped,unused-ignore] CheckName = Literal["format", "parse"] CheckSet = frozenset[CheckName] DEFAULT_CHECKS: CheckSet = frozenset(("format", "parse")) CHECK_FORMAT: CheckName = "format" CHECK_PARSE: CheckName = "parse" class CheckSeverity(enum.StrEnum): ERROR = "error" WARNING = "warning" class CheckUsageError(ValueError): pass class CheckDiagnosticPorcelain(PorcelainEntity, total=False): severity: str code: str check: str path: str message: str line: NotRequired[int] column: NotRequired[int] hint: NotRequired[str] @dataclasses.dataclass(frozen=True) class CheckDiagnostic: severity: CheckSeverity code: str check: str path: pathlib.Path message: str line: int | None = None column: int | None = None hint: str | None = None def to_porcelain(self) -> CheckDiagnosticPorcelain: result: CheckDiagnosticPorcelain = { "ty": PorcelainEntityType.CheckDiagnosticV1, "severity": self.severity.value, "code": self.code, "check": self.check, "path": str(self.path), "message": self.message, } if self.line is not None: result["line"] = self.line if self.column is not None: result["column"] = self.column if self.hint is not None: result["hint"] = self.hint return result @dataclasses.dataclass(frozen=True) class ManifestRepoContext: repo_root: pathlib.Path manifest_root_name: str category: str name: str version: str def normalize_checks(raw_checks: Iterable[str] | None) -> CheckSet: if raw_checks is None: return DEFAULT_CHECKS checks: set[CheckName] = set() for raw_check in raw_checks: match raw_check: case "format": checks.add(CHECK_FORMAT) case "parse": checks.add(CHECK_PARSE) case _: raise CheckUsageError(f"unsupported check: {raw_check}") return frozenset(checks) if checks else DEFAULT_CHECKS def infer_manifest_repo_context(path: pathlib.Path) -> ManifestRepoContext | None: if len(path.parents) < 4: return None manifest_root = path.parents[2] if manifest_root.name not in ("packages", "manifests"): return None return ManifestRepoContext( repo_root=path.parents[3], manifest_root_name=manifest_root.name, category=path.parents[1].name, name=path.parent.name, version=path.stem, ) def parse_package_selector_args(tokens: Sequence[str]) -> ListFilter: if not tokens: raise CheckUsageError("--only-packages requires at least one package selector") result = ListFilter() idx = 0 unary_options = { "--category-contains": ListFilterOpKind.CATEGORY_CONTAINS, "--category-is": ListFilterOpKind.CATEGORY_IS, "--name-contains": ListFilterOpKind.NAME_CONTAINS, } unsupported_options = {"--is-installed", "--related-to-entity"} while idx < len(tokens): token = tokens[idx] option, sep, inline_value = token.partition("=") if option == "--all": if sep: raise CheckUsageError("--all does not take an argument") result.append(ListFilterOp(ListFilterOpKind.ALL, "")) idx += 1 continue if option in unsupported_options: raise CheckUsageError( f"package selector {option} is not supported by admin check" ) op_kind = unary_options.get(option) if op_kind is None: raise CheckUsageError(f"unsupported package selector: {token}") if sep: value = inline_value else: idx += 1 if idx >= len(tokens): raise CheckUsageError(f"package selector {option} requires an argument") value = tokens[idx] result.append(ListFilterOp(op_kind, value)) idx += 1 return result def check_manifest_file( path: pathlib.Path, checks: CheckSet = DEFAULT_CHECKS, ) -> list[CheckDiagnostic]: diagnostics: list[CheckDiagnostic] = [] text = _read_manifest_text(path, diagnostics) if text is None: return diagnostics manifest = _parse_manifest_text(path, text, diagnostics) if manifest is None: return diagnostics if CHECK_PARSE in checks: diagnostics.extend(_check_manifest_parse_surface(path, manifest)) if diagnostics: return diagnostics if CHECK_FORMAT in checks: diagnostics.extend(_check_manifest_format(path, text, manifest)) return diagnostics def check_repo( repo_root: pathlib.Path, checks: CheckSet = DEFAULT_CHECKS, package_selector: ListFilter | None = None, ) -> list[CheckDiagnostic]: diagnostics = check_repo_config(repo_root) manifest_root = _find_manifest_root(repo_root) if manifest_root is None: return diagnostics for path in sorted(p for p in manifest_root.rglob("*") if p.is_file()): rel_path = path.relative_to(manifest_root) parts = rel_path.parts identity = _package_identity_from_manifest_relpath(parts) if ( package_selector is not None and identity is not None and not _package_selector_matches(package_selector, *identity) ): continue path_diagnostic = _check_repo_manifest_path(path, rel_path, parts) if path_diagnostic is not None: diagnostics.append(path_diagnostic) continue diagnostics.extend(check_manifest_file(path, checks)) return diagnostics def check_repo_config(repo_root: pathlib.Path) -> list[CheckDiagnostic]: path = repo_root / "config.toml" try: with open(path, "rb") as fp: obj = tomllib.load(fp) except FileNotFoundError: return [ _diagnostic( "RYC0005", CHECK_PARSE, path, "config.toml is missing", ) ] except tomllib.TOMLDecodeError as exc: return [ _diagnostic( "RYC0005", CHECK_PARSE, path, str(exc), line=getattr(exc, "lineno", None), column=getattr(exc, "colno", None), ) ] except (OSError, UnicodeDecodeError) as exc: return [ _diagnostic( "RYC0005", CHECK_PARSE, path, str(exc), ) ] try: RepoConfig.from_object(obj) except (KeyError, TypeError, ValueError, RuntimeError) as exc: return [ _diagnostic( "RYC0005", CHECK_PARSE, path, _format_manifest_error(exc), ) ] return [] def _read_manifest_text( path: pathlib.Path, diagnostics: list[CheckDiagnostic], ) -> str | None: try: return path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: diagnostics.append( _diagnostic( "RYC0002", CHECK_PARSE, path, str(exc), ) ) return None def _parse_manifest_text( path: pathlib.Path, text: str, diagnostics: list[CheckDiagnostic], ) -> PackageManifest | None: try: doc = tomlkit.loads(text) except ParseError as exc: diagnostics.append( _diagnostic( "RYC0002", CHECK_PARSE, path, str(exc), line=getattr(exc, "line", None), column=getattr(exc, "col", None), ) ) return None except ValueError as exc: diagnostics.append( _diagnostic( "RYC0002", CHECK_PARSE, path, str(exc), ) ) return None try: return PackageManifest(doc) except (KeyError, TypeError, ValueError, RuntimeError) as exc: diagnostics.append( _diagnostic( "RYC0003", CHECK_PARSE, path, _format_manifest_error(exc), ) ) return None def _check_manifest_parse_surface( path: pathlib.Path, manifest: PackageManifest, ) -> list[CheckDiagnostic]: try: _touch_manifest_parse_surface(manifest) except (KeyError, TypeError, ValueError, RuntimeError) as exc: return [ _diagnostic( "RYC0003", CHECK_PARSE, path, _format_manifest_error(exc), ) ] return [] def _check_manifest_format( path: pathlib.Path, text: str, manifest: PackageManifest, ) -> list[CheckDiagnostic]: try: canonical_text = dumps_canonical_package_manifest_toml(manifest) except (KeyError, TypeError, ValueError, RuntimeError) as exc: return [ _diagnostic( "RYC0003", CHECK_FORMAT, path, _format_manifest_error(exc), ) ] if text == canonical_text: return [] return [ _diagnostic( "RYC0001", CHECK_FORMAT, path, "manifest is not canonical", hint=f"run: ruyi admin format-manifest {path}", ) ] def _touch_manifest_parse_surface(manifest: PackageManifest) -> None: manifest.to_raw() manifest.raw_doc manifest.slug manifest.kind manifest.desc manifest.doc_uri manifest.vendor_name manifest.upstream_version service_level = manifest.service_level service_level.level service_level.has_known_issues list(service_level.known_issues) for distfile in manifest.distfiles.values(): distfile.name distfile.urls distfile.size distfile.checksums distfile.prefixes_to_unpack distfile.strip_components distfile.unpack_method distfile.fetch_restriction distfile.get_checksum("sha256") distfile.is_restricted("fetch") distfile.is_restricted("mirror") if binary_metadata := manifest.binary_metadata: binary_metadata.data binary_metadata.is_available_for_current_host for host in binary_metadata.data: binary_metadata.get_distfile_names_for_host(host) binary_metadata.get_commands_for_host(host) if blob_metadata := manifest.blob_metadata: blob_metadata.get_distfile_names() native_host = get_native_host() if source_metadata := manifest.source_metadata: source_metadata.get_distfile_names_for_host(native_host) if toolchain_metadata := manifest.toolchain_metadata: toolchain_metadata.target toolchain_metadata.target_arch toolchain_metadata.quirks toolchain_metadata.has_quirk("default") toolchain_metadata.satisfies_quirk_set(set()) list(toolchain_metadata.components) toolchain_metadata.get_component_version("gcc") toolchain_metadata.has_binutils toolchain_metadata.has_clang toolchain_metadata.has_gcc toolchain_metadata.has_llvm toolchain_metadata.included_sysroot if emulator_metadata := manifest.emulator_metadata: emulator_metadata.quirks for program in emulator_metadata.programs: program.relative_path program.flavor program.supported_arches program.binfmt_misc program.is_qemu list(emulator_metadata.list_for_arch(native_host.arch)) if provisionable_metadata := manifest.provisionable_metadata: provisionable_metadata.partition_map provisionable_metadata.strategy def _find_manifest_root(repo_root: pathlib.Path) -> pathlib.Path | None: for name in ("packages", "manifests"): manifest_root = repo_root / name if manifest_root.is_dir(): return manifest_root return None def _package_identity_from_manifest_relpath( parts: tuple[str, ...], ) -> tuple[str, str] | None: if len(parts) < 2: return None return parts[0], parts[1] def _package_selector_matches( package_selector: ListFilter, category: str, pkg_name: str, ) -> bool: for op in package_selector.ops: match op.op: case ListFilterOpKind.ALL: continue case ListFilterOpKind.CATEGORY_CONTAINS: if op.arg not in category: return False case ListFilterOpKind.CATEGORY_IS: if op.arg != category: return False case ListFilterOpKind.NAME_CONTAINS: if op.arg not in pkg_name: return False case ListFilterOpKind.RELATED_TO_ENTITY | ListFilterOpKind.IS_INSTALLED: raise CheckUsageError( "stateful package selectors are not supported by admin check" ) case _: raise CheckUsageError("unknown package selector") return True def _check_repo_manifest_path( path: pathlib.Path, rel_path: pathlib.Path, parts: tuple[str, ...], ) -> CheckDiagnostic | None: if len(parts) != 3: return _diagnostic( "RYC0004", CHECK_PARSE, path, f"manifest path must be //.toml, got {rel_path}", ) if path.suffix.lower() != ".toml": return _diagnostic( "RYC0004", CHECK_PARSE, path, "manifest file must use the .toml extension", ) version = path.stem try: Version.parse(version) except ValueError as exc: return _diagnostic( "RYC0004", CHECK_PARSE, path, f"manifest filename is not a valid semantic version: {version}: {exc}", ) return None def _diagnostic( code: str, check: CheckName, path: pathlib.Path, message: str, *, line: int | None = None, column: int | None = None, hint: str | None = None, ) -> CheckDiagnostic: return CheckDiagnostic( severity=CheckSeverity.ERROR, code=code, check=check, path=path, message=message, line=line, column=column, hint=hint, ) def _format_manifest_error(exc: BaseException) -> str: if isinstance(exc, KeyError): return f"missing package manifest field: {exc!s}" message = str(exc) return message if message else exc.__class__.__name__ ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/check_cli.py000066400000000000000000000120441520522431500215360ustar00rootroot00000000000000import pathlib from typing import Iterable, NotRequired, Sequence, TYPE_CHECKING from .check import ( CheckDiagnostic, CheckName, CheckSet, CheckSeverity, CheckUsageError, DEFAULT_CHECKS, CHECK_FORMAT, CHECK_PARSE, check_manifest_file, check_repo, ) from .list_filter import ListFilter, ListFilterOp, ListFilterOpKind from ..utils.porcelain import PorcelainEntity, PorcelainOutput if TYPE_CHECKING: from ..config import GlobalConfig class CheckDiagnosticPorcelain(PorcelainEntity, total=False): severity: str code: str check: str path: str message: str line: NotRequired[int] column: NotRequired[int] hint: NotRequired[str] def normalize_checks(raw_checks: Iterable[str] | None) -> CheckSet: if raw_checks is None: return DEFAULT_CHECKS checks: set[CheckName] = set() for raw_check in raw_checks: match raw_check: case "format": checks.add(CHECK_FORMAT) case "parse": checks.add(CHECK_PARSE) case _: raise CheckUsageError(f"unsupported check: {raw_check}") return frozenset(checks) if checks else DEFAULT_CHECKS def parse_package_selector_args(tokens: Sequence[str]) -> ListFilter: if not tokens: raise CheckUsageError("--only-packages requires at least one package selector") result = ListFilter() idx = 0 unary_options = { "--category-contains": ListFilterOpKind.CATEGORY_CONTAINS, "--category-is": ListFilterOpKind.CATEGORY_IS, "--name-contains": ListFilterOpKind.NAME_CONTAINS, } unsupported_options = {"--is-installed", "--related-to-entity"} while idx < len(tokens): token = tokens[idx] option, sep, inline_value = token.partition("=") if option == "--all": if sep: raise CheckUsageError("--all does not take an argument") result.append(ListFilterOp(ListFilterOpKind.ALL, "")) idx += 1 continue if option in unsupported_options: raise CheckUsageError( f"package selector {option} is not supported by admin check" ) op_kind = unary_options.get(option) if op_kind is None: raise CheckUsageError(f"unsupported package selector: {token}") if sep: value = inline_value else: idx += 1 if idx >= len(tokens): raise CheckUsageError(f"package selector {option} requires an argument") value = tokens[idx] result.append(ListFilterOp(op_kind, value)) idx += 1 return result def format_check_diagnostic(diagnostic: CheckDiagnostic) -> str: location = str(diagnostic.path) if diagnostic.line is not None: location += f":{diagnostic.line}" if diagnostic.column is not None: location += f":{diagnostic.column}" result = ( f"{location}: {diagnostic.severity.value} {diagnostic.code}: " f"{diagnostic.message}" ) if diagnostic.hint is not None: result += f"\nhint: {diagnostic.hint}" return result def count_diagnostics( diagnostics: Iterable[CheckDiagnostic], ) -> tuple[int, int]: errors = 0 warnings = 0 for diagnostic in diagnostics: match diagnostic.severity: case CheckSeverity.ERROR: errors += 1 case CheckSeverity.WARNING: warnings += 1 return errors, warnings def format_check_summary(diagnostics: Iterable[CheckDiagnostic]) -> str: errors, warnings = count_diagnostics(diagnostics) return f"{errors} error(s), {warnings} warning(s)" def has_error_diagnostic(diagnostics: Iterable[CheckDiagnostic]) -> bool: return any(diagnostic.severity == CheckSeverity.ERROR for diagnostic in diagnostics) def do_admin_check( cfg: "GlobalConfig", *, files: Sequence[str] | None, repo: str | None, checks: Iterable[str] | None, only_packages: Sequence[str] | None, ) -> int: check_set = normalize_checks(checks) package_selector = ( parse_package_selector_args(only_packages) if only_packages is not None else None ) diagnostics: list[CheckDiagnostic] = [] if repo is not None: diagnostics.extend( check_repo( pathlib.Path(repo), checks=check_set, package_selector=package_selector, ) ) else: if not files: raise CheckUsageError("either --file or --repo must be specified") for file in files: diagnostics.extend(check_manifest_file(pathlib.Path(file), check_set)) if cfg.is_porcelain: with PorcelainOutput() as po: for diagnostic in diagnostics: po.emit(diagnostic.to_porcelain()) else: for diagnostic in diagnostics: cfg.logger.stdout(format_check_diagnostic(diagnostic)) cfg.logger.stdout(format_check_summary(diagnostics)) return 1 if has_error_diagnostic(diagnostics) else 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/checksum.py000066400000000000000000000026531520522431500214410ustar00rootroot00000000000000import hashlib from typing import BinaryIO, Final, Iterable from ..i18n import _ SUPPORTED_CHECKSUM_KINDS: Final = {"sha256", "sha512"} def get_hash_instance(kind: str) -> "hashlib._Hash": if kind not in SUPPORTED_CHECKSUM_KINDS: raise ValueError(_("checksum algorithm {kind} not supported").format(kind=kind)) return hashlib.new(kind) class Checksummer: def __init__(self, file: BinaryIO, checksums: dict[str, str]) -> None: self.file = file self.checksums = checksums def check(self) -> None: computed_csums = self.compute() for kind, expected_csum in self.checksums.items(): if computed_csums[kind] != expected_csum: raise ValueError( _("wrong {kind} checksum: want {want}, got {got}").format( kind=kind, want=expected_csum, got=computed_csums[kind], ) ) def compute( self, kinds: Iterable[str] | None = None, chunksize: int = 4096, ) -> dict[str, str]: if kinds is None: kinds = self.checksums.keys() checksummers = {kind: get_hash_instance(kind) for kind in kinds} while chunk := self.file.read(chunksize): for h in checksummers.values(): h.update(chunk) return {kind: h.hexdigest() for kind, h in checksummers.items()} ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/cli_completion.py000066400000000000000000000031071520522431500226320ustar00rootroot00000000000000from typing import Any, Callable, TYPE_CHECKING if TYPE_CHECKING: from ..cli.completer import DynamicCompleter from ..config import GlobalConfig def repo_id_completer_builder( cfg: "GlobalConfig", ) -> "DynamicCompleter": repo_ids = [entry.id for entry in cfg.repo_entries] def f(prefix: str, parsed_args: object, **kwargs: Any) -> list[str]: return [rid for rid in repo_ids if rid.startswith(prefix)] return f def package_completer_builder( cfg: "GlobalConfig", filters: list[Callable[[str], bool]] | None = None, ) -> "DynamicCompleter": pkg_names: list[str] | None = None def f(prefix: str, parsed_args: object, **kwargs: Any) -> list[str]: nonlocal pkg_names if pkg_names is None: # Lazy import to avoid circular dependency, and lazy repo access so # parser construction for unrelated completions does not sync repos. from ..ruyipkg.augmented_pkg import ( AugmentedPkg, ) # pylint: disable=import-outside-toplevel from ..ruyipkg.list_filter import ( ListFilter, ) # pylint: disable=import-outside-toplevel pkg_names = [] for pkg in AugmentedPkg.yield_from_repo(cfg, cfg.repo, ListFilter()): if pkg.name is None: continue if filters is not None and not all(fn(pkg.name) for fn in filters): continue pkg_names.append(pkg.name) return [name for name in pkg_names if name.startswith(prefix)] return f ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/composite_repo.py000066400000000000000000000334451520522431500226710ustar00rootroot00000000000000from typing import Iterable, TYPE_CHECKING from ..i18n import _ from .entity import EntityStore from .news_store import NewsItemStore from .pkg_manifest import BoundPackageManifest, is_prerelease from .profile import ProfileProxy from .protocols import ProvidesPackageManifests from .repo import MetadataRepo, RepoEntry if TYPE_CHECKING: from ..config import GlobalConfig from ..telemetry.scope import TelemetryScopeConfig from .msg import RepoMessageStore class CompositeRepo(ProvidesPackageManifests): """Aggregates multiple MetadataRepo instances by priority order. Packages from higher-priority repos shadow those from lower-priority repos when they share the same ``(category, name, version)`` tuple.""" def __init__(self, entries: list[RepoEntry], gc: "GlobalConfig") -> None: # Sort by priority ascending; higher priority repos shadow lower. self._entries = sorted(entries, key=lambda e: e.priority) self._gc = gc self._repos: list[MetadataRepo] | None = None # Merged package caches (populated lazily). self._merged_categories: ( dict[str, dict[str, dict[str, BoundPackageManifest]]] | None ) = None self._merged_pkgs: dict[str, dict[str, BoundPackageManifest]] | None = None self._merged_slugs: dict[str, BoundPackageManifest] | None = None def _ensure_repos(self) -> list[MetadataRepo]: if self._repos is not None: return self._repos self._repos = [ entry.make_metadata_repo(self._gc) for entry in self._entries if entry.active ] return self._repos def _ensure_merged_cache(self) -> None: """Build merged package caches across all repos. Iterates repos in ascending priority order so that higher-priority entries overwrite lower-priority ones for the same ``(category, name, version)`` key.""" if self._merged_categories is not None: return categories: dict[str, dict[str, dict[str, BoundPackageManifest]]] = {} pkgs_by_name: dict[str, dict[str, BoundPackageManifest]] = {} slug_cache: dict[str, BoundPackageManifest] = {} # Ascending priority: later repos overwrite earlier for same key. for repo in self._ensure_repos(): for pm in repo.iter_pkg_manifests(): cat_dict = categories.setdefault(pm.category, {}) name_dict = cat_dict.setdefault(pm.name, {}) name_dict[pm.ver] = pm by_name = pkgs_by_name.setdefault(pm.name, {}) by_name[pm.ver] = pm if pm.slug: slug_cache[pm.slug] = pm self._merged_categories = categories self._merged_pkgs = pkgs_by_name self._merged_slugs = slug_cache def iter_repos(self) -> Iterable[MetadataRepo]: """Iterate over all active MetadataRepo instances in priority order (ascending).""" return iter(self._ensure_repos()) # --- sync --- def sync_all(self) -> None: """Sync all active repos.""" repos = self._ensure_repos() for repo in repos: self._gc.logger.I(_("syncing repo '{id}'").format(id=repo.repo_id)) repo.sync() self._validate_repo_identity(repo) self._invalidate_merged_cache() def sync_one(self, repo_id: str) -> None: """Sync a single repo identified by *repo_id*.""" for repo in self._ensure_repos(): if repo.repo_id == repo_id: self._gc.logger.I(_("syncing repo '{id}'").format(id=repo.repo_id)) repo.sync() self._validate_repo_identity(repo) self._invalidate_merged_cache() return raise ValueError(_("no active repo with id '{id}'").format(id=repo_id)) def _validate_repo_identity(self, repo: MetadataRepo) -> None: """Warn if the repo's on-disk config.toml declares an id that does not match the configured RepoEntry.id.""" cfg = repo.maybe_config if cfg is None: return on_disk_id = cfg.repo_id if on_disk_id and on_disk_id != repo.repo_id: self._gc.logger.W( _( "repo '{id}' declares id '{on_disk_id}' in its " "config.toml; expected '{id}'" ).format(id=repo.repo_id, on_disk_id=on_disk_id) ) def _invalidate_merged_cache(self) -> None: """Clear the merged caches so they are rebuilt on the next access.""" self._merged_categories = None self._merged_pkgs = None self._merged_slugs = None # --- ProvidesPackageManifests implementation --- def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]: self._ensure_merged_cache() assert self._merged_categories is not None for cat_pkgs in self._merged_categories.values(): for ver_dict in cat_pkgs.values(): yield from ver_dict.values() def iter_pkgs(self) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: self._ensure_merged_cache() assert self._merged_categories is not None for cat, cat_pkgs in self._merged_categories.items(): for pkg_name, pkg_vers in cat_pkgs.items(): yield (cat, pkg_name, pkg_vers) def get_pkg( self, name: str, category: str, ver: str, ) -> BoundPackageManifest | None: self._ensure_merged_cache() assert self._merged_categories is not None try: return self._merged_categories[category][name][ver] except KeyError: return None def get_pkg_latest_ver( self, name: str, category: str | None = None, include_prerelease_vers: bool = False, ) -> BoundPackageManifest: self._ensure_merged_cache() assert self._merged_categories is not None assert self._merged_pkgs is not None if category is not None: pkgset = self._merged_categories[category] else: pkgset = self._merged_pkgs all_semvers = [pm.semver for pm in pkgset[name].values()] if not include_prerelease_vers: all_semvers = [sv for sv in all_semvers if not is_prerelease(sv)] latest_ver = max(all_semvers) return pkgset[name][str(latest_ver)] def get_pkg_by_slug(self, slug: str) -> BoundPackageManifest | None: self._ensure_merged_cache() assert self._merged_slugs is not None return self._merged_slugs.get(slug) def iter_pkg_vers( self, name: str, category: str | None = None, ) -> Iterable[BoundPackageManifest]: self._ensure_merged_cache() assert self._merged_categories is not None assert self._merged_pkgs is not None if category is not None: return self._merged_categories[category][name].values() return self._merged_pkgs[name].values() # --- Aggregation helpers for MetadataRepo-specific features --- @property def entity_store(self) -> EntityStore: """Combined entity store from all repos. Returns the store from the highest-priority repo. Individual repos' entity providers are already loaded within each MetadataRepo.""" repos = self._ensure_repos() if not repos: return EntityStore(self._gc.logger) return repos[-1].entity_store def news_store(self) -> NewsItemStore: """Aggregated news across all repos. News items from all repos are merged by ID. Items with the same ID from different repos are combined (each language variant is kept). Iteration order is ascending priority so higher-priority repos' language files take precedence for same (id, lang) pair.""" repos = self._ensure_repos() if len(repos) <= 1: if repos: return repos[0].news_store() rs_store = self._gc.news_read_status rs_store.load() merged = NewsItemStore(rs_store) merged.finalize() return merged # Collect all individual news stores, then merge by transferring # parsed items. Higher-priority repos overwrite same (id, lang) # pairs because we iterate in ascending priority order. rs_store = self._gc.news_read_status rs_store.load() merged = NewsItemStore(rs_store) for repo in repos: store = repo.news_store() for ni in store.list(only_unread=False): for nic in ni.langs.values(): merged.add_item(ni.id, nic.metadata, nic.post) merged.finalize() return merged def _get_profile_repo_for_arch(self, arch: str) -> MetadataRepo | None: """Return the highest-priority repo with a loadable profile plugin. Profile plugins are resolved per arch in priority order. The first repo with a loadable ``ruyi-profile-{arch}`` plugin owns the whole arch; lower priority repos are not consulted for the same arch. """ for repo in reversed(self._ensure_repos()): if arch not in repo.get_supported_arches(): continue try: repo.ensure_profile_store_for_arch(arch) except RuntimeError as e: self._gc.logger.D( f"skipping repo '{repo.repo_id}' for profile arch '{arch}': {e}" ) continue except (FileNotFoundError, NotADirectoryError): continue return repo return None def get_profile(self, name: str) -> ProfileProxy | None: """Priority-ordered profile lookup.""" for arch in self.get_supported_arches(): if p := self.get_profile_for_arch(arch, name): return p return None def get_profile_for_arch(self, arch: str, name: str) -> ProfileProxy | None: """Priority-ordered profile lookup for a specific arch.""" repo = self._get_profile_repo_for_arch(arch) if repo is None: return None return repo.get_profile_for_arch(arch, name) def iter_profiles_for_arch(self, arch: str) -> Iterable[ProfileProxy]: """Priority-ordered profile iteration for a specific arch.""" repo = self._get_profile_repo_for_arch(arch) if repo is None: return yield from repo.iter_profiles_for_arch(arch) def get_supported_arches(self) -> list[str]: """Architectures with a loadable profile plugin in priority order.""" arches: list[str] = [] seen: set[str] = set() for repo in reversed(self._ensure_repos()): for arch in repo.get_supported_arches(): if arch in seen: continue try: repo.ensure_profile_store_for_arch(arch) except RuntimeError as e: self._gc.logger.D( f"skipping repo '{repo.repo_id}' for profile arch '{arch}': {e}" ) continue except (FileNotFoundError, NotADirectoryError): continue seen.add(arch) arches.append(arch) return arches def get_from_plugin(self, plugin_id: str, key: str) -> object | None: """Priority-ordered plugin value lookup.""" for repo in reversed(self._ensure_repos()): try: return repo.get_from_plugin(plugin_id, key) except (FileNotFoundError, NotADirectoryError, RuntimeError): continue raise RuntimeError(f"plugin '{plugin_id}' not found in any repo") def eval_plugin_fn( self, function: object, *args: object, **kwargs: object, ) -> object: repos = self._ensure_repos() if not repos: raise RuntimeError("no active repo available for plugin evaluation") return repos[-1].eval_plugin_fn(function, *args, **kwargs) def run_plugin_cmd(self, cmd_name: str, args: list[str]) -> int: """Priority-ordered plugin dispatch.""" # Try highest priority first for repo in reversed(self._ensure_repos()): try: return repo.run_plugin_cmd(cmd_name, args) except RuntimeError: continue raise RuntimeError(f"command plugin '{cmd_name}' not found in any repo") def get_telemetry_api_url( self, scope: "TelemetryScopeConfig", *, repo_id: str | None = None, ) -> str | None: """Return the telemetry API URL for the given *scope*. When *repo_id* is given, only that repo is consulted. Otherwise the first match wins across repos (highest priority first).""" if repo_id is not None: for repo in self._ensure_repos(): if repo.repo_id == repo_id: return repo.get_telemetry_api_url(scope) return None for repo in reversed(self._ensure_repos()): if url := repo.get_telemetry_api_url(scope): return url return None def ensure_git_repo(self) -> None: """Ensure all active repos have their git repos cloned.""" for repo in self._ensure_repos(): repo.ensure_git_repo() @property def repo_id(self) -> str: """Return the repo_id of the highest-priority repo.""" repos = self._ensure_repos() return repos[-1].repo_id if repos else "ruyisdk" @property def messages(self) -> "RepoMessageStore": """Return messages from the highest-priority repo.""" from .msg import RepoMessageStore repos = self._ensure_repos() if repos: return repos[-1].messages return RepoMessageStore.from_object({}) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/distfile.py000066400000000000000000000167101520522431500214410ustar00rootroot00000000000000from functools import cached_property import os from typing import Any, Final from ..i18n import _, d_ from ..log import RuyiLogger from .checksum import Checksummer from .fetcher import BaseFetcher from .pkg_manifest import DistfileDecl from .repo import MetadataRepo from .unpack import do_unpack, do_unpack_or_symlink from .unpack_method import UnpackMethod # https://github.com/ruyisdk/ruyi/issues/46 HELP_ERROR_FETCHING: Final = d_( """ Downloads can fail for a multitude of reasons, most of which should not and cannot be handled by [yellow]Ruyi[/]. For your convenience though, please check if any of the following common failure modes apply to you, and take actions accordingly if one of them turns out to be the case: * Basic connectivity problems - is [yellow]the gateway[/] reachable? - is [yellow]common websites[/] reachable? - is there any [yellow]DNS pollution[/]? * Organizational and/or ISP restrictions - is there a [yellow]firewall[/] preventing Ruyi traffic? - is your [yellow]ISP blocking access[/] to the source website? * Volatile upstream - is the recorded [yellow]link dead[/]? (Please raise a Ruyi issue for a fix!) """ ) class Distfile: def __init__( self, decl: DistfileDecl, mr: MetadataRepo, ) -> None: self._decl = decl self._mr = mr @cached_property def dest(self) -> str: destdir = self._mr.global_config.ensure_distfiles_dir() return os.path.join(destdir, self._decl.name) @property def size(self) -> int: return self._decl.size @property def csums(self) -> dict[str, str]: return self._decl.checksums @property def prefixes_to_unpack(self) -> list[str] | None: return self._decl.prefixes_to_unpack @property def strip_components(self) -> int: return self._decl.strip_components @property def unpack_method(self) -> UnpackMethod: return self._decl.unpack_method @property def is_fetch_restricted(self) -> bool: return self._decl.is_restricted("fetch") @cached_property def urls(self) -> list[str]: return self._mr.get_distfile_urls(self._decl) def render_fetch_instructions(self, logger: RuyiLogger, lang_code: str) -> str: fr = self._decl.fetch_restriction if fr is None: return "" params = { "dest_path": self.dest, } if "params" in fr: for k in params.keys(): # Don't allow package-defined params to override preset params, # to reduce surprises for packagers. if k in fr["params"]: logger.F( _( "malformed package fetch instructions: the param named '{param}' is reserved and cannot be overridden by packages" ).format( param=k, ) ) raise RuntimeError(_("malformed package fetch instructions")) params.update(fr["params"]) return self._mr.messages.render_message(fr["msgid"], lang_code, params) def is_downloaded(self) -> bool: """Check if the distfile has been downloaded. A return value of True does NOT guarantee integrity.""" try: st = os.stat(self.dest) return st.st_size == self.size except FileNotFoundError: return False def ensure(self, logger: RuyiLogger) -> None: logger.D(f"checking {self.dest}") try: st = os.stat(self.dest) except FileNotFoundError: logger.D(f"file {self.dest} not existent") return self.fetch_and_ensure_integrity(logger) if st.st_size < self.size: # assume incomplete transmission, try to resume logger.D( f"file {self.dest} appears incomplete: size {st.st_size} < {self.size}; resuming" ) return self.fetch_and_ensure_integrity(logger, resume=True) elif st.st_size == self.size: if self.ensure_integrity_or_rm(logger): logger.D(f"file {self.dest} passed checks") return # the file is already gone, re-fetch logger.D(f"re-fetching {self.dest}") return self.fetch_and_ensure_integrity(logger) logger.W( _( "file {file} is corrupt: size too big ({actual_size} > {expected_size}); deleting" ).format( file=self.dest, actual_size=st.st_size, expected_size=self.size, ) ) os.remove(self.dest) return self.fetch_and_ensure_integrity(logger) def ensure_integrity_or_rm(self, logger: RuyiLogger) -> bool: try: with open(self.dest, "rb") as fp: cs = Checksummer(fp, self.csums) cs.check() return True except ValueError as e: logger.W( _("file {file} is corrupt: {reason}; deleting").format( file=self.dest, reason=e, ) ) os.remove(self.dest) return False def fetch_and_ensure_integrity( self, logger: RuyiLogger, *, resume: bool = False, ) -> None: if self.is_fetch_restricted: # the file must be re-fetched if we arrive here, but we cannot, # because of the fetch restriction. # # notify the user and die # TODO: allow rendering instructions for all missing fetch-restricted # files at once logger.F( _( "the file [yellow]'{file}'[/] cannot be automatically fetched" ).format( file=self.dest, ) ) logger.I(_("instructions on fetching this file:")) logger.I( self.render_fetch_instructions(logger, self._mr.global_config.lang_code) ) raise SystemExit(1) try: return self._fetch_and_ensure_integrity(logger, resume=resume) except RuntimeError as e: logger.F(f"{e}") logger.stdout(_(HELP_ERROR_FETCHING)) raise SystemExit(1) def _fetch_and_ensure_integrity( self, logger: RuyiLogger, *, resume: bool = False, ) -> None: fetcher = BaseFetcher.new(logger, self.urls, self.dest) fetcher.fetch(resume=resume) if not self.ensure_integrity_or_rm(logger): raise RuntimeError( _("failed to fetch distfile: {file} failed integrity checks").format( file=self.dest, ) ) def unpack( self, root: str | os.PathLike[Any] | None, logger: RuyiLogger, ) -> None: return do_unpack( logger, self.dest, root, self.strip_components, self.unpack_method, prefixes_to_unpack=self.prefixes_to_unpack, ) def unpack_or_symlink( self, root: str | os.PathLike[Any] | None, logger: RuyiLogger, ) -> None: return do_unpack_or_symlink( logger, self.dest, root, self.strip_components, self.unpack_method, prefixes_to_unpack=self.prefixes_to_unpack, ) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/entity.py000066400000000000000000000346501520522431500211550ustar00rootroot00000000000000from typing import Any, Callable, Iterable, Iterator, Mapping import fastjsonschema from fastjsonschema.exceptions import JsonSchemaException from ..i18n import _ from ..log import RuyiLogger from .entity_provider import BaseEntity, BaseEntityProvider, EntityValidationError class EntityStore: def __init__( self, logger: RuyiLogger, *providers: BaseEntityProvider, ) -> None: """Initialize the entity store. Args: logger: The logger to use. providers: A list of entity providers to use for loading entity data. """ self._logger = logger self._providers = providers self._entity_types: set[str] = set() """Cache of entity types discovered.""" self._entities: dict[str, dict[str, BaseEntity]] = {} """Cache of loaded entities by type.""" self._schemas: dict[str, object] = {} """Cache of loaded schemas.""" self._validators: dict[str, Callable[[object], object | None]] = {} """Cache of compiled schema validators.""" self._loaded = False self._discovered = False def _discover_entity_types(self) -> None: """Discover all entity types by examining schemas from all providers.""" if self._discovered: return # Collect schemas from all providers for provider in self._providers: schemas = provider.discover_schemas() # Add new schemas to our cache for entity_type, schema in schemas.items(): if entity_type not in self._schemas: self._schemas[entity_type] = schema self._entity_types.add(entity_type) self._entities[entity_type] = {} self._logger.D(f"discovered entity types from schemas: {self._entity_types}") self._discovered = True def _get_validator(self, entity_type: str) -> Callable[[object], object | None]: """Get or create a compiled schema validator for the entity type.""" if entity_type in self._validators: return self._validators[entity_type] schema = self._schemas.get(entity_type) if not schema: self._logger.W( _("no schema found for entity type: {entity_type}").format( entity_type=entity_type, ) ) # Return a simple validator that accepts anything return lambda x: x try: validator = fastjsonschema.compile(schema) self._validators[entity_type] = validator return validator except Exception as e: self._logger.W( _("failed to compile schema for {entity_type}: {reason}").format( entity_type=entity_type, reason=e, ) ) # Return a simple validator that accepts anything return lambda x: x def _validate_entity( self, entity_type: str, entity_id: str, data: Mapping[str, Any], ) -> None: """Validate an entity against its schema.""" validator = self._get_validator(entity_type) try: validator(data) except JsonSchemaException as e: raise EntityValidationError(entity_type, entity_id, e) from e def load_all(self, validate: bool = True) -> None: """Load all entities from all providers.""" if self._loaded: return # Discover entity types from schemas if not already done self._discover_entity_types() # Load entities from all providers for provider in self._providers: provider_entities = provider.load_entities(list(self._entity_types)) # Merge entities from this provider with our cache for entity_type, entities_by_id in provider_entities.items(): for entity_id, entity_data in entities_by_id.items(): # Validate entity data if validate: self._validate_entity(entity_type, entity_id, entity_data) # Create and store a generic entity self._entities[entity_type][entity_id] = BaseEntity( entity_type, entity_id, entity_data, ) self._loaded = True # Populate reverse references # This must happen after the loaded flag is set, because the getter # is lazy and will infinitely recurse otherwise. for entity_type, entities in self._entities.items(): for entity_id, entity in entities.items(): # Collect reverse references for ref in entity.related_refs: if related_entity := self.get_entity_by_ref(ref): related_entity._add_reverse_ref(str(entity)) entity_counts = {t: len(entities) for t, entities in self._entities.items()} self._logger.D(f"count of loaded entities: {entity_counts}") def get_entity_types(self) -> Iterator[str]: """Get all available entity types from the schemas.""" self._discover_entity_types() yield from self._entity_types def get_entity(self, entity_type: str, entity_id: str) -> BaseEntity | None: """Get an entity by type and ID.""" self.load_all() return self._entities.get(entity_type, {}).get(entity_id) def iter_entities( self, entity_type: str | Iterable[str] | None, ) -> Iterator[BaseEntity]: """Iterate over all entities of a specific type, or all entities.""" self.load_all() if entity_type is not None: if isinstance(entity_type, str): yield from self._entities.get(entity_type, {}).values() return # handle multiple entity types for et in entity_type: yield from self._entities.get(et, {}).values() return for entities in self._entities.values(): yield from entities.values() def get_entity_by_ref(self, ref: str) -> BaseEntity | None: """Resolve an entity reference of the form ``type:id``.""" if ":" not in ref: raise ValueError(f"Invalid entity reference: {ref}") entity_type, entity_id = ref.split(":", 1) self.load_all() return self.get_entity(entity_type, entity_id) def list_related_entities( self, entity: BaseEntity | str, reverse_refs: bool = False, ) -> list[BaseEntity]: """Get all directly related entities of the given entity. Args: entity: The entity whose related entities to retrieve, or an entity reference in the form ``type:id``. reverse_refs: If True, return reverse references instead of forward references. Returns: A list of directly related entities """ if isinstance(entity, str): e = self.get_entity_by_ref(entity) if e is None: raise ValueError(f"Entity not found: {entity}") entity = e related_entities = [] for ref in entity.reverse_refs if reverse_refs else entity.related_refs: related_entity = self.get_entity_by_ref(ref) if related_entity: related_entities.append(related_entity) return related_entities def traverse_related_entities( self, entity: BaseEntity | str, transitive: bool = False, no_direct_refs: bool = False, forward_refs: bool = True, reverse_refs: bool = False, entity_types: list[str] | None = None, ) -> Iterator[BaseEntity]: """Traverse related entities of the given entity. Args: entity: The starting entity or reference (in the form ``type:id``). transitive: If True, traverse the transitive closure of related entities. If False, only traverse direct related entities. no_direct_refs: If True, skip direct references. forward_refs: If True, traverse forward references. reverse_refs: If True, traverse reverse references. entity_types: Optional list of entity types to filter by. If provided, only entities of the specified types will be yielded. Returns: An iterator over the related entities """ if isinstance(entity, str): # If a string is provided, resolve it to an entity e = self.get_entity_by_ref(entity) if e is None: raise ValueError(f"Entity not found: {entity}") entity = e # Set to track visited entities and avoid cycles visited = set() # Helper function for recursive traversal def _traverse( current_entity: BaseEntity, path: list[BaseEntity], ) -> Iterator[BaseEntity]: # Skip if already visited (prevents cycles) if current_entity in visited: return # Enforce uniqueness-among-type if current_entity.unique_among_type_during_traversal: for e in path: if e.entity_type == current_entity.entity_type: return depth = len(path) # Do not yield related entities if either: # - we're the root entity (depth == 0) # - no_direct_refs is True and we're at depth == 1 skip_current_level = depth == 0 or (no_direct_refs and depth == 1) # Check if this entity matches the desired type filter entity_type_okay = ( entity_types is None or current_entity.entity_type in entity_types ) if not skip_current_level and entity_type_okay: yield current_entity # Mark as visited visited.add(current_entity) new_path = path.copy() new_path.append(current_entity) # Process forward edges if requested if forward_refs: for related_entity in self.list_related_entities( current_entity, reverse_refs=False, ): # Recursively traverse if transitive mode is enabled # or if we're at the root entity if depth == 0 or transitive: yield from _traverse(related_entity, new_path) # Process reverse edges if requested if reverse_refs: for related_entity in self.list_related_entities( current_entity, reverse_refs=True, ): # Recursively traverse if transitive mode is enabled # or if we're at the root entity if depth == 0 or transitive: yield from _traverse(related_entity, new_path) # Start traversal from the given entity yield from _traverse(entity, []) def is_entity_related_to( self, entity: BaseEntity | str, related_entity: BaseEntity | str, transitive: bool = False, unidirectional: bool = True, not_found_ok: bool = True, ) -> bool: """Check if the given entity is related to another entity. Args: entity: The starting entity or reference (in the form ``type:id``). related_entity: The related entity or reference (in the form ``type:id``). transitive: If True, check for transitive relationships. unidirectional: If True, entities are considered related if and only if the relationship chain consists of forward or reverse edges only. not_found_ok: If True, return False if either entity is not found. If False, raise an error if either entity is not found. Returns: True if the entities are related, False otherwise. """ if isinstance(entity, str): e = self.get_entity_by_ref(entity) if e is None: if not_found_ok: return False raise ValueError(f"Entity not found: {entity}") entity = e if isinstance(related_entity, str): re = self.get_entity_by_ref(related_entity) if re is None: if not_found_ok: return False raise ValueError(f"Entity not found: {related_entity}") related_entity = re # Check if the two entities are directly related if related_entity in self.list_related_entities(entity): return True if related_entity in self.list_related_entities(entity, reverse_refs=True): return True # If transitive mode is enabled, check for indirect relationships if transitive: if unidirectional: for e in self.traverse_related_entities( entity, forward_refs=True, reverse_refs=False, transitive=True, ): if related_entity in self.list_related_entities( e, reverse_refs=False, ): return True for e in self.traverse_related_entities( entity, forward_refs=False, reverse_refs=True, transitive=True, ): if related_entity in self.list_related_entities( e, reverse_refs=True, ): return True else: for e in self.traverse_related_entities( entity, forward_refs=True, reverse_refs=True, transitive=True, ): if related_entity in self.list_related_entities( e, reverse_refs=False, ): return True if related_entity in self.list_related_entities( e, reverse_refs=True, ): return True return False ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/entity_cli.py000066400000000000000000000076751520522431500220130ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class EntityCommand( RootCommand, cmd="entity", has_subcommands=True, is_experimental=True, help=_("Interact with entities defined in the repositories"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass class EntityDescribeCommand( EntityCommand, cmd="describe", help=_("Describe an entity"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "ref", help=_( "Reference to the entity to describe in the form of ':'" ), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: logger = cfg.logger ref = args.ref entity_store = cfg.repo.entity_store entity = entity_store.get_entity_by_ref(ref) if entity is None: logger.F(_("entity [yellow]{ref}[/] not found").format(ref=ref)) return 1 logger.stdout( _("Entity [bold]{entity}[/] ([green]{display_name}[/])\n").format( entity=str(entity), display_name=entity.display_name, ) ) fwd_refs = entity.related_refs if fwd_refs: logger.stdout(_(" Direct forward relationships:")) for ref in sorted(fwd_refs): logger.stdout(f" - [yellow]{ref}[/]") else: logger.stdout(_(" Direct forward relationships: [gray]none[/]")) rev_refs = entity.reverse_refs if rev_refs: logger.stdout(_(" Direct reverse relationships:")) for ref in sorted(rev_refs): logger.stdout(f" - [yellow]{ref}[/]") else: logger.stdout(_(" Direct reverse relationships: [gray]none[/]")) logger.stdout(_(" All indirectly related entities:")) for e in entity_store.traverse_related_entities( entity, transitive=True, no_direct_refs=True, forward_refs=True, reverse_refs=True, ): logger.stdout(f" - [yellow]{e}[/]") # TODO: render type-specific data return 0 class EntityListCommand( EntityCommand, cmd="list", help=_("List entities"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "-t", "--entity-type", action="append", nargs=1, dest="entity_type", help=_( "List entities of this type. Can be passed multiple times to list multiple types." ), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ..utils.porcelain import PorcelainOutput entity_types_in: list[list[str]] | None = args.entity_type entity_types: list[str] | None = None if entity_types_in is not None: entity_types = [x[0] for x in entity_types_in] logger = cfg.logger entity_store = cfg.repo.entity_store # Check if porcelain output is requested if cfg.is_porcelain: with PorcelainOutput() as po: for e in entity_store.iter_entities(entity_types): po.emit(e.to_porcelain()) return 0 # Human-readable output for e in entity_store.iter_entities(entity_types): logger.stdout(f"'{str(e)}':") logger.stdout(f" display name: {e.display_name}") logger.stdout(f" data: {e.data}") logger.stdout(f" forward_refs: {e.related_refs}") logger.stdout(f" reverse_refs: {e.reverse_refs}") return 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/entity_provider.py000066400000000000000000000213501520522431500230600ustar00rootroot00000000000000import abc import json import os import pathlib import tomllib from typing import Any, Mapping, Sequence from ..i18n import _ from ..log import RuyiLogger from ..utils.porcelain import PorcelainEntity, PorcelainEntityType class PorcelainEntityListOutputV1(PorcelainEntity): entity_type: str entity_id: str display_name: str | None data: Mapping[str, Any] related_refs: list[str] reverse_refs: list[str] class EntityError(Exception): """Base exception for entity-related errors.""" pass class EntityValidationError(EntityError): """Exception raised when an entity fails validation.""" def __init__(self, entity_type: str, entity_id: str, cause: Exception) -> None: self.entity_type = entity_type self.entity_id = entity_id self.cause = cause message = ( f"Entity validation failed for entity '{entity_type}:{entity_id}': {cause}" ) super().__init__(message) class BaseEntity: """Base class for all entity types.""" def __init__( self, entity_type: str, entity_id: str, data: Mapping[str, Any], ) -> None: self._entity_type = entity_type self._id = entity_id self._data = data self._reverse_refs: set[str] = set() @property def entity_type(self) -> str: """Type of the entity.""" return self._entity_type @property def id(self) -> str: """ID of the entity.""" return self._id @property def display_name(self) -> str | None: """Human-readable name of the entity.""" result = self._data[self.entity_type].get("display_name", None) if result is None or isinstance(result, str): return result # return None if type is unexpected return None @property def unique_among_type_during_traversal(self) -> bool: """Whether the entity should be unique among all entities of the same type during traversal. For example, if the entity is ``arch:foo64`` and there is also ``arch:foo32``, with this property set to ``True`` on each, there will be only one ``arch:foo*`` entity in any traversal path involving them, so that a hypothetical traversal starting from a "foo64" device will not return entities only related to the "foo32" architecture. """ if r := self._data.get("unique_among_type_during_traversal", None): if isinstance(r, bool): return r # return False if type is unexpected return False @property def data(self) -> Any: """Raw data of the entity.""" return self._data[self.entity_type] @property def related_refs(self) -> list[str]: """Get the list of related entity references.""" if r := self._data.get("related"): if isinstance(r, list): return r # return empty list if that is the case, or if the type is unexpected return [] @property def reverse_refs(self) -> list[str]: """Get the list of reverse-related entity references.""" return list(self._reverse_refs) def _add_reverse_ref(self, ref: str) -> None: self._reverse_refs.add(ref) def to_porcelain(self) -> PorcelainEntityListOutputV1: """Convert this entity to porcelain output format.""" return { "ty": PorcelainEntityType.EntityListOutputV1, "entity_type": self.entity_type, "entity_id": self.id, "display_name": self.display_name, "data": self._data, "related_refs": self.related_refs, "reverse_refs": self.reverse_refs, } def __str__(self) -> str: return f"{self.entity_type}:{self.id}" def __hash__(self) -> int: return hash((self.entity_type, self.id)) def __eq__(self, other: object) -> bool: if not isinstance(other, BaseEntity): return NotImplemented return self.entity_type == other.entity_type and self.id == other.id class BaseEntityProvider(abc.ABC): """Abstract base class for entity data providers. Entity providers are responsible for discovering and loading entity schemas and data. """ @abc.abstractmethod def discover_schemas(self) -> dict[str, object]: """Discover available entity schemas. Returns: A dictionary mapping entity types to their schema objects """ raise NotImplementedError @abc.abstractmethod def load_entities( self, entity_types: Sequence[str], ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: """Load entities of the given types. Args: entity_types: Sequence of entity types to load Returns: A nested dictionary mapping entity types to entity IDs to raw entity data """ raise NotImplementedError class FSEntityProvider(BaseEntityProvider): """Entity provider that loads entity data from the filesystem. This provider reads schemas from the ``_schemas`` directory and entity data from subdirectories organized by entity type. """ def __init__(self, logger: RuyiLogger, entities_root: os.PathLike[Any]) -> None: """Initialize the filesystem-based entity provider. Args: logger: Logger instance to use. entities_root: Path to the root directory containing entity data. The ``_schemas`` directory should be a subdirectory of this path. """ self._logger = logger self._entities_root = pathlib.Path(entities_root) self._schemas_root = self._entities_root / "_schemas" def discover_schemas(self) -> dict[str, object]: """Discover entity schemas from the filesystem. Returns: A dictionary mapping entity types to their schema objects """ schemas: dict[str, object] = {} if not os.path.isdir(self._schemas_root): self._logger.D(f"entity schemas directory not found: {self._schemas_root}") return schemas try: schema_files = list(self._schemas_root.glob("*.jsonschema")) except IOError as e: self._logger.W( _("failed to access entity schemas directory {dir}: {reason}").format( dir=self._schemas_root, reason=e, ) ) return schemas for p in schema_files: # Extract entity type from schema filename (remove .jsonschema extension) entity_type = p.name[:-11] # 11 is the length of ".jsonschema" try: with open(p, "r", encoding="utf-8") as f: schema = json.load(f) except (IOError, json.JSONDecodeError) as e: self._logger.D( f"failed to load schema for entity type '{entity_type}': {e}" ) continue # Cache the schema schemas[entity_type] = schema self._logger.D(f"discovered entity types from schemas: {list(schemas.keys())}") return schemas def load_entities( self, entity_types: Sequence[str], ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: """Load entity data from the filesystem. Args: entity_types: Set of entity types to load Returns: A nested dictionary mapping entity types to entity IDs to raw entity data """ entities: dict[str, dict[str, dict[str, Any]]] = { entity_type: {} for entity_type in entity_types } for entity_type in entity_types: type_dir = self._entities_root / entity_type if not type_dir.exists(): self._logger.D(f"entity type directory does not exist: {type_dir}") continue for file_path in type_dir.glob("*.toml"): try: with open(file_path, "rb") as f: data = tomllib.load(f) except (IOError, tomllib.TOMLDecodeError) as e: self._logger.W( _("failed to load entity from {path}: {reason}").format( path=file_path, reason=e, ) ) continue # Extract entity ID from filename (remove .toml extension) entity_id = file_path.name[:-5] # Create and store raw entity data entities[entity_type][entity_id] = data entity_counts = {t: len(e) for t, e in entities.items()} self._logger.D(f"count of loaded entities from filesystem: {entity_counts}") return entities ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/fetcher.py000066400000000000000000000231131520522431500212510ustar00rootroot00000000000000import abc import mmap import os import subprocess from typing import Any, Final import requests from rich import progress from ..i18n import _ from ..log import RuyiLogger ENV_OVERRIDE_FETCHER: Final = "RUYI_OVERRIDE_FETCHER" def _is_url_ftp(url: str) -> bool: return url.lower().startswith("ftp://") class BaseFetcher: def __init__(self, logger: RuyiLogger, urls: list[str], dest: str) -> None: self._logger = logger self.urls = urls self.dest = dest @classmethod @abc.abstractmethod def is_available(cls, logger: RuyiLogger) -> bool: return False @abc.abstractmethod def fetch_one(self, url: str, dest: str, resume: bool) -> bool: return False def fetch_one_with_retry( self, url: str, dest: str, resume: bool, retries: int, ) -> bool: for t in range(retries): if t > 0: self._logger.I( _("retrying download ({current} of {total} times)").format( current=t + 1, total=retries, ) ) if self.fetch_one(url, dest, resume): return True return False def fetch(self, *, resume: bool = False, retries: int = 3) -> None: for url in self.urls: self._logger.I( _("downloading {url} to {dest}").format( url=url, dest=self.dest, ) ) if self.fetch_one_with_retry(url, self.dest, resume, retries): return # all URLs have been tried and all have failed raise RuntimeError( _("failed to fetch '{dest}': all source URLs have failed").format( dest=self.dest, ) ) @classmethod def new(cls, logger: RuyiLogger, urls: list[str], dest: str) -> "BaseFetcher": return get_usable_fetcher_cls(logger)(logger, urls, dest) KNOWN_FETCHERS: Final[dict[str, type[BaseFetcher]]] = {} def register_fetcher(name: str, f: type[BaseFetcher]) -> None: # NOTE: can add priority support if needed KNOWN_FETCHERS[name] = f _fetcher_cache_populated: bool = False _cached_usable_fetcher_class: type[BaseFetcher] | None = None def get_usable_fetcher_cls(logger: RuyiLogger) -> type[BaseFetcher]: global _fetcher_cache_populated global _cached_usable_fetcher_class if _fetcher_cache_populated: if _cached_usable_fetcher_class is None: raise RuntimeError(_("no fetcher is available on the system")) return _cached_usable_fetcher_class _fetcher_cache_populated = True if override_name := os.environ.get(ENV_OVERRIDE_FETCHER): logger.D(f"forcing fetcher '{override_name}'") cls = KNOWN_FETCHERS.get(override_name) if cls is None: raise RuntimeError( _("unknown fetcher '{name}'").format( name=override_name, ) ) if not cls.is_available(logger): raise RuntimeError( _("the requested fetcher '{name}' is unavailable on the system").format( name=override_name, ) ) _cached_usable_fetcher_class = cls return cls for name, cls in KNOWN_FETCHERS.items(): if not cls.is_available(logger): logger.D(f"fetcher '{name}' is unavailable") continue _cached_usable_fetcher_class = cls return cls raise RuntimeError(_("no fetcher is available on the system")) class CurlFetcher(BaseFetcher): def __init__(self, logger: RuyiLogger, urls: list[str], dest: str) -> None: super().__init__(logger, urls, dest) @classmethod def is_available(cls, logger: RuyiLogger) -> bool: # try running "curl --version" and it should succeed try: retcode = subprocess.call(["curl", "--version"], stdout=subprocess.DEVNULL) return retcode == 0 except Exception as e: logger.D("exception occurred when trying to curl --version:", e) return False def fetch_one(self, url: str, dest: str, resume: bool) -> bool: argv = ["curl"] if resume: argv.extend(("-C", "-")) # A bug in curl 8.14.1 (and only that version) broke the recognition of # the `--ftp-pasv`` flag, and unfortunately this version is currently # provided by some popular distros so far. # # So, for the vast majority of non-FTP downloads to work even with # this buggy version, we simply do not pass the flag if the URL is # not an FTP one. # # See: https://github.com/curl/curl/issues/17545 # See: https://github.com/ruyisdk/ruyi/issues/316 if _is_url_ftp(url): argv.append("--ftp-pasv") argv.extend( ( "-L", "--connect-timeout", "60", "-o", dest, url, ) ) retcode = subprocess.call(argv) if retcode != 0: self._logger.W( _( "failed to fetch distfile: command '{cmd}' returned {retcode}" ).format( cmd=" ".join(argv), retcode=retcode, ) ) return False return True register_fetcher("curl", CurlFetcher) class WgetFetcher(BaseFetcher): def __init__(self, logger: RuyiLogger, urls: list[str], dest: str) -> None: super().__init__(logger, urls, dest) @classmethod def is_available(cls, logger: RuyiLogger) -> bool: # try running "wget --version" and it should succeed try: retcode = subprocess.call(["wget", "--version"], stdout=subprocess.DEVNULL) return retcode == 0 except Exception as e: logger.D("exception occurred when trying to wget --version:", e) return False def fetch_one(self, url: str, dest: str, resume: bool) -> bool: # These arguments are taken from Gentoo argv = ["wget"] if resume: argv.append("-c") # wget does not suffer from the same bug as curl, but to be safe, we # also enable the passive FTP mode only if the URL is an FTP one. if _is_url_ftp(url): argv.append("--passive-ftp") argv.extend(("-T", "60", "-O", dest, url)) retcode = subprocess.call(argv) if retcode != 0: self._logger.W( _( "failed to fetch distfile: command '{cmd}' returned {retcode}" ).format( cmd=" ".join(argv), retcode=retcode, ) ) return False return True register_fetcher("wget", WgetFetcher) class PythonRequestsFetcher(BaseFetcher): def __init__(self, logger: RuyiLogger, urls: list[str], dest: str) -> None: super().__init__(logger, urls, dest) self.chunk_size = 4 * mmap.PAGESIZE # TODO: User-Agent @classmethod def is_available(cls, logger: RuyiLogger) -> bool: return True def fetch_one(self, url: str, dest: str, resume: bool) -> bool: self._logger.D(f"downloading [cyan]{url}[/] to [cyan]{dest}") open_mode = "ab" if resume else "wb" start_from = 0 headers: dict[str, str] = {} if resume: filesize = os.stat(dest).st_size self._logger.D(f"resuming from position {filesize}") start_from = filesize headers["Range"] = f"bytes={filesize}-" r = requests.get(url, headers=headers, stream=True) total_len: int | None = None if total_len_str := r.headers.get("Content-Length"): total_len = int(total_len_str) + start_from try: trc = progress.TimeRemainingColumn(compact=True, elapsed_when_finished=True) # type: ignore[call-arg,unused-ignore] except TypeError: # rich < 12.0.0 does not support the styles we're asking here, so # just downgrade UX in favor of basic usability in that case. # # see https://github.com/Textualize/rich/pull/1992 trc = progress.TimeRemainingColumn() columns = ( progress.SpinnerColumn(), progress.BarColumn(), progress.DownloadColumn(), progress.TransferSpeedColumn(), trc, ) dest_filename = os.path.basename(dest) with open(dest, open_mode) as f: with progress.Progress(*columns, console=self._logger.log_console) as pg: indeterminate = total_len is None kwargs: dict[str, Any] if indeterminate: # be compatible with rich <= 12.3.0 where add_task()'s `total` # parameter cannot be None # see https://github.com/Textualize/rich/commit/052b15785876ad85 kwargs = {"start": False} else: kwargs = {"total": total_len} task = pg.add_task(dest_filename, completed=start_from, **kwargs) for chunk in r.iter_content(self.chunk_size): f.write(chunk) # according to the docs it's probably not okay to pulse the # progress bar if the total number of steps is not yet known if not indeterminate: pg.advance(task, len(chunk)) return True register_fetcher("requests", PythonRequestsFetcher) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/host.py000066400000000000000000000025671520522431500206200ustar00rootroot00000000000000import platform import sys from typing import NamedTuple class RuyiHost(NamedTuple): os: str arch: str def __str__(self) -> str: return f"{self.os}/{self.arch}" def canonicalize(self) -> "RuyiHost": return RuyiHost( os=canonicalize_os_str(self.os), arch=canonicalize_arch_str(self.arch), ) def canonicalize_host_str(host: str | RuyiHost) -> str: if isinstance(host, str): frags = host.split("/", 1) os = "linux" if len(frags) == 1 else frags[0] arch = frags[0] if len(frags) == 1 else frags[1] return str(RuyiHost(os, arch).canonicalize()) return str(host.canonicalize()) def canonicalize_arch_str(arch: str) -> str: # Information sources: # # * https://bugs.python.org/issue7146#msg94134 # * https://superuser.com/questions/305901/possible-values-of-processor-architecture match arch.lower(): case "amd64" | "em64t": return "x86_64" case "arm64": return "aarch64" case "x86": return "i686" case arch_lower: return arch_lower def canonicalize_os_str(os: str) -> str: match os: case "win32": return "windows" case _: return os def get_native_host() -> RuyiHost: return RuyiHost(os=sys.platform, arch=platform.machine()).canonicalize() ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/install.py000066400000000000000000000503171520522431500213050ustar00rootroot00000000000000import os import pathlib import shutil import tempfile from typing import Any from ruyi.ruyipkg.state import BoundInstallationStateStore from ..cli.user_input import ask_for_yesno_confirmation from ..config import GlobalConfig from ..i18n import _ from ..telemetry.scope import TelemetryScope from .atom import Atom from .distfile import Distfile from .host import RuyiHost from .pkg_manifest import BoundPackageManifest from .composite_repo import CompositeRepo from .unpack import ensure_unpack_cmd_for_method def is_root_likely_populated(root: str) -> bool: try: return any(os.scandir(root)) except FileNotFoundError: return False def do_extract_atoms( cfg: GlobalConfig, mr: CompositeRepo, atom_strs: set[str], *, canonicalized_host: str | RuyiHost, dest_dir: os.PathLike[Any] | None, # None for CWD extract_without_subdir: bool, fetch_only: bool, ) -> int: logger = cfg.logger logger.D(f"about to extract for host {canonicalized_host}: {atom_strs}") mr = cfg.repo for a_str in atom_strs: a = Atom.parse(a_str) pm = a.match_in_repo(mr, cfg.include_prereleases) if pm is None: logger.F( _("atom {atom} matches no package in the repository").format( atom=a_str, ) ) return 1 sv = pm.service_level if sv.has_known_issues: logger.W(_("package has known issue(s)")) for s in sv.render_known_issues(pm.repo.messages, cfg.lang_code): logger.I(s) ret = _do_extract_pkg( cfg, pm, canonicalized_host=canonicalized_host, fetch_only=fetch_only, dest_dir=dest_dir, extract_without_subdir=extract_without_subdir, ) if ret != 0: return ret return 0 def _do_extract_pkg( cfg: GlobalConfig, pm: BoundPackageManifest, *, canonicalized_host: str | RuyiHost, dest_dir: os.PathLike[Any] | None, # None for CWD extract_without_subdir: bool, fetch_only: bool, ) -> int: logger = cfg.logger pkg_name = pm.name_for_installation if not extract_without_subdir: # extract into a subdirectory named - subdir_name = pm.name_for_installation if dest_dir is None: dest_dir = pathlib.Path(subdir_name) else: dest_dir = pathlib.Path(dest_dir) / subdir_name logger.D(f"about to extract {pm} to {dest_dir}") # Make sure destination directory exists if dest_dir is not None: dest_dir = pathlib.Path(dest_dir) dest_dir.mkdir(parents=True, exist_ok=True) bm = pm.binary_metadata sm = pm.source_metadata if bm is None and sm is None: logger.F( _("don't know how to extract package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 2 if bm is not None and sm is not None: logger.F( _( "cannot handle package [green]{pkg}[/]: package is both binary and source" ).format( pkg=pkg_name, ) ) return 2 distfiles_for_host: list[str] | None = None if bm is not None: distfiles_for_host = bm.get_distfile_names_for_host(canonicalized_host) elif sm is not None: distfiles_for_host = sm.get_distfile_names_for_host(canonicalized_host) if not distfiles_for_host: logger.F( _("package [green]{pkg}[/] declares no distfile for host {host}").format( pkg=pkg_name, host=canonicalized_host, ) ) return 2 dfs = pm.distfiles for df_name in distfiles_for_host: df_decl = dfs[df_name] ensure_unpack_cmd_for_method(logger, df_decl.unpack_method) df = Distfile(df_decl, pm.repo) df.ensure(logger) if fetch_only: logger.D("skipping extraction because [yellow]--fetch-only[/] is given") continue logger.I( _("extracting [green]{distfile}[/] for package [green]{pkg}[/]").format( distfile=df_name, pkg=pkg_name, ) ) # unpack into destination df.unpack(dest_dir, logger) if not fetch_only: # None is the unpack helper's CWD sentinel; report it as a real path. reported_dest_dir = pathlib.Path(".") if dest_dir is None else dest_dir logger.I( _("package [green]{pkg}[/] has been extracted to {dest_dir}").format( pkg=pkg_name, dest_dir=reported_dest_dir, ) ) return 0 def do_install_atoms( config: GlobalConfig, mr: CompositeRepo, atom_strs: set[str], *, canonicalized_host: str | RuyiHost, fetch_only: bool, reinstall: bool, ) -> int: logger = config.logger logger.D(f"about to install for host {canonicalized_host}: {atom_strs}") for a_str in atom_strs: a = Atom.parse(a_str) pm = a.match_in_repo(mr, config.include_prereleases) if pm is None: logger.F( _("atom {atom} matches no package in the repository").format(atom=a_str) ) return 1 pkg_name = pm.name_for_installation sv = pm.service_level if sv.has_known_issues: logger.W(_("package has known issue(s)")) for s in sv.render_known_issues(pm.repo.messages, config.lang_code): logger.I(s) config.telemetry.record( TelemetryScope(mr.repo_id), "repo:package-install-v1", atom=a_str, host=canonicalized_host, pkg_category=pm.category, pkg_kinds=pm.kind, pkg_name=pm.name, pkg_version=pm.ver, ) if pm.binary_metadata is not None: ret = _do_install_binary_pkg( config, mr, pm, canonicalized_host, fetch_only, reinstall, ) if ret != 0: return ret continue if pm.blob_metadata is not None: ret = _do_install_blob_pkg(config, mr, pm, fetch_only, reinstall) if ret != 0: return ret continue # the user may be trying to fetch a source-only package with `ruyi install --fetch-only`, # so try that too for better UX if fetch_only and pm.source_metadata is not None: ret = _do_extract_pkg( config, pm, canonicalized_host=canonicalized_host, dest_dir=None, # unused in this case extract_without_subdir=False, # unused in this case fetch_only=fetch_only, ) if ret != 0: return ret continue logger.F( _("don't know how to handle non-binary package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 2 return 0 def _do_install_binary_pkg( config: GlobalConfig, mr: CompositeRepo, pm: BoundPackageManifest, canonicalized_host: str | RuyiHost, fetch_only: bool, reinstall: bool, ) -> int: logger = config.logger bm = pm.binary_metadata assert bm is not None pkg_name = pm.name_for_installation install_root = config.global_binary_install_root(str(canonicalized_host), pkg_name) rgs = config.ruyipkg_global_state is_installed = rgs.is_package_installed( pm.repo_id, pm.category, pm.name, pm.ver, str(canonicalized_host), ) # Fallback to directory check if not tracked in state if not is_installed and is_root_likely_populated(install_root): is_installed = True if is_installed: if not reinstall: logger.I( _("skipping already installed package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 0 logger.W( _( "package [green]{pkg}[/] seems already installed; purging and re-installing due to [yellow]--reinstall[/]" ).format(pkg=pkg_name) ) # Remove from state tracking before purging rgs.remove_installation( pm.repo_id, pm.category, pm.name, pm.ver, str(canonicalized_host), ) shutil.rmtree(install_root) ir_parent = pathlib.Path(install_root).resolve().parent ir_parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".ruyi-tmp", dir=ir_parent) as tmp_root: ret = _do_install_binary_pkg_to( config, mr, pm, canonicalized_host, fetch_only, tmp_root, ) if ret != 0: return ret os.rename(tmp_root, install_root) if not fetch_only: rgs.record_installation( repo_id=pm.repo_id, category=pm.category, name=pm.name, version=pm.ver, host=str(canonicalized_host), install_path=install_root, ) repo_tag = f" [dim]\\[{pm.repo_id}][/]" if len(config.repo_entries) > 1 else "" logger.I( _( "package [green]{pkg}[/]{repo_tag} installed to [yellow]{install_root}[/]" ).format( pkg=pkg_name, repo_tag=repo_tag, install_root=install_root, ) ) return 0 def _do_install_binary_pkg_to( config: GlobalConfig, mr: CompositeRepo, pm: BoundPackageManifest, canonicalized_host: str | RuyiHost, fetch_only: bool, install_root: str, ) -> int: logger = config.logger bm = pm.binary_metadata assert bm is not None dfs = pm.distfiles pkg_name = pm.name_for_installation distfiles_for_host = bm.get_distfile_names_for_host(str(canonicalized_host)) if not distfiles_for_host: logger.F( _("package [green]{pkg}[/] declares no binary for host {host}").format( pkg=pkg_name, host=canonicalized_host, ) ) return 2 for df_name in distfiles_for_host: df_decl = dfs[df_name] ensure_unpack_cmd_for_method(logger, df_decl.unpack_method) df = Distfile(df_decl, pm.repo) df.ensure(logger) if fetch_only: logger.D( _("skipping installation because [yellow]--fetch-only[/] is given") ) continue logger.I( _("extracting [green]{distfile}[/] for package [green]{pkg}[/]").format( distfile=df_name, pkg=pkg_name, ) ) df.unpack(install_root, logger) return 0 def _do_install_blob_pkg( config: GlobalConfig, mr: CompositeRepo, pm: BoundPackageManifest, fetch_only: bool, reinstall: bool, ) -> int: logger = config.logger bm = pm.blob_metadata assert bm is not None pkg_name = pm.name_for_installation install_root = config.global_blob_install_root(pkg_name) rgs = config.ruyipkg_global_state is_installed = rgs.is_package_installed( pm.repo_id, pm.category, pm.name, pm.ver, "", # host is "" for blob packages ) # Fallback to directory check if not tracked in state if not is_installed and is_root_likely_populated(install_root): is_installed = True if is_installed: if not reinstall: logger.I( _("skipping already installed package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 0 logger.W( _( "package [green]{pkg}[/] seems already installed; purging and re-installing due to [yellow]--reinstall[/]" ).format(pkg=pkg_name) ) # Remove from state tracking before purging rgs.remove_installation( pm.repo_id, pm.category, pm.name, pm.ver, "", ) shutil.rmtree(install_root) ir_parent = pathlib.Path(install_root).resolve().parent ir_parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".ruyi-tmp", dir=ir_parent) as tmp_root: ret = _do_install_blob_pkg_to( config, mr, pm, fetch_only, tmp_root, ) if ret != 0: return ret os.rename(tmp_root, install_root) if not fetch_only: rgs.record_installation( repo_id=pm.repo_id, category=pm.category, name=pm.name, version=pm.ver, host="", # Empty for blob packages install_path=install_root, ) logger.I( _("package [green]{pkg}[/] installed to [yellow]{install_root}[/]").format( pkg=pkg_name, install_root=install_root, ) ) return 0 def _do_install_blob_pkg_to( config: GlobalConfig, mr: CompositeRepo, pm: BoundPackageManifest, fetch_only: bool, install_root: str, ) -> int: logger = config.logger bm = pm.blob_metadata assert bm is not None pkg_name = pm.name_for_installation dfs = pm.distfiles distfile_names = bm.get_distfile_names() if not distfile_names: logger.F( _("package [green]{pkg}[/] declares no blob distfile").format(pkg=pkg_name) ) return 2 for df_name in distfile_names: df_decl = dfs[df_name] ensure_unpack_cmd_for_method(logger, df_decl.unpack_method) df = Distfile(df_decl, pm.repo) df.ensure(logger) if fetch_only: logger.D( _("skipping installation because [yellow]--fetch-only[/] is given") ) continue logger.I( _("extracting [green]{distfile}[/] for package [green]{pkg}[/]").format( distfile=df_name, pkg=pkg_name, ) ) df.unpack_or_symlink(install_root, logger) return 0 def do_uninstall_atoms( config: GlobalConfig, mr: CompositeRepo, atom_strs: set[str], *, canonicalized_host: str | RuyiHost, assume_yes: bool, ) -> int: logger = config.logger logger.D(f"about to uninstall for host {canonicalized_host}: {atom_strs}") bis = BoundInstallationStateStore(config.ruyipkg_global_state, mr) pms_to_uninstall: list[tuple[str, BoundPackageManifest]] = [] for a_str in atom_strs: a = Atom.parse(a_str) pm = a.match_in_repo(bis, config.include_prereleases) if pm is None: logger.F( _("atom [yellow]{atom}[/] is non-existent or not installed").format( atom=a_str, ) ) return 1 pms_to_uninstall.append((a_str, pm)) if not pms_to_uninstall: logger.I(_("no packages to uninstall")) return 0 logger.I(_("the following packages will be uninstalled:")) for _unused, pm in pms_to_uninstall: logger.I( _(" - [green]{category}/{name}[/] ({version})").format( category=pm.category, name=pm.name, version=pm.ver, ) ) if not assume_yes: if not ask_for_yesno_confirmation(logger, _("Proceed?"), default=False): logger.I(_("uninstallation aborted")) return 0 for a_str, pm in pms_to_uninstall: pkg_name = pm.name_for_installation config.telemetry.record( TelemetryScope(mr.repo_id), "repo:package-uninstall-v1", atom=a_str, host=canonicalized_host, pkg_category=pm.category, pkg_kinds=pm.kind, pkg_name=pm.name, pkg_version=pm.ver, ) if pm.binary_metadata is not None: ret = _do_uninstall_binary_pkg( config, pm, canonicalized_host, ) if ret != 0: return ret continue if pm.blob_metadata is not None: ret = _do_uninstall_blob_pkg(config, pm) if ret != 0: return ret continue logger.F( _("don't know how to handle non-binary package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 2 return 0 def _do_uninstall_binary_pkg( config: GlobalConfig, pm: BoundPackageManifest, canonicalized_host: str | RuyiHost, ) -> int: logger = config.logger bm = pm.binary_metadata assert bm is not None pkg_name = pm.name_for_installation install_root = config.global_binary_install_root(str(canonicalized_host), pkg_name) rgs = config.ruyipkg_global_state is_installed = rgs.is_package_installed( pm.repo_id, pm.category, pm.name, pm.ver, str(canonicalized_host), ) # Check directory existence if the PM state says the package is not installed if not is_installed: if not os.path.exists(install_root): logger.I( _("skipping not-installed package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 0 # There may be potentially user-generated data in the directory, # let's be safe and fail the process. logger.F( _( "package [green]{pkg}[/] is not tracked as installed, but its directory [yellow]{install_root}[/] exists." ).format(pkg=pkg_name, install_root=install_root) ) logger.I(_("Please remove it manually if you are sure it's safe to do so.")) logger.I( _( "If you believe this is a bug, please file an issue at [yellow]https://github.com/ruyisdk/ruyi/issues[/]." ) ) return 1 logger.I(_("uninstalling package [green]{pkg}[/]").format(pkg=pkg_name)) if is_installed: rgs.remove_installation( pm.repo_id, pm.category, pm.name, pm.ver, str(canonicalized_host), ) if os.path.exists(install_root): shutil.rmtree(install_root) logger.I(_("package [green]{pkg}[/] uninstalled").format(pkg=pkg_name)) return 0 def _do_uninstall_blob_pkg( config: GlobalConfig, pm: BoundPackageManifest, ) -> int: logger = config.logger bm = pm.blob_metadata assert bm is not None pkg_name = pm.name_for_installation install_root = config.global_blob_install_root(pkg_name) rgs = config.ruyipkg_global_state is_installed = rgs.is_package_installed( pm.repo_id, pm.category, pm.name, pm.ver, "", # host is "" for blob packages ) # Check directory existence if the PM state says the package is not installed if not is_installed: if not os.path.exists(install_root): logger.I( _("skipping not-installed package [green]{pkg}[/]").format( pkg=pkg_name, ) ) return 0 # There may be potentially user-generated data in the directory, # let's be safe and fail the process. logger.F( _( "package [green]{pkg}[/] is not tracked as installed, but its directory [yellow]{install_root}[/] exists." ).format(pkg=pkg_name, install_root=install_root) ) logger.I(_("Please remove it manually if you are sure it's safe to do so.")) logger.I( _( "If you believe this is a bug, please file an issue at [yellow]https://github.com/ruyisdk/ruyi/issues[/]." ) ) return 1 logger.I(_("uninstalling package [green]{pkg}[/]").format(pkg=pkg_name)) if is_installed: rgs.remove_installation( pm.repo_id, pm.category, pm.name, pm.ver, "", ) if os.path.exists(install_root): shutil.rmtree(install_root) logger.I(_("package [green]{pkg}[/] uninstalled").format(pkg=pkg_name)) return 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/install_cli.py000066400000000000000000000123631520522431500221330ustar00rootroot00000000000000import argparse import pathlib from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ from .cli_completion import package_completer_builder from .host import get_native_host if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class ExtractCommand( RootCommand, cmd="extract", help=_("Fetch package(s) then extract to current directory"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument( "atom", type=str, nargs="+", help=_("Specifier (atom) of the package(s) to extract"), ) if gc.is_cli_autocomplete: a.completer = package_completer_builder(gc) p.add_argument( "-d", "--dest-dir", type=str, metavar="DESTDIR", default=".", help=_("Destination directory to extract to (default: current directory)"), ) p.add_argument( "--extract-without-subdir", action="store_true", help=_( "Extract files directly into DESTDIR instead of package-named subdirectories" ), ) p.add_argument( "-f", "--fetch-only", action="store_true", help=_("Fetch distribution files only without installing"), ) p.add_argument( "--host", type=str, default=get_native_host(), help=_("Override the host architecture (normally not needed)"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .host import canonicalize_host_str from .install import do_extract_atoms atom_strs: set[str] = set(args.atom) dest_dir_arg: str = args.dest_dir extract_without_subdir: bool = args.extract_without_subdir host: str = args.host fetch_only: bool = args.fetch_only dest_dir = None if dest_dir_arg == "." else pathlib.Path(dest_dir_arg) return do_extract_atoms( cfg, cfg.repo, atom_strs, canonicalized_host=canonicalize_host_str(host), dest_dir=dest_dir, extract_without_subdir=extract_without_subdir, fetch_only=fetch_only, ) class InstallCommand( RootCommand, cmd="install", aliases=["i"], help=_("Install package from configured repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument( "atom", type=str, nargs="+", help=_("Specifier (atom) of the package to install"), ) if gc.is_cli_autocomplete: a.completer = package_completer_builder(gc) p.add_argument( "-f", "--fetch-only", action="store_true", help=_("Fetch distribution files only without installing"), ) p.add_argument( "--host", type=str, default=get_native_host(), help=_("Override the host architecture (normally not needed)"), ) p.add_argument( "--reinstall", action="store_true", help=_("Force re-installation of already installed packages"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .host import canonicalize_host_str from .install import do_install_atoms host: str = args.host atom_strs: set[str] = set(args.atom) fetch_only: bool = args.fetch_only reinstall: bool = args.reinstall return do_install_atoms( cfg, cfg.repo, atom_strs, canonicalized_host=canonicalize_host_str(host), fetch_only=fetch_only, reinstall=reinstall, ) class UninstallCommand( RootCommand, cmd="uninstall", aliases=["remove", "rm"], help=_("Uninstall installed packages"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: argparse.ArgumentParser) -> None: p.add_argument( "atom", type=str, nargs="+", help=_("Specifier (atom) of the package to uninstall"), ) p.add_argument( "--host", type=str, default=get_native_host(), help=_("Override the host architecture (normally not needed)"), ) p.add_argument( "-y", "--yes", action="store_true", dest="assume_yes", help=_("Assume yes to all prompts"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .host import canonicalize_host_str from .install import do_uninstall_atoms host: str = args.host atom_strs: set[str] = set(args.atom) assume_yes: bool = args.assume_yes return do_uninstall_atoms( cfg, cfg.repo, atom_strs, canonicalized_host=canonicalize_host_str(host), assume_yes=assume_yes, ) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/list.py000066400000000000000000000113711520522431500206070ustar00rootroot00000000000000from itertools import chain from ..config import GlobalConfig from ..i18n import _ from ..log import RuyiLogger from ..utils.porcelain import PorcelainOutput from .augmented_pkg import AugmentedPkg from .list_filter import ListFilter from .pkg_manifest import BoundPackageManifest def do_list( cfg: GlobalConfig, filters: ListFilter, verbose: bool, ) -> int: logger = cfg.logger if not filters: if cfg.is_porcelain: # we don't want to print message for humans in case of porcelain # mode, but we don't want to retain the old behavior of listing # all packages either return 1 logger.F(_("no filter specified for list operation")) logger.I( _( "for the old behavior of listing all packages, try [yellow]ruyi list --all[/]" ) ) return 1 augmented_pkgs = list(AugmentedPkg.yield_from_repo(cfg, cfg.repo, filters)) multi_repo = len(cfg.repo_entries) > 1 if cfg.is_porcelain: return _do_list_porcelain(augmented_pkgs) if not verbose: return _do_list_non_verbose(logger, augmented_pkgs, multi_repo) for i, ver in enumerate(chain(*(ap.versions for ap in augmented_pkgs))): if i > 0: logger.stdout("\n") _print_pkg_detail(logger, ver.pm, cfg.lang_code, multi_repo) return 0 def _do_list_non_verbose( logger: RuyiLogger, augmented_pkgs: list[AugmentedPkg], multi_repo: bool = False, ) -> int: logger.stdout(_("List of available packages:\n")) for ap in augmented_pkgs: logger.stdout(f"* [bold green]{ap.category}/{ap.name}[/]") for ver in ap.versions: if ver.remarks: comments_str = ( f" ({', '.join(r.as_rich_markup() for r in ver.remarks)})" ) else: comments_str = "" slug_str = f" slug: [yellow]{ver.pm.slug}[/]" if ver.pm.slug else "" repo_str = f" [dim]\\[{ver.pm.repo_id}][/]" if multi_repo else "" logger.stdout( f" - [blue]{ver.pm.semver}[/]{comments_str}{slug_str}{repo_str}" ) return 0 def _do_list_porcelain(augmented_pkgs: list[AugmentedPkg]) -> int: with PorcelainOutput() as po: for ap in augmented_pkgs: po.emit(ap.to_porcelain()) return 0 def _print_pkg_detail( logger: RuyiLogger, pm: BoundPackageManifest, lang_code: str, multi_repo: bool = False, ) -> None: repo_tag = f" [dim]\\[{pm.repo_id}][/]" if multi_repo else "" logger.stdout( f"[bold]## [green]{pm.category}/{pm.name}[/] [blue]{pm.ver}[/]{repo_tag}[/]\n" ) if pm.slug is not None: logger.stdout(_("* Slug: [yellow]{slug}[/]").format(slug=pm.slug)) else: logger.stdout(_("* Slug: (none)")) logger.stdout(_("* Package kind: {kind}").format(kind=sorted(pm.kind))) logger.stdout(_("* Vendor: {vendor}").format(vendor=pm.vendor_name)) if upstream_ver := pm.upstream_version: logger.stdout( _("* Upstream version number: {version}").format(version=upstream_ver) ) else: logger.stdout(_("* Upstream version number: (undeclared)")) logger.stdout("") sv = pm.service_level if sv.has_known_issues: logger.stdout(_("\nPackage has known issue(s):\n")) for x in sv.render_known_issues(pm.repo.messages, lang_code): logger.stdout(x, end="\n\n") df = pm.distfiles logger.stdout(_("Package declares {count} distfile(s):\n").format(count=len(df))) for dd in df.values(): logger.stdout(f"* [green]{dd.name}[/]") logger.stdout(_(" - Size: [yellow]{size}[/] bytes").format(size=dd.size)) for kind, csum in dd.checksums.items(): logger.stdout(f" - {kind.upper()}: [yellow]{csum}[/]") if bm := pm.binary_metadata: logger.stdout(_("\n### Binary artifacts\n")) for host, data in bm.data.items(): logger.stdout(_("* Host [green]{host}[/]:").format(host=host)) logger.stdout( _(" - Distfiles: {distfiles}").format(distfiles=data["distfiles"]) ) if cmds := data.get("commands"): logger.stdout(_(" - Available command(s):")) for k in sorted(cmds.keys()): logger.stdout(f" - [green]{k}[/]") if tm := pm.toolchain_metadata: logger.stdout(_("\n### Toolchain metadata\n")) logger.stdout(_("* Target: [bold green]{target}[/]").format(target=tm.target)) logger.stdout(_("* Quirks: {quirks}").format(quirks=tm.quirks)) logger.stdout(_("* Components:")) for tc in tm.components: logger.stdout(f' - {tc["name"]} [bold green]{tc["version"]}[/]') ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/list_cli.py000066400000000000000000000047521520522431500214430ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ from .list_filter import ListFilter, ListFilterAction if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class ListCommand( RootCommand, cmd="list", has_subcommands=True, is_subcommand_required=False, has_main=True, help=_("List available packages in configured repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "--verbose", "-v", action="store_true", help=_("Also show details for every package"), ) p.add_argument( "--all", action=ListFilterAction, nargs=0, dest="filters", help=_("Match and show all packages"), ) # filter expressions p.add_argument( "--is-installed", action=ListFilterAction, nargs=1, dest="filters", help=_( "Match packages that are installed (y/true/1) or not installed (n/false/0)" ), ) p.add_argument( "--category-contains", action=ListFilterAction, nargs=1, dest="filters", help=_( "Match packages from categories whose names contain the given string" ), ) p.add_argument( "--category-is", action=ListFilterAction, nargs=1, dest="filters", help=_("Match packages from the given category"), ) p.add_argument( "--name-contains", action=ListFilterAction, nargs=1, dest="filters", help=_("Match packages whose names contain the given string"), ) if gc.is_experimental: p.add_argument( "--related-to-entity", action=ListFilterAction, nargs=1, dest="filters", help=_("Match packages related to the given entity"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .list import do_list verbose: bool = args.verbose filters: ListFilter = args.filters return do_list( cfg, filters=filters, verbose=verbose, ) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/list_filter.py000066400000000000000000000133221520522431500221520ustar00rootroot00000000000000import argparse import enum from typing import Any, Callable, Iterable, NamedTuple, Sequence, TypeVar, TYPE_CHECKING from ..cli.completion import ArgcompleteAction from ..utils.global_mode import TRUTHY_ENV_VAR_VALUES if TYPE_CHECKING: from ..config import GlobalConfig from .composite_repo import CompositeRepo _T = TypeVar("_T") class ListFilterOpKind(enum.Enum): UNKNOWN = 0 CATEGORY_CONTAINS = 1 CATEGORY_IS = 2 NAME_CONTAINS = 3 RELATED_TO_ENTITY = 4 IS_INSTALLED = 5 ALL = 6 class ListFilterOp(NamedTuple): op: ListFilterOpKind arg: str class ListFilterExecCtx(NamedTuple): cfg: "GlobalConfig" mr: "CompositeRepo" category: str pkg_name: str def _execute_filter_op(op: ListFilterOp, ctx: ListFilterExecCtx) -> bool: match op.op: case ListFilterOpKind.ALL: return True case ListFilterOpKind.CATEGORY_CONTAINS: return op.arg in ctx.category case ListFilterOpKind.CATEGORY_IS: return op.arg == ctx.category case ListFilterOpKind.NAME_CONTAINS: return op.arg in ctx.pkg_name case ListFilterOpKind.RELATED_TO_ENTITY: es = ctx.mr.entity_store return es.is_entity_related_to( f"pkg:{ctx.category}/{ctx.pkg_name}", op.arg, transitive=True, unidirectional=False, ) case ListFilterOpKind.IS_INSTALLED: asks_for_installed = op.arg.lower() in TRUTHY_ENV_VAR_VALUES # We need to check all versions of this package to see if any are installed # For now, we'll use a simple heuristic - check if ANY version is installed installed_packages = ctx.cfg.ruyipkg_global_state.list_installed_packages() is_installed = any( pkg.category == ctx.category and pkg.name == ctx.pkg_name for pkg in installed_packages ) return not (is_installed ^ asks_for_installed) case _: return False class ListFilter: def __init__(self) -> None: self.ops: list[ListFilterOp] = [] def __bool__(self) -> bool: return len(self.ops) > 0 def __repr__(self) -> str: return f"" def append(self, op: ListFilterOp) -> None: self.ops.append(op) def check_pkg_name( self, cfg: "GlobalConfig", mr: "CompositeRepo", category: str, pkg_name: str, ) -> bool: ctx = ListFilterExecCtx(cfg, mr, category, pkg_name) return all(_execute_filter_op(op, ctx) for op in self.ops) class ListFilterAction(ArgcompleteAction): def __init__( self, option_strings: Sequence[str], dest: str, nargs: int | str | None = None, const: _T | None = None, default: _T | str | None = None, type: Callable[[str], _T] | argparse.FileType | None = None, choices: Iterable[_T] | None = None, required: bool = False, help: str | None = None, metavar: str | tuple[str, ...] | None = None, ) -> None: # for now let's just support argument-less and unary filter ops if nargs not in (None, 0, 1): raise ValueError("nargs not supported") if const is not None: raise ValueError("const not supported") if default is not None: raise ValueError("default not supported") if type is not None: raise ValueError("type not supported") if choices is not None: raise ValueError("choices not supported") if required: raise ValueError("required not supported") if metavar is None: metavar = "STR" super().__init__( option_strings, dest, nargs, const, default, type, choices, required, help, metavar, ) self.filter_op_kind: ListFilterOpKind match option_strings[0].lstrip("-"): case "category-contains": self.filter_op_kind = ListFilterOpKind.CATEGORY_CONTAINS case "category-is": self.filter_op_kind = ListFilterOpKind.CATEGORY_IS case "name-contains": self.filter_op_kind = ListFilterOpKind.NAME_CONTAINS case "related-to-entity": self.filter_op_kind = ListFilterOpKind.RELATED_TO_ENTITY case "is-installed": self.filter_op_kind = ListFilterOpKind.IS_INSTALLED case "all": self.filter_op_kind = ListFilterOpKind.ALL case _: # should never happen self.filter_op_kind = ListFilterOpKind.UNKNOWN def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: str | Sequence[Any] | None, option_string: str | None = None, ) -> None: dest: ListFilter | None = getattr(namespace, self.dest, None) if not dest: dest = ListFilter() setattr(namespace, self.dest, dest) if self.filter_op_kind == ListFilterOpKind.ALL: # "--all" takes no argument dest.append(ListFilterOp(ListFilterOpKind.ALL, "")) return val: str if isinstance(values, str): val = values elif isinstance(values, list): val = values[0] else: # should never happen # XXX: no easy way to wire to the global logger instance here # log.D(f"unexpected values type: {type(values)}") val = "" dest.append(ListFilterOp(self.filter_op_kind, val)) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/manifest_io.py000066400000000000000000000006551520522431500221340ustar00rootroot00000000000000import pathlib from .canonical_dump import dumps_canonical_package_manifest_toml from .pkg_manifest import PackageManifest def load_package_manifest_from_path(path: pathlib.Path) -> PackageManifest: return PackageManifest.load_from_path(path) def dump_canonical_package_manifest_from_path(path: pathlib.Path) -> str: return dumps_canonical_package_manifest_toml( load_package_manifest_from_path(path), ) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/migration.py000066400000000000000000000027201520522431500216230ustar00rootroot00000000000000import os import pathlib import shutil from typing import TYPE_CHECKING from ..i18n import _ if TYPE_CHECKING: from ..log import RuyiLogger def migrate_repo_dir(cache_root: str, logger: "RuyiLogger") -> None: """Migrate the legacy packages-index/ directory to repos/ruyisdk/. If ``/packages-index/`` exists (not as a symlink) and ``/repos/ruyisdk/`` does not, move the former to the latter and create a compatibility symlink at the old location. If both exist, or the old path is already a symlink, do nothing. """ legacy_path = pathlib.Path(cache_root) / "packages-index" new_path = pathlib.Path(cache_root) / "repos" / "ruyisdk" # Nothing to migrate if the legacy directory doesn't exist or is # already a symlink (i.e. a previous migration already ran). if not legacy_path.exists() or legacy_path.is_symlink(): return # Already migrated (both exist) — do nothing. if new_path.exists(): return logger.I( _("migrating repo directory from [yellow]{old}[/] to [yellow]{new}[/]").format( old=legacy_path, new=new_path ) ) new_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(legacy_path), str(new_path)) # Create a compatibility symlink so that any code still using the old # path continues to work during the transition. os.symlink(str(new_path), str(legacy_path)) logger.I(_("repo directory migration complete")) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/msg.py000066400000000000000000000061541520522431500204250ustar00rootroot00000000000000from typing import Callable, TypedDict, TypeGuard, cast from jinja2 import BaseLoader, Environment, TemplateNotFound from ..utils.l10n import match_lang_code RepoMessagesV1Type = TypedDict( "RepoMessagesV1Type", { "ruyi-repo-messages": str, # lang_code: message_content }, ) def validate_repo_messages_v1(x: object) -> TypeGuard[RepoMessagesV1Type]: if not isinstance(x, dict): return False x = cast(dict[str, object], x) if x.get("ruyi-repo-messages", "") != "v1": return False return True def group_messages_by_lang_code(decl: RepoMessagesV1Type) -> dict[str, dict[str, str]]: obj = cast(dict[str, dict[str, str]], decl) result: dict[str, dict[str, str]] = {} for msgid, msg_decl in obj.items(): # skip the file type marker if msgid == "ruyi-repo-messages": continue for lang_code, msg in msg_decl.items(): if lang_code not in result: result[lang_code] = {} result[lang_code][msgid] = msg return result class RepoMessageStore: def __init__(self, decl: RepoMessagesV1Type) -> None: self._msgs_by_lang_code = group_messages_by_lang_code(decl) self._cached_envs_by_lang_code: dict[str, Environment] = {} @classmethod def from_object(cls, obj: object) -> "RepoMessageStore": if not validate_repo_messages_v1(obj): # TODO: more detail in the error message raise RuntimeError("malformed v1 repo messages definition") return cls(obj) def get_message_template(self, msgid: str, lang_code: str) -> str | None: resolved_lang_code = match_lang_code(lang_code, self._msgs_by_lang_code.keys()) return self._msgs_by_lang_code[resolved_lang_code].get(msgid) def get_jinja(self, lang_code: str) -> Environment: if lang_code in self._cached_envs_by_lang_code: return self._cached_envs_by_lang_code[lang_code] env = Environment( loader=RepoMessageLoader(self, lang_code), autoescape=False, # we're not producing HTML auto_reload=False, # we're serving static assets ) self._cached_envs_by_lang_code[lang_code] = env return env def render_message( self, msgid: str, lang_code: str, params: dict[str, str], add_trailing_newline: bool = False, ) -> str: env = self.get_jinja(lang_code) tmpl = env.get_template(msgid) result = tmpl.render(params) if add_trailing_newline and not result.endswith("\n"): return result + "\n" return result class RepoMessageLoader(BaseLoader): def __init__(self, store: RepoMessageStore, lang_code: str) -> None: self.store = store self.lang_code = lang_code def get_source( self, environment: Environment, template: str, ) -> tuple[str, (str | None), (Callable[[], bool] | None)]: result = self.store.get_message_template(template, self.lang_code) if result is None: raise TemplateNotFound(template) return result, None, None ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/news.py000066400000000000000000000101771520522431500206130ustar00rootroot00000000000000from rich import box from rich.table import Table from ..config import GlobalConfig from ..i18n import _ from ..log import RuyiLogger from ..utils.markdown import RuyiStyledMarkdown from ..utils.porcelain import PorcelainOutput from .news_store import NewsItem, NewsItemContent, NewsItemStore def print_news_item_titles( logger: RuyiLogger, newsitems: list[NewsItem], lang: str, ) -> None: tbl = Table(box=box.SIMPLE, show_edge=False) # i18n NOTE: used as news item table title tbl.add_column(_("No.")) # i18n NOTE: used as news item table title tbl.add_column(_("ID")) # i18n NOTE: used as news item table title tbl.add_column(_("Title")) for ni in newsitems: unread = not ni.is_read ord = f"[bold green]{ni.ordinal}[/]" if unread else f"{ni.ordinal}" id = f"[bold green]{ni.id}[/]" if unread else ni.id tbl.add_row( ord, id, ni.get_content_for_lang(lang).display_title, ) logger.stdout(tbl) def maybe_notify_unread_news( gc: GlobalConfig, prompt_no_unread: bool = True, ) -> None: """Check if there are new newsitems, notify the user if so.""" unread_newsitems = gc.repo.news_store().list(True) if unread_newsitems: gc.logger.stdout( _("\nThere are {count} new news item(s):\n").format( count=len(unread_newsitems), ) ) print_news_item_titles(gc.logger, unread_newsitems, gc.lang_code) gc.logger.stdout(_("\nYou can read them with [yellow]ruyi news read[/].")) return if prompt_no_unread: gc.logger.stdout( _( "\nAll news items have been read. To see a list of them, run [yellow]ruyi news list[/].\n" ) ) def do_news_list( cfg: GlobalConfig, only_unread: bool, ) -> int: logger = cfg.logger store = cfg.repo.news_store() newsitems = store.list(only_unread) if cfg.is_porcelain: with PorcelainOutput() as po: for ni in newsitems: po.emit(ni.to_porcelain()) return 0 logger.stdout(_("[bold green]News items:[/]\n")) if not newsitems: logger.stdout(_(" (no unread item)") if only_unread else _(" (no item)")) return 0 print_news_item_titles(logger, newsitems, cfg.lang_code) return 0 def do_news_read( cfg: GlobalConfig, quiet: bool, items_strs: list[str], ) -> int: logger = cfg.logger store = cfg.repo.news_store() # filter out requested news items items = filter_news_items_by_specs(logger, store, items_strs) if items is None: return 1 if cfg.is_porcelain: with PorcelainOutput() as po: for ni in items: po.emit(ni.to_porcelain()) elif not quiet: # render the items if items: for ni in items: print_news(logger, ni.get_content_for_lang(cfg.lang_code)) else: logger.stdout(_("No news to display.")) # record read statuses store.mark_as_read(*(ni.id for ni in items)) return 0 def filter_news_items_by_specs( logger: RuyiLogger, store: NewsItemStore, specs: list[str], ) -> list[NewsItem] | None: if not specs: # all unread items return store.list(True) all_ni = store.list(False) items: list[NewsItem] = [] ni_by_ord = {ni.ordinal: ni for ni in all_ni} ni_by_id = {ni.id: ni for ni in all_ni} for i in specs: try: ni_ord = int(i) if ni_ord not in ni_by_ord: logger.F( _("there is no news item with ordinal {ord}").format(ord=ni_ord) ) return None items.append(ni_by_ord[ni_ord]) except ValueError: # treat i as id if i not in ni_by_id: logger.F(_("there is no news item with ID '{id}'").format(id=i)) return None items.append(ni_by_id[i]) return items def print_news(logger: RuyiLogger, nic: NewsItemContent) -> None: md = RuyiStyledMarkdown(nic.content) logger.stdout(md) logger.stdout("") ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/news_cli.py000066400000000000000000000046021520522431500214360ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class NewsCommand( RootCommand, cmd="news", has_subcommands=True, is_subcommand_required=False, has_main=True, help=_("List and read news items from configured repository"), ): _my_parser: "ArgumentParser | None" = None @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: cls._my_parser = p @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .news import maybe_notify_unread_news assert cls._my_parser is not None cls._my_parser.print_help() maybe_notify_unread_news(cfg, True) return 0 class NewsListCommand( NewsCommand, cmd="list", help=_("List news items"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "--new", action="store_true", help=_("List unread news items only"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .news import do_news_list only_unread: bool = args.new return do_news_list( cfg, only_unread, ) class NewsReadCommand( NewsCommand, cmd="read", help=_("Read news items"), description=_( "Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified." ), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "--quiet", "-q", action="store_true", help=_("Do not output anything and only mark as read"), ) p.add_argument( "item", type=str, nargs="*", help=_("Ordinal or ID of the news item(s) to read"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .news import do_news_read quiet: bool = args.quiet items_strs: list[str] = args.item return do_news_read( cfg, quiet, items_strs, ) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/news_store.py000066400000000000000000000137361520522431500220330ustar00rootroot00000000000000import functools import re from typing import Any, Final, TypedDict from ..config.news import NewsReadStatusStore from ..utils import frontmatter from ..utils.porcelain import PorcelainEntity, PorcelainEntityType from ..utils.l10n import match_lang_code NEWS_FILENAME_RE: Final = re.compile(r"^(\d+-\d{2}-\d{2}-.*?)(\.[0-9A-Za-z_-]+)?\.md$") @functools.total_ordering class NewsItemNameMetadata: def __init__(self, id: str, lang: str) -> None: self.id = id self.lang = lang def __eq__(self, other: Any) -> bool: if not isinstance(other, NewsItemNameMetadata): return NotImplemented return self.id == other.id def __lt__(self, other: Any) -> bool: if not isinstance(other, NewsItemNameMetadata): return NotImplemented # order by id in lexical order return self.id < other.id def parse_news_filename(filename: str) -> NewsItemNameMetadata | None: m = NEWS_FILENAME_RE.match(filename) if m is None: return None id = m.group(1) lang = m.group(2) if not lang: lang = "zh_CN" # TODO: kill after l10n work is complete else: lang = lang[1:] # strip the dot prefix return NewsItemNameMetadata(id, lang) class NewsItemStore: def __init__(self, rs_store: NewsReadStatusStore) -> None: self._buf_news_by_ids: dict[str, NewsItem] = {} self._newsitems: list[NewsItem] self._rs_store = rs_store def add(self, filename: str, contents: str) -> None: md = parse_news_filename(filename) if md is None: return None post = frontmatter.loads(contents) if ni := self._buf_news_by_ids.get(md.id): ni.add_lang(md, post) else: ni = NewsItem(md.id) ni.add_lang(md, post) self._buf_news_by_ids[md.id] = ni def add_item( self, news_id: str, md: NewsItemNameMetadata, post: frontmatter.Post, ) -> None: """Add a pre-parsed news item entry, used for cross-repo merging.""" if ni := self._buf_news_by_ids.get(news_id): ni.add_lang(md, post) else: ni = NewsItem(news_id) ni.add_lang(md, post) self._buf_news_by_ids[news_id] = ni def finalize(self) -> None: self._newsitems = list(self._buf_news_by_ids.values()) # sort in intended display order self._newsitems.sort() # mark the news item instances with ordinals for i, ni in enumerate(self._newsitems): ni.ordinal = i + 1 # also read statuses for ni in self._newsitems: ni.is_read = ni.id in self._rs_store def list(self, only_unread: bool) -> list["NewsItem"]: if not only_unread: return self._newsitems return [x for x in self._newsitems if not x.is_read] def mark_as_read(self, *ids: str) -> None: if not ids: return for id in ids: self._rs_store.add(id) self._rs_store.save() @functools.total_ordering class NewsItem: def __init__(self, id: str) -> None: self._id = id self._content_by_lang: dict[str, NewsItemContent] = {} # these fields are updated later in store initialization code self.ordinal = 0 self.is_read = False def __eq__(self, other: Any) -> bool: if not isinstance(other, NewsItem): return NotImplemented return self._id == other._id and self.ordinal == other.ordinal def __lt__(self, other: Any) -> bool: if not isinstance(other, NewsItem): return NotImplemented return self._id < other._id or self.ordinal < other.ordinal @property def id(self) -> str: return self._id @property def langs(self) -> dict[str, "NewsItemContent"]: """Language-keyed content map.""" return self._content_by_lang def __contains__(self, lang: str) -> bool: return lang in self._content_by_lang def __getitem__(self, lang: str) -> "NewsItemContent": return self._content_by_lang[lang] def add_lang(self, md: NewsItemNameMetadata, post: frontmatter.Post) -> None: self._content_by_lang[md.lang] = NewsItemContent(md, post) def __delitem__(self, lang: str) -> None: del self._content_by_lang[lang] def get_content_for_lang(self, lang: str) -> "NewsItemContent": resolved_lang_code = match_lang_code(lang, self._content_by_lang.keys()) return self[resolved_lang_code] def to_porcelain(self) -> "PorcelainNewsItemV1": return { "ty": PorcelainEntityType.NewsItemV1, "id": self.id, "ord": self.ordinal, "is_read": self.is_read, "langs": [x.to_porcelain() for x in self._content_by_lang.values()], } class NewsItemContent: def __init__( self, md: NewsItemNameMetadata, post: frontmatter.Post, ) -> None: self._md = md self._post = post @property def lang(self) -> str: return self._md.lang @property def post(self) -> frontmatter.Post: """The parsed frontmatter post.""" return self._post @property def metadata(self) -> NewsItemNameMetadata: """The filename-derived metadata.""" return self._md @property def display_title(self) -> str: metadata_title = self._post.get("title") return metadata_title if isinstance(metadata_title, str) else self._md.id @property def content(self) -> str: return self._post.content def to_porcelain(self) -> "PorcelainNewsItemContentV1": return { "lang": self.lang, "display_title": self.display_title, "content": self.content, } class PorcelainNewsItemContentV1(TypedDict): lang: str display_title: str content: str class PorcelainNewsItemV1(PorcelainEntity): id: str ord: int is_read: bool langs: list[PorcelainNewsItemContentV1] ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/pkg_manifest.py000066400000000000000000000446241520522431500223120ustar00rootroot00000000000000from copy import deepcopy from functools import cached_property import os import pathlib import re from typing import ( Any, BinaryIO, Final, Iterable, Iterator, Literal, TypedDict, TYPE_CHECKING, cast, ) if TYPE_CHECKING: from typing_extensions import NotRequired, Self # pyright only works with semver 3.x from semver.version import Version else: try: from semver.version import Version # type: ignore[import-untyped,unused-ignore] except ModuleNotFoundError: # semver 2.x from semver import VersionInfo as Version # type: ignore[import-untyped,unused-ignore] import tomlkit from .host import RuyiHost, canonicalize_host_str, get_native_host from .msg import RepoMessageStore from .unpack_method import UnpackMethod, determine_unpack_method if TYPE_CHECKING: # avoid circular import at runtime from .repo import MetadataRepo class VendorDeclType(TypedDict): name: str eula: str | None RestrictKind = Literal["fetch"] | Literal["mirror"] class FetchRestrictionDeclType(TypedDict): msgid: str params: "NotRequired[dict[str, str]]" class DistfileDeclType(TypedDict): name: str urls: "NotRequired[list[str]]" restrict: "NotRequired[list[RestrictKind]]" size: int checksums: dict[str, str] strip_components: "NotRequired[int]" unpack: "NotRequired[UnpackMethod]" fetch_restriction: "NotRequired[FetchRestrictionDeclType]" prefixes_to_unpack: "NotRequired[list[str]]" class BinaryFileDeclType(TypedDict): host: str distfiles: list[str] commands: "NotRequired[dict[str, str]]" BinaryDeclType = list[BinaryFileDeclType] class BlobDeclType(TypedDict): distfiles: list[str] class SourceDeclType(TypedDict): distfiles: list[str] class ToolchainComponentDeclType(TypedDict): name: str version: str class ToolchainDeclType(TypedDict): target: str quirks: "NotRequired[list[str]]" flavors: "NotRequired[list[str]]" # Backward compatibility alias components: list[ToolchainComponentDeclType] included_sysroot: "NotRequired[str]" EmulatorFlavor = Literal["qemu-linux-user"] class EmulatorProgramDeclType(TypedDict): path: str flavor: EmulatorFlavor supported_arches: list[str] binfmt_misc: "NotRequired[str]" class EmulatorDeclType(TypedDict): quirks: "NotRequired[list[str]]" flavors: "NotRequired[list[str]]" # Backward compatibility alias programs: list[EmulatorProgramDeclType] PartitionKind = ( Literal["boot"] | Literal["disk"] | Literal["live"] | Literal["root"] | Literal["uboot"] ) # error: "" has no attribute "__args__" # KNOWN_PARTITION_KINDS = frozenset(kind.__args__[0] for kind in PartitionKind.__args__) KNOWN_PARTITION_KINDS: Final = frozenset(("boot", "disk", "live", "root", "uboot")) PartitionMapDecl = dict[PartitionKind, str] class ProvisionableDeclType(TypedDict): partition_map: PartitionMapDecl strategy: str PackageKind = ( Literal["binary"] | Literal["blob"] | Literal["source"] | Literal["toolchain"] | Literal["emulator"] | Literal["provisionable"] ) ALL_PACKAGE_KINDS: Final[list[PackageKind]] = [ "binary", "blob", "source", "toolchain", "emulator", "provisionable", ] RuyiPkgFormat = Literal["v1"] ServiceLevelKind = Literal["known_issue"] | Literal["untested"] ALL_SERVICE_LEVEL_KINDS: Final[list[ServiceLevelKind]] = ["known_issue", "untested"] class ServiceLevelDeclType(TypedDict): level: ServiceLevelKind msgid: "NotRequired[str]" params: "NotRequired[dict[str, str]]" class PackageMetadataDeclType(TypedDict): slug: "NotRequired[str]" # deprecated for v1+ desc: str doc_uri: "NotRequired[str]" vendor: VendorDeclType service_level: "NotRequired[list[ServiceLevelDeclType]]" upstream_version: "NotRequired[str]" class InputPackageManifestType(TypedDict): format: "NotRequired[RuyiPkgFormat]" # v0 fields slug: "NotRequired[str]" kind: "NotRequired[list[PackageKind]]" # mandatory in v0 desc: "NotRequired[str]" # mandatory in v0 doc_uri: "NotRequired[str]" vendor: "NotRequired[VendorDeclType]" # mandatory in v0 # v1+ fields metadata: "NotRequired[PackageMetadataDeclType]" # common fields distfiles: list[DistfileDeclType] binary: "NotRequired[BinaryDeclType]" blob: "NotRequired[BlobDeclType]" source: "NotRequired[SourceDeclType]" toolchain: "NotRequired[ToolchainDeclType]" emulator: "NotRequired[EmulatorDeclType]" provisionable: "NotRequired[ProvisionableDeclType]" class PackageManifestType(TypedDict): format: RuyiPkgFormat kind: list[PackageKind] metadata: PackageMetadataDeclType distfiles: list[DistfileDeclType] binary: "NotRequired[BinaryDeclType]" blob: "NotRequired[BlobDeclType]" source: "NotRequired[SourceDeclType]" toolchain: "NotRequired[ToolchainDeclType]" emulator: "NotRequired[EmulatorDeclType]" provisionable: "NotRequired[ProvisionableDeclType]" class DistfileDecl: def __init__(self, data: DistfileDeclType) -> None: self._data = data @property def name(self) -> str: return self._data["name"] @property def urls(self) -> list[str] | None: return self._data.get("urls") def is_restricted(self, kind: RestrictKind) -> bool: if restricts := self._data.get("restrict"): # account for a common oversight in some existing manifests where # the field was specified as a string instead of a list, in case the # user has not yet synced their repo if isinstance(restricts, str): return kind == restricts return kind in restricts return False @property def size(self) -> int: return self._data["size"] @property def checksums(self) -> dict[str, str]: return self._data["checksums"] def get_checksum(self, kind: str) -> str | None: return self._data["checksums"].get(kind) @property def prefixes_to_unpack(self) -> list[str] | None: return self._data.get("prefixes_to_unpack") @property def strip_components(self) -> int: return self._data.get("strip_components", 1) @property def unpack_method(self) -> UnpackMethod: x = self._data.get("unpack", UnpackMethod.AUTO) if x == UnpackMethod.AUTO: return determine_unpack_method(self.name) return x @property def fetch_restriction(self) -> FetchRestrictionDeclType | None: return self._data.get("fetch_restriction") class BinaryDecl: def __init__(self, data: BinaryDeclType) -> None: self._data = {canonicalize_host_str(d["host"]): d for d in data} @property def data(self) -> dict[str, BinaryFileDeclType]: return self._data def get_distfile_names_for_host(self, host: str | RuyiHost) -> list[str] | None: if data := self._data.get(canonicalize_host_str(host)): return data.get("distfiles") return None @property def is_available_for_current_host(self) -> bool: return str(get_native_host()) in self._data def get_commands_for_host(self, host: str) -> dict[str, str]: if data := self._data.get(canonicalize_host_str(host)): return data.get("commands", {}) return {} class BlobDecl: def __init__(self, data: BlobDeclType) -> None: self._data = data def get_distfile_names(self) -> list[str] | None: return self._data["distfiles"] class SourceDecl: def __init__(self, data: SourceDeclType) -> None: self._data = data def get_distfile_names_for_host(self, host: str | RuyiHost) -> list[str] | None: # currently the host parameter is ignored return self._data["distfiles"] class ToolchainDecl: def __init__(self, data: ToolchainDeclType) -> None: self._data = data self._component_vers_cache: dict[str, str] | None = None # rename "flavors" to "quirks" for compatibility with old data if "quirks" not in self._data and "flavors" in self._data: self._data["quirks"] = self._data["flavors"] del self._data["flavors"] @property def _component_vers(self) -> dict[str, str]: if self._component_vers_cache is None: self._component_vers_cache = { x["name"]: x["version"] for x in self.components } return self._component_vers_cache @property def target(self) -> str: return self._data["target"] @property def target_arch(self) -> str: # TODO: switch to proper mapping later; for now this suffices return self.target.split("-", 1)[0] @property def quirks(self) -> list[str]: return self._data.get("quirks", []) def has_quirk(self, q: str) -> bool: return q in self.quirks def satisfies_quirk_set(self, req: set[str]) -> bool: # req - my_quirks must be the empty set so that my_quirks >= req return len(req.difference(self.quirks)) == 0 @property def components(self) -> Iterable[ToolchainComponentDeclType]: return self._data["components"] def get_component_version(self, name: str) -> str | None: return self._component_vers.get(name) @property def has_binutils(self) -> bool: return self.get_component_version("binutils") is not None @property def has_clang(self) -> bool: return self.get_component_version("clang") is not None @property def has_gcc(self) -> bool: return self.get_component_version("gcc") is not None @property def has_llvm(self) -> bool: return self.get_component_version("llvm") is not None @property def included_sysroot(self) -> str | None: return self._data.get("included_sysroot") class EmulatorProgDecl: def __init__(self, data: EmulatorProgramDeclType) -> None: self.relative_path = data["path"] # have to explicitly annotate the type to please the type checker... self.flavor: EmulatorFlavor = data["flavor"] self.supported_arches = set(data["supported_arches"]) self.binfmt_misc = data.get("binfmt_misc") def get_binfmt_misc_str(self, install_root: os.PathLike[Any]) -> str | None: if self.binfmt_misc is None: return None binpath = os.path.join(install_root, self.relative_path) return self.binfmt_misc.replace("$BIN", binpath) @property def is_qemu(self) -> bool: return self.flavor == "qemu-linux-user" class EmulatorDecl: def __init__(self, data: EmulatorDeclType) -> None: self._data = data self.programs = [EmulatorProgDecl(x) for x in data["programs"]] # rename "flavors" to "quirks" for compatibility with old data if "quirks" not in self._data and "flavors" in self._data: self._data["quirks"] = self._data["flavors"] del self._data["flavors"] @property def quirks(self) -> list[str] | None: return self._data.get("quirks") def list_for_arch(self, arch: str) -> Iterable[EmulatorProgDecl]: for p in self.programs: if arch in p.supported_arches: yield p class ProvisionableDecl: def __init__(self, data: ProvisionableDeclType) -> None: self._data = data @property def partition_map(self) -> PartitionMapDecl: return self._data["partition_map"] @property def strategy(self) -> str: return self._data["strategy"] class PackageMetadataDecl: def __init__(self, data: PackageMetadataDeclType) -> None: self._data = data def _translate_to_manifest_v1(obj: InputPackageManifestType) -> PackageManifestType: fmt = obj.get("format", "") if fmt == "v1": return cast(PackageManifestType, obj) if fmt != "": # unrecognized package format raise RuntimeError(f"unrecognized Ruyi package format: {fmt}") # translate v0 to v1 result = deepcopy(obj) result["format"] = "v1" md: PackageMetadataDeclType = {"desc": "", "vendor": {"name": "", "eula": None}} if "slug" in result: md["slug"] = result["slug"] del result["slug"] if "desc" in result: md["desc"] = result["desc"] del result["desc"] if "vendor" in result: md["vendor"] = result["vendor"] del result["vendor"] if "doc_uri" in result: md["doc_uri"] = result["doc_uri"] del result["doc_uri"] result["metadata"] = md return cast(PackageManifestType, result) class PackageServiceLevel: def __init__(self, data: list[ServiceLevelDeclType]) -> None: self._data = data @property def level(self) -> ServiceLevelKind: for r in self._data: if r["level"] == "untested": continue return r["level"] return "untested" @property def has_known_issues(self) -> bool: for r in self._data: if r["level"] == "known_issue": return True return False @property def known_issues(self) -> Iterator[ServiceLevelDeclType]: for r in self._data: if r["level"] == "known_issue": yield r def render_known_issues( self, msg_store: RepoMessageStore, lang_code: str, ) -> Iterator[str]: for x in self.known_issues: if "msgid" not in x: # malformed known issue declaration, but let's not panic yield "" continue yield msg_store.render_message(x["msgid"], lang_code, x.get("params", {})) class PackageManifest: def __init__( self, doc: tomlkit.TOMLDocument | InputPackageManifestType, ) -> None: self._raw_doc = doc if isinstance(doc, tomlkit.TOMLDocument) else None self._data = _translate_to_manifest_v1(cast(InputPackageManifestType, doc)) if "kind" not in self._data: self._data["kind"] = [k for k in ALL_PACKAGE_KINDS if k in self._data] @classmethod def load_toml(cls, stream: BinaryIO) -> "Self": return cls(tomlkit.load(stream)) @classmethod def load_from_path(cls, p: pathlib.Path) -> "Self": suffix = p.suffix.lower() match suffix: case ".toml": with open(p, "rb") as fp: return cls.load_toml(fp) case _: raise RuntimeError( f"unrecognized package manifest file extension: '{p.suffix}'" ) def to_raw(self) -> PackageManifestType: return deepcopy(self._data) @property def raw_doc(self) -> tomlkit.TOMLDocument | None: return self._raw_doc @property def slug(self) -> str | None: return self._data["metadata"].get("slug") @property def kind(self) -> list[PackageKind]: return self._data["kind"] def has_kind(self, k: PackageKind) -> bool: return k in self._data["kind"] @property def desc(self) -> str: return self._data["metadata"]["desc"] @property def doc_uri(self) -> str | None: return self._data["metadata"].get("doc_uri") @property def vendor_name(self) -> str: return self._data["metadata"]["vendor"]["name"] @property def upstream_version(self) -> str | None: return self._data["metadata"].get("upstream_version") # TODO: vendor_eula @property def service_level(self) -> PackageServiceLevel: return PackageServiceLevel(self._data["metadata"].get("service_level", [])) @cached_property def distfiles(self) -> dict[str, DistfileDecl]: return {x["name"]: DistfileDecl(x) for x in self._data["distfiles"]} @cached_property def binary_metadata(self) -> BinaryDecl | None: if not self.has_kind("binary"): return None if "binary" not in self._data: return None return BinaryDecl(self._data["binary"]) @cached_property def blob_metadata(self) -> BlobDecl | None: if not self.has_kind("blob"): return None if "blob" not in self._data: return None return BlobDecl(self._data["blob"]) @cached_property def source_metadata(self) -> SourceDecl | None: if not self.has_kind("source"): return None if "source" not in self._data: return None return SourceDecl(self._data["source"]) @cached_property def toolchain_metadata(self) -> ToolchainDecl | None: if not self.has_kind("toolchain"): return None if "toolchain" not in self._data: return None return ToolchainDecl(self._data["toolchain"]) @cached_property def emulator_metadata(self) -> EmulatorDecl | None: if not self.has_kind("emulator"): return None if "emulator" not in self._data: return None return EmulatorDecl(self._data["emulator"]) @cached_property def provisionable_metadata(self) -> ProvisionableDecl | None: if not self.has_kind("provisionable"): return None if "provisionable" not in self._data: return None return ProvisionableDecl(self._data["provisionable"]) class BoundPackageManifest(PackageManifest): def __init__( self, category: str, name: str, ver: str, data: InputPackageManifestType, repo: "MetadataRepo", ) -> None: super().__init__(data) self.category = category self.name = name self.ver = ver self._semver = Version.parse(ver) self.repo = repo @property def repo_id(self) -> str: return self.repo.repo_id @property def semver(self) -> Version: return self._semver @property def is_prerelease(self) -> bool: return is_prerelease(self._semver) @property def name_for_installation(self) -> str: return f"{self.name}-{self.ver}" PRERELEASE_TAGS_RE: Final = re.compile(r"^(?:alpha|beta|pre|rc)") def is_prerelease(sv: Version) -> bool: if sv.prerelease is None: return False # only consider "(alpha|beta|pre|rc).*" versions as prerelease, to accommodate # various semver "hacks" as incorporated by upstream(s), and ourselves # ("ruyi.YYYYMMDD" are used as ordinary datestamps that affects sorting # order, in contrast to build tags). # see https://github.com/ruyisdk/ruyi/issues/156 return PRERELEASE_TAGS_RE.match(sv.prerelease) is not None ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/profile.py000066400000000000000000000250511520522431500212740ustar00rootroot00000000000000from os import PathLike from typing import ( Any, Iterable, Mapping, Protocol, Sequence, TypedDict, TypeGuard, TYPE_CHECKING, cast, ) if TYPE_CHECKING: from typing_extensions import NotRequired from ..pluginhost.ctx import PluginHostContext from ..pluginhost.traits import SupportsEvalFunction from .entity_provider import BaseEntityProvider from .pkg_manifest import EmulatorFlavor class InvalidProfilePluginError(RuntimeError): def __init__(self, s: str) -> None: super().__init__(f"invalid arch profile plugin: {s}") def validate_list_str(x: object) -> TypeGuard[list[str]]: if not isinstance(x, list): return False x = cast(list[object], x) return all(isinstance(y, str) for y in x) def validate_list_str_or_none(x: object) -> TypeGuard[list[str] | None]: return True if x is None else validate_list_str(x) def validate_dict_str_str(x: object) -> TypeGuard[dict[str, str]]: if not isinstance(x, dict): return False for k, v in cast(dict[object, object], x).items(): if not isinstance(k, str) or not isinstance(v, str): return False return True class PluginProfileProvider: def __init__( self, phctx: PluginHostContext[Any, SupportsEvalFunction], plugin_id: str, ) -> None: self._phctx = phctx self._plugin_id = plugin_id self._ev = phctx.make_evaluator() def _must_get(self, name: str) -> object: if v := self._phctx.get_from_plugin(self._plugin_id, name): return v raise InvalidProfilePluginError( f"'{name}' not found in plugin '{self._plugin_id}'" ) def list_all_profile_ids(self) -> list[str]: fn = self._must_get("list_all_profile_ids_v1") ret = self._ev.eval_function(fn) if not validate_list_str(ret): raise InvalidProfilePluginError( "list_all_profile_ids must return list[str]" ) return ret def list_needed_quirks(self, profile_id: str) -> list[str] | None: # For backward compatibility, try "list_needed_quirks_v1" first, then # fall back to "list_needed_flavors_v1" if the former is not available. fn = self._phctx.get_from_plugin(self._plugin_id, "list_needed_quirks_v1") if fn is None: fn = self._must_get("list_needed_flavors_v1") ret = self._ev.eval_function(fn, profile_id) if not validate_list_str_or_none(ret): raise InvalidProfilePluginError( "list_needed_quirks_v1 must return list[str] | None" ) return ret def get_common_flags(self, profile_id: str, toolchain_quirks: list[str]) -> str: result = self._maybe_get_common_flags_v2(profile_id, toolchain_quirks) if result is not None: return result return self._get_common_flags_v1(profile_id) def _get_common_flags_v1(self, profile_id: str) -> str: fn = self._must_get("get_common_flags_v1") ret = self._ev.eval_function(fn, profile_id) if not isinstance(ret, str): raise InvalidProfilePluginError("get_common_flags_v1 must return str") return ret def _maybe_get_common_flags_v2( self, profile_id: str, toolchain_flavors: list[str], ) -> str | None: fn = self._phctx.get_from_plugin(self._plugin_id, "get_common_flags_v2") if fn is None: return None ret = self._ev.eval_function(fn, profile_id, toolchain_flavors) if not isinstance(ret, str): raise InvalidProfilePluginError("get_common_flags_v2 must return str") return ret def get_needed_emulator_pkg_flavors( self, profile_id: str, flavor: EmulatorFlavor, ) -> Iterable[str]: fn = self._must_get("get_needed_emulator_pkg_flavors_v1") ret = self._ev.eval_function( fn, profile_id, flavor, ) if not validate_list_str(ret): raise InvalidProfilePluginError( "get_needed_emulator_pkg_flavors_v1 must return list[str]" ) return ret def check_emulator_flavor( self, profile_id: str, flavor: EmulatorFlavor, emulator_pkg_flavors: list[str] | None, ) -> bool: fn = self._must_get("check_emulator_flavor_v1") ret = self._ev.eval_function( fn, profile_id, flavor, emulator_pkg_flavors, ) if not isinstance(ret, bool): raise InvalidProfilePluginError("check_emulator_flavor_v1 must return bool") return ret def get_env_config_for_emu_flavor( self, profile_id: str, flavor: EmulatorFlavor, sysroot: PathLike[Any] | None, ) -> dict[str, str] | None: fn = self._must_get("get_env_config_for_emu_flavor_v1") ret = self._ev.eval_function( fn, profile_id, flavor, str(sysroot) if sysroot is not None else None, ) if not validate_dict_str_str(ret): raise InvalidProfilePluginError( "get_env_config_for_emu_flavor_v1 must return dict[str, str]" ) return ret class ProfileProxy: def __init__( self, provider: PluginProfileProvider, arch: str, profile_id: str, ) -> None: self._provider = provider self._arch = arch self._id = profile_id @property def arch(self) -> str: return self._arch @property def id(self) -> str: return self._id @property def need_quirks(self) -> set[str]: r = self._provider.list_needed_quirks(self._id) return set(r) if r else set() def get_common_flags(self, toolchain_flavors: list[str]) -> str: return self._provider.get_common_flags(self._id, toolchain_flavors) def get_needed_emulator_pkg_flavors( self, flavor: EmulatorFlavor, ) -> set[str]: return set(self._provider.get_needed_emulator_pkg_flavors(self._id, flavor)) def check_emulator_flavor( self, flavor: EmulatorFlavor, emulator_pkg_flavors: list[str] | None, ) -> bool: return self._provider.check_emulator_flavor( self._id, flavor, emulator_pkg_flavors ) def get_env_config_for_emu_flavor( self, flavor: EmulatorFlavor, sysroot: PathLike[Any] | None, ) -> dict[str, str] | None: return self._provider.get_env_config_for_emu_flavor(self._id, flavor, sysroot) # # Protocols # # MetadataRepo is defined in repo.py, but we don't want to import repo.py here # to avoid circular import. Instead, we just describe the methods and properties # that we need from MetadataRepo with a Protocol. class ProvidesProfiles(Protocol): def get_supported_arches(self) -> list[str]: ... def get_profile_for_arch(self, arch: str, name: str) -> ProfileProxy | None: ... def iter_profiles_for_arch(self, arch: str) -> Iterable[ProfileProxy]: ... # # Entity type and schema for profile entities # PROFILE_V1_ENTITY_TYPE = "profile-v1" PROFILE_V1_ENTITY_TYPE_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema#", "required": ["profile-v1"], "properties": { "profile-v1": { "type": "object", "properties": { "id": {"type": "string"}, "display_name": {"type": "string"}, "name": {"type": "string"}, "arch": {"type": "string"}, "needed_toolchain_quirks": { "type": "array", "items": {"type": "string"}, }, "toolchain_common_flags_str": {"type": "string"}, }, "required": [ "id", "display_name", "name", "arch", "needed_toolchain_quirks", "toolchain_common_flags_str", ], }, "related": { "type": "array", "description": "List of related entity references", "items": {"type": "string", "pattern": "^.+:.+"}, }, "unique_among_type_during_traversal": { "type": "boolean", "description": "Whether this entity should be unique among all entities of the same type during traversal", }, }, } class ProfileV1EntityData(TypedDict): id: str display_name: str name: str arch: str needed_toolchain_quirks: list[str] toolchain_common_flags_str: str ProfileV1Entity = TypedDict( "ProfileV1Entity", { "profile-v1": ProfileV1EntityData, "related": "NotRequired[list[str]]", "unique_among_type_during_traversal": "NotRequired[bool]", }, total=False, ) class ProfileEntityProvider(BaseEntityProvider): def __init__(self, provider: ProvidesProfiles) -> None: super().__init__() self._provider = provider def discover_schemas(self) -> dict[str, object]: return { PROFILE_V1_ENTITY_TYPE: PROFILE_V1_ENTITY_TYPE_SCHEMA, } def load_entities( self, entity_types: Sequence[str], ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: result: dict[str, Mapping[str, Mapping[str, Any]]] = {} for ty in entity_types: if ty == PROFILE_V1_ENTITY_TYPE: result[ty] = _load_profile_v1_entities(self._provider) return result def _load_profile_v1_entities(provider: ProvidesProfiles) -> dict[str, ProfileV1Entity]: result: dict[str, ProfileV1Entity] = {} for arch in provider.get_supported_arches(): result.update(_load_profile_v1_entities_for_arch(provider, arch)) return result def _load_profile_v1_entities_for_arch( provider: ProvidesProfiles, arch: str, ) -> dict[str, ProfileV1Entity]: result: dict[str, ProfileV1Entity] = {} for profile in provider.iter_profiles_for_arch(arch): full_name = profile.id relations = [f"arch:{arch}"] needed_toolchain_quirks = sorted(profile.need_quirks) result[profile.id] = { "profile-v1": { "id": profile.id, "display_name": full_name, "name": profile.id, "arch": profile.arch, "needed_toolchain_quirks": needed_toolchain_quirks, "toolchain_common_flags_str": profile.get_common_flags( needed_toolchain_quirks, ), }, "related": relations, } return result ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/profile_cli.py000066400000000000000000000025371520522431500221270ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..i18n import _ from .list_cli import ListCommand if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class ListProfilesCommand( ListCommand, cmd="profiles", help=_("List all available profiles"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: logger = cfg.logger mr = cfg.repo for arch in mr.get_supported_arches(): for p in mr.iter_profiles_for_arch(arch): if not p.need_quirks: logger.stdout( _("{profile_id} (arch: [green]{arch}[/])").format( profile_id=p.id, arch=arch, ) ) continue logger.stdout( _( "{profile_id} (arch: [green]{arch}[/], needs quirks: [yellow]{need_quirks}[/])" ).format( profile_id=p.id, arch=arch, need_quirks=", ".join(sorted(p.need_quirks)), ) ) return 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/protocols.py000066400000000000000000000034511520522431500216600ustar00rootroot00000000000000from typing import Iterable, Protocol from .pkg_manifest import BoundPackageManifest class ProvidesPackageManifests(Protocol): """A protocol that defines methods for providing package manifests.""" def get_pkg( self, name: str, category: str, ver: str, ) -> BoundPackageManifest | None: """Returns the package manifest by exact match, or None if not found.""" ... def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]: """Iterates over all package manifests provided by this store.""" ... def iter_pkgs( self, ) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: """Iterates over all package manifests provided by this store, returning ``(category, package_name, pkg_manifests_by_versions)``.""" ... def iter_pkg_vers( self, name: str, category: str | None = None, ) -> Iterable[BoundPackageManifest]: """Iterates over all versions of a certain package provided by this store, specified by name and optionally category.""" ... def get_pkg_latest_ver( self, name: str, category: str | None = None, include_prerelease_vers: bool = False, ) -> BoundPackageManifest: """Returns the latest version of a package provided by this store, specified by name and optionally category. If ``include_prerelease_vers`` is True, it will also consider prerelease versions. Raises KeyError if no such package exists.""" ... # To be removed later along with slug support def get_pkg_by_slug(self, slug: str) -> BoundPackageManifest | None: """Returns the package with the specified slug from this store, or None if not found.""" ... ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/recipe_project.py000066400000000000000000000134631520522431500226350ustar00rootroot00000000000000"""Recipe-project discovery and marker-file parsing. A *recipe project* is a directory tree with a ``ruyi-build-recipes.toml`` marker at its root. Build recipes (``.star`` files) anywhere beneath the root may ``load()`` each other via the ``ruyi-build://`` scheme. This module owns the on-disk representation: discovery of the project root given a recipe file, parsing the marker, and a realpath-based join helper used by loaders and the build runner. """ from __future__ import annotations from dataclasses import dataclass, field import pathlib import tomllib from typing import Any MARKER_FILENAME = "ruyi-build-recipes.toml" SUPPORTED_FORMATS = frozenset({"v1"}) class RecipeProjectError(RuntimeError): """Raised for any ruyi-build-recipes.toml or project-layout problem.""" @dataclass(frozen=True) class RecipeProject: """A discovered recipe project. Attributes: root: Absolute, realpath-resolved path to the project root. name: Human-readable project name from the marker, or the root directory name if unspecified. output_dir: Project-relative directory for build outputs; absolute path computed as ``root / output_dir``. extra_artifact_roots: Absolute allow-list for artifact roots outside the project tree. Each entry is realpath-resolved. """ root: pathlib.Path name: str output_dir: pathlib.Path extra_artifact_roots: tuple[pathlib.Path, ...] = field(default_factory=tuple) @property def marker_path(self) -> pathlib.Path: return self.root / MARKER_FILENAME def discover_recipe_project(recipe_file: pathlib.Path) -> RecipeProject: """Find the recipe project containing ``recipe_file``. Walks the lexical parents of ``recipe_file`` looking for the marker file. The realpath of the recipe file must remain within the realpath of the project root (defense against symlink escape). Raises :class:`RecipeProjectError` if no marker is found or if the realpath check fails. """ if not recipe_file.exists(): raise RecipeProjectError(f"recipe file not found: {recipe_file}") recipe_abs = recipe_file.absolute() resolved_recipe = recipe_file.resolve(strict=True) for candidate in (recipe_abs.parent, *recipe_abs.parents): marker = candidate / MARKER_FILENAME if marker.is_file(): root = candidate.resolve(strict=True) if not resolved_recipe.is_relative_to(root): raise RecipeProjectError( f"recipe file {recipe_file} escapes its project root {root} " f"after realpath resolution" ) return _parse_marker(root, marker) raise RecipeProjectError( f"no {MARKER_FILENAME} found in any parent of {recipe_file}" ) def _parse_marker(root: pathlib.Path, marker: pathlib.Path) -> RecipeProject: try: with open(marker, "rb") as f: data = tomllib.load(f) except tomllib.TOMLDecodeError as e: raise RecipeProjectError(f"malformed {MARKER_FILENAME} at {marker}: {e}") from e fmt = data.get("format") if fmt not in SUPPORTED_FORMATS: raise RecipeProjectError( f"{marker}: unsupported or missing 'format' (got {fmt!r}; " f"expected one of {sorted(SUPPORTED_FORMATS)})" ) project_section: Any = data.get("project", {}) if not isinstance(project_section, dict): raise RecipeProjectError(f"{marker}: [project] must be a table") name = project_section.get("name", root.name) if not isinstance(name, str) or not name: raise RecipeProjectError(f"{marker}: project.name must be a non-empty string") output_dir_raw = project_section.get("output_dir", "out") if not isinstance(output_dir_raw, str) or not output_dir_raw: raise RecipeProjectError( f"{marker}: project.output_dir must be a non-empty string" ) output_dir_rel = pathlib.PurePosixPath(output_dir_raw) if output_dir_rel.is_absolute(): raise RecipeProjectError( f"{marker}: project.output_dir must be a project-relative path" ) output_dir = (root / output_dir_rel).resolve() if not output_dir.is_relative_to(root): raise RecipeProjectError( f"{marker}: project.output_dir escapes the project root" ) extra_raw = project_section.get("extra_artifact_roots", []) if not isinstance(extra_raw, list) or not all( isinstance(x, str) for x in extra_raw ): raise RecipeProjectError( f"{marker}: project.extra_artifact_roots must be a list of strings" ) extras: list[pathlib.Path] = [] for entry in extra_raw: p = pathlib.Path(entry) if not p.is_absolute(): raise RecipeProjectError( f"{marker}: extra_artifact_roots entries must be absolute paths " f"(got {entry!r})" ) extras.append(p.resolve()) return RecipeProject( root=root, name=name, output_dir=output_dir, extra_artifact_roots=tuple(extras), ) def safe_join(root: pathlib.Path, rel: str) -> pathlib.Path: """Join ``rel`` onto ``root`` and verify the result stays inside ``root``. Used when resolving ``ruyi-build://``-scheme paths and ``ctx.repo_path`` calls. Accepts either forward-slash relative paths or plain relative paths; absolute inputs are rejected. """ p = pathlib.PurePosixPath(rel) if p.is_absolute(): raise RecipeProjectError( f"safe_join: absolute paths are not allowed (got {rel!r})" ) joined = (root / p).resolve() root_resolved = root.resolve() if not joined.is_relative_to(root_resolved): raise RecipeProjectError( f"safe_join: {rel!r} escapes recipe project root {root_resolved}" ) return joined ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/repo.py000066400000000000000000000733031520522431500206040ustar00rootroot00000000000000import glob from dataclasses import dataclass from functools import cached_property import itertools import os import os.path import pathlib import re import tomllib from typing import ( Any, Final, Iterable, Mapping, Sequence, TypedDict, TypeGuard, TYPE_CHECKING, cast, ) from urllib import parse from pygit2 import clone_repository from pygit2.repository import Repository from ..i18n import _ from ..log import RuyiLogger from ..pluginhost.ctx import PluginHostContext from ..telemetry.scope import TelemetryScopeConfig from ..utils.git import RemoteGitProgressIndicator, pull_ff_or_die from ..utils.porcelain import PorcelainEntity, PorcelainEntityType from ..utils.url import urljoin_for_sure from .entity import EntityStore from .entity_provider import BaseEntityProvider, FSEntityProvider from .msg import RepoMessageStore from .news_store import NewsItemStore from .pkg_manifest import ( BoundPackageManifest, DistfileDecl, InputPackageManifestType, is_prerelease, ) from .profile import PluginProfileProvider, ProfileEntityProvider, ProfileProxy from .protocols import ProvidesPackageManifests if TYPE_CHECKING: from typing_extensions import NotRequired # for avoiding circular import from ..config import GlobalConfig REPO_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9_-]*$") DEFAULT_REPO_ID: Final = "ruyisdk" DEFAULT_REPO_NAME: Final = "RuyiSDK official repository" DEFAULT_REPO_PRIORITY: Final = 0 @dataclass(frozen=True) class RepoEntry: """A configured pointer to a single metadata repository.""" id: str name: str remote: str | None branch: str local_path: str | None priority: int active: bool is_system: bool = False def resolve_root(self, cache_root: str | os.PathLike[str]) -> str: """Return the local checkout path for this repo entry.""" if self.local_path is not None: return self.local_path return os.path.join(cache_root, "repos", self.id) @classmethod def from_legacy_config(cls, gc: "GlobalConfig") -> "RepoEntry": """Construct the default RepoEntry from the existing GlobalConfig repo fields (backward-compatible single-repo path).""" from ..config import DEFAULT_REPO_URL, DEFAULT_REPO_BRANCH return cls( id=DEFAULT_REPO_ID, name=DEFAULT_REPO_NAME, remote=gc.override_repo_url or DEFAULT_REPO_URL, branch=gc.override_repo_branch or DEFAULT_REPO_BRANCH, local_path=gc.override_repo_dir, priority=DEFAULT_REPO_PRIORITY, active=True, ) def make_metadata_repo(self, gc: "GlobalConfig") -> "MetadataRepo": """Construct a MetadataRepo from this entry's fields.""" root = self.resolve_root(str(gc.cache_root)) return MetadataRepo( gc, root=root, remote=self.remote or "", branch=self.branch, repo_id=self.id, repo_name=self.name, ) def to_porcelain(self) -> "PorcelainRepoEntryV1": return { "ty": PorcelainEntityType.RepoEntryV1, "id": self.id, "name": self.name, "remote": self.remote, "branch": self.branch, "local_path": self.local_path, "priority": self.priority, "active": self.active, "is_system": self.is_system, } class RepoConfigV0Type(TypedDict): dist: str doc_uri: "NotRequired[str]" def validate_repo_config_v0(x: object) -> TypeGuard[RepoConfigV0Type]: if not isinstance(x, dict): return False if "ruyi-repo" in x: return False if "dist" not in x or not isinstance(x["dist"], str): return False if "doc_uri" in x and not isinstance(x["doc_uri"], str): return False return True class RepoConfigV1Repo(TypedDict): doc_uri: "NotRequired[str]" id: "NotRequired[str]" name: "NotRequired[str]" class RepoConfigV1Mirror(TypedDict): id: str urls: list[str] class RepoConfigV1Telemetry(TypedDict): id: str scope: TelemetryScopeConfig url: str RepoConfigV1Type = TypedDict( "RepoConfigV1Type", { "ruyi-repo": str, "repo": "NotRequired[RepoConfigV1Repo]", "mirrors": list[RepoConfigV1Mirror], "telemetry": "NotRequired[list[RepoConfigV1Telemetry]]", }, ) def validate_repo_config_v1(x: object) -> TypeGuard[RepoConfigV1Type]: if not isinstance(x, dict): return False x = cast(dict[str, object], x) if x.get("ruyi-repo", "") != "v1": return False return True MIRROR_ID_RUYI_DIST: Final = "ruyi-dist" class RepoConfig: def __init__( self, mirrors: list[RepoConfigV1Mirror], repo: RepoConfigV1Repo | None, telemetry_apis: list[RepoConfigV1Telemetry] | None, ) -> None: self.mirrors = {x["id"]: x["urls"] for x in mirrors} self.repo = repo self.telemetry_apis: dict[str, RepoConfigV1Telemetry] if telemetry_apis is not None: self.telemetry_apis = {x["id"]: x for x in telemetry_apis} else: self.telemetry_apis = {} @classmethod def from_object(cls, obj: object) -> "RepoConfig": if not isinstance(obj, dict): raise ValueError("repo config must be a dict") if "ruyi-repo" in obj: return cls.from_v1(cast(object, obj)) return cls.from_v0(cast(object, obj)) @classmethod def from_v0(cls, obj: object) -> "RepoConfig": if not validate_repo_config_v0(obj): # TODO: more detail in the error message raise RuntimeError("malformed v0 repo config") v1_mirrors: list[RepoConfigV1Mirror] = [ { "id": MIRROR_ID_RUYI_DIST, "urls": [urljoin_for_sure(obj["dist"], "dist/")], }, ] v1_repo: RepoConfigV1Repo | None = None if "doc_uri" in obj: v1_repo = { "doc_uri": obj["doc_uri"], } return cls(v1_mirrors, v1_repo, None) @classmethod def from_v1(cls, obj: object) -> "RepoConfig": if not validate_repo_config_v1(obj): # TODO: more detail in the error message raise RuntimeError("malformed v1 repo config") return cls(obj["mirrors"], obj.get("repo"), obj.get("telemetry")) @property def repo_id(self) -> str: if self.repo is not None and "id" in self.repo: return self.repo["id"] return "ruyisdk" @property def repo_name(self) -> str: if self.repo is not None and "name" in self.repo: return self.repo["name"] return "RuyiSDK official repository" def get_dist_urls_for_file(self, logger: RuyiLogger, url: str) -> list[str]: u = parse.urlparse(url) path = u.path.lstrip("/") match u.scheme: case "": return self.get_mirror_urls_for_file(MIRROR_ID_RUYI_DIST, path) case "mirror": return self.get_mirror_urls_for_file(u.netloc, path) case "http" | "https": # pass-through known protocols return [url] case _: # deny others logger.W( _("unrecognized dist URL scheme: {scheme}").format(scheme=u.scheme) ) return [] def get_mirror_urls_for_file(self, mirror_id: str, path: str) -> list[str]: mirror_urls = self.mirrors.get(mirror_id, []) return [parse.urljoin(base, path) for base in mirror_urls] def get_telemetry_api_url(self, scope: TelemetryScopeConfig) -> str | None: for api_decl in self.telemetry_apis.values(): if api_decl.get("scope", "") == scope: return api_decl.get("url", None) return None class ArchProfileStore: def __init__(self, phctx: PluginHostContext[Any, Any], arch: str) -> None: self._arch = arch plugin_id = f"ruyi-profile-{arch}" self._provider = PluginProfileProvider(phctx, plugin_id) self._init_cache() def _init_cache(self) -> None: self._profiles_cache: dict[str, ProfileProxy] = {} for profile_id in self._provider.list_all_profile_ids(): self._profiles_cache[profile_id] = ProfileProxy( self._provider, self._arch, profile_id ) def __contains__(self, profile_id: str) -> bool: return profile_id in self._profiles_cache def __getitem__(self, profile_id: str) -> ProfileProxy: try: return self._profiles_cache[profile_id] except KeyError as e: raise KeyError( f"profile '{profile_id}' is not supported by this arch" ) from e def get(self, profile_id: str) -> ProfileProxy | None: return self._profiles_cache.get(profile_id) def iter_profiles(self) -> Iterable[ProfileProxy]: return self._profiles_cache.values() class MetadataRepo(ProvidesPackageManifests): def __init__( self, gc: "GlobalConfig", *, root: str, remote: str, branch: str, repo_id: str = DEFAULT_REPO_ID, repo_name: str = DEFAULT_REPO_NAME, ) -> None: self._gc = gc self.root = root self.remote = remote self.branch = branch self._repo_id = repo_id self._repo_name = repo_name self.repo: Repository | None = None self._cfg: RepoConfig | None = None self._cfg_initialized = False self._pkgs: dict[str, dict[str, BoundPackageManifest]] = {} self._categories: dict[str, dict[str, dict[str, BoundPackageManifest]]] = {} self._slug_cache: dict[str, BoundPackageManifest] = {} self._supported_arches: set[str] | None = None self._arch_profile_stores: dict[str, ArchProfileStore] = {} self._news_cache: NewsItemStore | None = None self._plugin_host_ctx = PluginHostContext.new( gc.logger, self.plugin_root, locale=gc.lang_code, message_store_factory=lambda: self.messages, ) self._plugin_fn_evaluator = self._plugin_host_ctx.make_evaluator() @classmethod def from_global_config(cls, gc: "GlobalConfig") -> "MetadataRepo": """Factory that preserves the current single-repo construction path. All existing call sites that used ``MetadataRepo(gc)`` should use this instead. .. deprecated:: Use ``RepoEntry.make_metadata_repo()`` or the CompositeRepo via ``GlobalConfig.repo`` instead. """ import warnings warnings.warn( "MetadataRepo.from_global_config() is deprecated; " "use RepoEntry.make_metadata_repo() or GlobalConfig.repo instead", DeprecationWarning, stacklevel=2, ) return cls( gc, root=gc.get_repo_dir(), remote=gc.get_repo_url(), branch=gc.get_repo_branch(), ) @property def repo_id(self) -> str: return self._repo_id @property def repo_name(self) -> str: return self._repo_name @property def logger(self) -> RuyiLogger: return self._gc.logger @property def plugin_root(self) -> pathlib.Path: return pathlib.Path(self.root) / "plugins" def iter_plugin_ids(self) -> Iterable[str]: try: for p in self.plugin_root.iterdir(): if p.is_dir(): yield p.name except (FileNotFoundError, NotADirectoryError): pass def get_from_plugin(self, plugin_id: str, key: str) -> object | None: return self._plugin_host_ctx.get_from_plugin(plugin_id, key) def eval_plugin_fn( self, function: object, *args: object, **kwargs: object, ) -> object: """Evaluates a function from a plugin. NOTE: There is security implication for the unsandboxed plugin backend, which provides **NO GUARDS** against arbitrary inputs for the ``function`` argument because there is **no sandbox**.""" return self._plugin_fn_evaluator.eval_function(function, *args, **kwargs) def ensure_git_repo(self) -> Repository: if self.repo is not None: return self.repo if os.path.exists(self.root): self.repo = Repository(self.root) return self.repo self.logger.I( _("the package repository does not exist at [yellow]{root}[/]").format( root=self.root ) ) self.logger.I( _("cloning from [cyan link={remote}]{remote}[/]").format(remote=self.remote) ) with RemoteGitProgressIndicator() as pr: repo = clone_repository( self.remote, self.root, checkout_branch=self.branch, callbacks=pr, ) # pygit2's type info is incomplete as of 1.16.0, and pyright # will not look at the typeshed stub for the appropriate signature # because pygit2 has the py.typed marker. Workaround the error for # now by explicitly casting to the right runtime type. self.repo = cast(Repository, repo) # type: ignore[redundant-cast] # reinit config after cloning self._cfg_initialized = False self._read_config(False) return self.repo def sync(self) -> None: if not self.remote: # Local-only repo: no git remote to sync from; just reload metadata. self._gc.logger.D( _("skipping sync for local-only repo '{id}'").format( id=self._repo_id, ) ) self._cfg_initialized = False self._read_config(False) return self._gc.logger.I(_("updating the package repository")) repo = self.ensure_git_repo() # only manage the repo settings on the user's behalf if the user # has not overridden the repo directory themselves allow_auto_management = not self._gc.have_overridden_repo_dir pull_ff_or_die( self.logger, repo, "origin", self.remote, self.branch, allow_auto_management=allow_auto_management, ) self._gc.logger.I(_("package repository is updated")) @property def global_config(self) -> "GlobalConfig": return self._gc @property def config(self) -> RepoConfig: x = self._read_config(True) assert x is not None return x @property def maybe_config(self) -> RepoConfig | None: """Like ``config``, but does not pull down the repo in case the repo is not locally present at invocation time.""" return self._read_config(False) def _read_config(self, ensure_if_not_existing: bool) -> RepoConfig | None: if self._cfg_initialized: return self._cfg if ensure_if_not_existing: self.ensure_git_repo() # we can read the config file directly because we're operating from a # working tree (as opposed to a bare repo) # # this is a fake loop (that "loops" only once) # here it's only for being able to use break's while True: try: with open(os.path.join(self.root, "config.toml"), "rb") as fp: obj = tomllib.load(fp) break except FileNotFoundError: pass self._cfg_initialized = True return None self._cfg_initialized = True self._cfg = RepoConfig.from_object(obj) return self._cfg @cached_property def messages(self) -> RepoMessageStore: self.ensure_git_repo() obj: dict[str, object] = {} try: with open(os.path.join(self.root, "messages.toml"), "rb") as fp: obj = tomllib.load(fp) except FileNotFoundError: pass return RepoMessageStore.from_object(obj) def iter_pkg_manifests( self, ensure_repo: bool = True, ) -> Iterable[BoundPackageManifest]: if ensure_repo: self.ensure_git_repo() # Try the name "packages" first, because it's possible that some # non-manifest things will arrive in the package directories in the # future. Keep probing the old "manifests" name until 0.51.0. # # TODO: remove the "manifests" alias after 0.50.0 branch is cut. for pkg_dir_name in ("packages", "manifests"): manifests_dir = os.path.join(self.root, pkg_dir_name) try: manifests_dir_iter = os.scandir(manifests_dir) except FileNotFoundError: continue for f in manifests_dir_iter: if not f.is_dir(): continue yield from self._iter_pkg_manifests_from_category(f.path) # Only process the first matched directory return def _iter_pkg_manifests_from_category( self, category_dir: str, ) -> Iterable[BoundPackageManifest]: self.ensure_git_repo() category = os.path.basename(category_dir) # all valid semver strings start with a number for f in glob.iglob("*/[0-9]*.toml", root_dir=category_dir): pkg_name, pkg_ver = os.path.split(f) pkg_ver = pkg_ver[:-5] # strip the ".toml" suffix with open(os.path.join(category_dir, f), "rb") as fp: yield BoundPackageManifest( category, pkg_name, pkg_ver, cast(InputPackageManifestType, tomllib.load(fp)), self, ) def get_supported_arches(self) -> list[str]: if self._supported_arches is not None: return list(self._supported_arches) res: set[str] = set() for plugin_id in self.iter_plugin_ids(): if plugin_id.startswith("ruyi-profile-"): res.add(plugin_id[13:]) self._supported_arches = res return list(res) def get_profile(self, name: str) -> ProfileProxy | None: # TODO: deprecate this after making sure every call site has gained # arch-awareness for arch in self.get_supported_arches(): store = self.ensure_profile_store_for_arch(arch) if p := store.get(name): return p return None def get_profile_for_arch(self, arch: str, name: str) -> ProfileProxy | None: store = self.ensure_profile_store_for_arch(arch) return store.get(name) def iter_profiles_for_arch(self, arch: str) -> Iterable[ProfileProxy]: store = self.ensure_profile_store_for_arch(arch) return store.iter_profiles() def ensure_profile_store_for_arch(self, arch: str) -> ArchProfileStore: if arch in self._arch_profile_stores: return self._arch_profile_stores[arch] self.ensure_git_repo() store = ArchProfileStore(self._plugin_host_ctx, arch) self._arch_profile_stores[arch] = store return store def ensure_pkg_cache( self, ensure_repo: bool = True, ) -> None: if self._pkgs: return if ensure_repo: self.ensure_git_repo() cache_by_name: dict[str, dict[str, BoundPackageManifest]] = {} cache_by_category: dict[str, dict[str, dict[str, BoundPackageManifest]]] = {} slug_cache: dict[str, BoundPackageManifest] = {} for pm in self.iter_pkg_manifests(ensure_repo=ensure_repo): if pm.name not in cache_by_name: cache_by_name[pm.name] = {} cache_by_name[pm.name][pm.ver] = pm if pm.category not in cache_by_category: cache_by_category[pm.category] = {pm.name: {}} if pm.name not in cache_by_category[pm.category]: cache_by_category[pm.category][pm.name] = {} cache_by_category[pm.category][pm.name][pm.ver] = pm if pm.slug: slug_cache[pm.slug] = pm self._pkgs = cache_by_name self._categories = cache_by_category self._slug_cache = slug_cache def iter_pkgs( self, ensure_repo: bool = True, ) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: if not self._pkgs: self.ensure_pkg_cache(ensure_repo=ensure_repo) for cat, cat_pkgs in self._categories.items(): for pkg_name, pkg_vers in cat_pkgs.items(): yield (cat, pkg_name, pkg_vers) def get_pkg_by_slug( self, slug: str, ensure_repo: bool = True, ) -> BoundPackageManifest | None: if not self._pkgs: self.ensure_pkg_cache(ensure_repo=ensure_repo) return self._slug_cache.get(slug) def iter_pkg_vers( self, name: str, category: str | None = None, ensure_repo: bool = True, ) -> Iterable[BoundPackageManifest]: if not self._pkgs: self.ensure_pkg_cache(ensure_repo=ensure_repo) if category is not None: return self._categories[category][name].values() return self._pkgs[name].values() def get_pkg( self, name: str, category: str, ver: str, *, ensure_repo: bool = True, ) -> BoundPackageManifest | None: if not self._pkgs: self.ensure_pkg_cache(ensure_repo=ensure_repo) try: return self._categories[category][name][ver] except KeyError: return None def get_pkg_latest_ver( self, name: str, category: str | None = None, include_prerelease_vers: bool = False, ensure_repo: bool = True, ) -> BoundPackageManifest: if not self._pkgs: self.ensure_pkg_cache(ensure_repo=ensure_repo) if category is not None: pkgset = self._categories[category] else: pkgset = self._pkgs all_semvers = [pm.semver for pm in pkgset[name].values()] if not include_prerelease_vers: all_semvers = [sv for sv in all_semvers if not is_prerelease(sv)] latest_ver = max(all_semvers) return pkgset[name][str(latest_ver)] def get_distfile_urls(self, decl: DistfileDecl) -> list[str]: urls_to_expand: list[str] = [] if not decl.is_restricted("mirror"): urls_to_expand.append(f"mirror://{MIRROR_ID_RUYI_DIST}/{decl.name}") if decl.urls: urls_to_expand.extend(decl.urls) cfg = self.config return list( itertools.chain( *( cfg.get_dist_urls_for_file(self.logger, url) for url in urls_to_expand ) ) ) def _ensure_news_cache( self, ensure_repo: bool = True, ) -> None: if self._news_cache is not None: return if ensure_repo: self.ensure_git_repo() news_dir = os.path.join(self.root, "news") rs_store = self._gc.news_read_status rs_store.load() cache = NewsItemStore(rs_store) try: for f in glob.iglob("*.md", root_dir=news_dir): with open(os.path.join(news_dir, f), "r", encoding="utf-8") as fp: try: contents = fp.read() except UnicodeDecodeError: self.logger.W( _("UnicodeDecodeError: {path}").format( path=os.path.join(news_dir, f) ) ) continue cache.add(f, contents) # may fail but failures are harmless except FileNotFoundError: pass cache.finalize() self._news_cache = cache def news_store( self, ensure_repo: bool = True, ) -> NewsItemStore: if self._news_cache is None: self._ensure_news_cache(ensure_repo=ensure_repo) assert self._news_cache is not None return self._news_cache def run_plugin_cmd(self, cmd_name: str, args: list[str]) -> int: plugin_id = f"ruyi-cmd-{cmd_name.lower()}" plugin_entrypoint = self._plugin_host_ctx.get_from_plugin( plugin_id, "plugin_cmd_main_v1", is_cmd_plugin=True, # allow access to host FS for command plugins ) if plugin_entrypoint is None: raise RuntimeError(f"cmd entrypoint not found in plugin '{plugin_id}'") ret = self.eval_plugin_fn(plugin_entrypoint, args) if not isinstance(ret, int): self.logger.W( _( "unexpected return type of cmd plugin '{plugin_id}': {type} is not int." ).format( plugin_id=plugin_id, type=type(ret), ) ) self.logger.I(_("forcing return code to 1; the plugin should be fixed")) ret = 1 return ret @cached_property def entity_store(self) -> EntityStore: """Get the entity store for this repository.""" return EntityStore( self.logger, FSEntityProvider(self.logger, pathlib.Path(self.root) / "entities"), MetadataRepoEntityProvider(self), ProfileEntityProvider(self), ) def get_telemetry_api_url(self, scope: TelemetryScopeConfig) -> str | None: # do not clone the metadata repo if it is absent, in case the user # is simply trying trivial commands like `ruyi version`. if repo_cfg := self.maybe_config: return repo_cfg.get_telemetry_api_url(scope) return None PACKAGE_ENTITY_TYPE = "pkg" PACKAGE_ENTITY_TYPE_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema#", "required": ["pkg"], "properties": { "pkg": { "type": "object", "properties": { "id": {"type": "string"}, "display_name": {"type": "string"}, "name": {"type": "string"}, "category": {"type": "string"}, }, "required": ["id", "display_name", "name", "category"], }, "related": { "type": "array", "description": "List of related entity references", "items": {"type": "string", "pattern": "^.+:.+"}, }, "unique_among_type_during_traversal": { "type": "boolean", "description": "Whether this entity should be unique among all entities of the same type during traversal", }, }, } class PackageEntityData(TypedDict): id: str display_name: str name: str category: str class PackageEntity(TypedDict): pkg: PackageEntityData related: "NotRequired[list[str]]" unique_among_type_during_traversal: "NotRequired[bool]" class MetadataRepoEntityProvider(BaseEntityProvider): def __init__(self, repo: MetadataRepo) -> None: super().__init__() self._repo = repo def discover_schemas(self) -> dict[str, object]: return { PACKAGE_ENTITY_TYPE: PACKAGE_ENTITY_TYPE_SCHEMA, } def load_entities( self, entity_types: Sequence[str], ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: result: dict[str, Mapping[str, Mapping[str, Any]]] = {} for ty in entity_types: if ty == PACKAGE_ENTITY_TYPE: result[ty] = self._load_package_entities() return result def _load_package_entities(self) -> dict[str, PackageEntity]: result: dict[str, PackageEntity] = {} for cat, pkg_name, pkg_vers in self._repo.iter_pkgs(): full_name = f"{cat}/{pkg_name}" relations = [] # see if all versions of the package are toolchains and share the # same arch tc_arch: str | None = None for pkg_ver in pkg_vers.values(): if tm := pkg_ver.toolchain_metadata: if tc_arch is None: tc_arch = tm.target_arch continue if tc_arch != tm.target_arch: tc_arch = None break else: break if tc_arch is not None: # this is a toolchain package, add the arch as a related entity relations.append(f"arch:{tc_arch}") # similarly, check for the emulator kind emu_arches: set[str] | None = None for pkg_ver in pkg_vers.values(): if em := pkg_ver.emulator_metadata: pkg_ver_arches: set[str] = set() for p in em.programs: pkg_ver_arches.update(p.supported_arches) if emu_arches is None: emu_arches = pkg_ver_arches continue if emu_arches != pkg_ver_arches: emu_arches = emu_arches.intersection(pkg_ver_arches) else: break if emu_arches is not None: for emu_arch in emu_arches: relations.append(f"arch:{emu_arch}") result[full_name] = { "pkg": { "id": full_name, "display_name": full_name, "name": pkg_name, "category": cat, }, "related": relations, } return result class PorcelainRepoEntryV1(PorcelainEntity): id: str name: str remote: str | None branch: str local_path: str | None priority: int active: bool is_system: bool ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/repo_cli.py000066400000000000000000000252731520522431500214360ustar00rootroot00000000000000import argparse import pathlib from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class RepoCommand( RootCommand, cmd="repo", has_subcommands=True, help=_("Manage configured package repositories"), ): @classmethod def configure_args( cls, gc: "GlobalConfig", p: "ArgumentParser", ) -> None: pass class RepoListCommand( RepoCommand, cmd="list", help=_("List configured package repositories"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .repo import DEFAULT_REPO_ID from ..utils.porcelain import PorcelainOutput entries = cfg.repo_entries logger = cfg.logger if cfg.is_porcelain: with PorcelainOutput() as po: for entry in entries: po.emit(entry.to_porcelain()) return 0 for entry in sorted(entries, key=lambda e: -e.priority): active_marker = "*" if entry.active else " " default_marker = " (default)" if entry.id == DEFAULT_REPO_ID else "" system_marker = " (system)" if entry.is_system else "" source = entry.remote or "" if entry.local_path: source = ( entry.local_path if not source else f"{source} (local: {entry.local_path})" ) logger.stdout( f" {active_marker} [bold]{entry.id}[/]{default_marker}{system_marker} " f"priority={entry.priority} {source}" ) return 0 class RepoAddCommand( RepoCommand, cmd="add", help=_("Add a package repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument("id", type=str, help=_("unique repository identifier")) p.add_argument( "url", type=str, nargs="?", default=None, help=_("git remote URL") ) p.add_argument( "--branch", type=str, default=None, help=_("git branch to track") ) p.add_argument( "--priority", type=int, default=0, help=_("priority (higher = overrides lower)"), ) p.add_argument( "--local", type=str, default=None, help=_("local path to use instead of or alongside remote"), ) p.add_argument( "--name", type=str, default=None, help=_("human-readable name for the repo") ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor from ..config.schema import ( KEY_REPOS_ACTIVE, KEY_REPOS_BRANCH, KEY_REPOS_ID, KEY_REPOS_LOCAL, KEY_REPOS_NAME, KEY_REPOS_PRIORITY, KEY_REPOS_REMOTE, ) from .repo import DEFAULT_REPO_ID, REPO_ID_PATTERN logger = cfg.logger repo_id: str = args.id url: str | None = args.url local: str | None = args.local if not REPO_ID_PATTERN.match(repo_id): logger.F(_("invalid repo id '{id}'").format(id=repo_id)) return 1 if repo_id == DEFAULT_REPO_ID: logger.F( _( "'{id}' is reserved; use [repo] config to configure the default repository" ).format( id=DEFAULT_REPO_ID, ) ) return 1 if not url and not local: logger.F(_("at least one of URL or --local must be provided")) return 1 if local and not pathlib.Path(local).is_absolute(): logger.F(_("local path '{path}' must be absolute").format(path=local)) return 1 # Check for conflict with existing entries. for entry in cfg.repo_entries: if entry.id == repo_id: logger.F(_("a repo with id '{id}' already exists").format(id=repo_id)) return 1 entry_data: dict[str, object] = {KEY_REPOS_ID: repo_id} if args.name: entry_data[KEY_REPOS_NAME] = args.name if url: entry_data[KEY_REPOS_REMOTE] = url if args.branch: entry_data[KEY_REPOS_BRANCH] = args.branch if local: entry_data[KEY_REPOS_LOCAL] = local if args.priority != 0: entry_data[KEY_REPOS_PRIORITY] = args.priority entry_data[KEY_REPOS_ACTIVE] = True with ConfigEditor.work_on_user_local_config(cfg) as editor: editor.add_repos_entry(entry_data) editor.stage() logger.I(_("repo '{id}' added; run 'ruyi update' to sync").format(id=repo_id)) return 0 class RepoRemoveCommand( RepoCommand, cmd="remove", help=_("Remove a package repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument("id", type=str, help=_("repository identifier to remove")) if gc.is_cli_autocomplete: from .cli_completion import repo_id_completer_builder a.completer = repo_id_completer_builder(gc) p.add_argument( "--purge", action="store_true", default=False, help=_("also remove cached repo data from disk"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: import shutil from ..config.editor import ConfigEditor from .repo import DEFAULT_REPO_ID logger = cfg.logger repo_id: str = args.id if repo_id == DEFAULT_REPO_ID: logger.F( _( "cannot remove the default repo '{id}'; use 'repo disable' instead" ).format( id=DEFAULT_REPO_ID, ) ) return 1 # Check if entry is system-provided for entry in cfg.repo_entries: if entry.id == repo_id and entry.is_system: logger.F( _( "cannot remove system-provided repo '{id}'; use 'repo disable' instead" ).format( id=repo_id, ) ) return 1 with ConfigEditor.work_on_user_local_config(cfg) as editor: if not editor.remove_repos_entry(repo_id): logger.F( _("no repo with id '{id}' found in user config").format(id=repo_id) ) return 1 editor.stage() if args.purge: repo_dir = cfg.get_repo_dir_for_id(repo_id) if pathlib.Path(repo_dir).exists(): shutil.rmtree(repo_dir) logger.I(_("purged cached data at '{path}'").format(path=repo_dir)) logger.I(_("repo '{id}' removed").format(id=repo_id)) return 0 class RepoEnableCommand( RepoCommand, cmd="enable", help=_("Enable a package repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument("id", type=str, help=_("repository identifier to enable")) if gc.is_cli_autocomplete: from .cli_completion import repo_id_completer_builder a.completer = repo_id_completer_builder(gc) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor from ..config.schema import KEY_REPOS_ACTIVE logger = cfg.logger repo_id: str = args.id with ConfigEditor.work_on_user_local_config(cfg) as editor: if not editor.update_repos_entry(repo_id, {KEY_REPOS_ACTIVE: True}): logger.F( _("no repo with id '{id}' found in user config").format(id=repo_id) ) return 1 editor.stage() logger.I(_("repo '{id}' enabled").format(id=repo_id)) return 0 class RepoDisableCommand( RepoCommand, cmd="disable", help=_("Disable a package repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument("id", type=str, help=_("repository identifier to disable")) if gc.is_cli_autocomplete: from .cli_completion import repo_id_completer_builder a.completer = repo_id_completer_builder(gc) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor from ..config.schema import KEY_REPOS_ACTIVE logger = cfg.logger repo_id: str = args.id with ConfigEditor.work_on_user_local_config(cfg) as editor: if not editor.update_repos_entry(repo_id, {KEY_REPOS_ACTIVE: False}): logger.F( _("no repo with id '{id}' found in user config").format(id=repo_id) ) return 1 editor.stage() logger.I(_("repo '{id}' disabled").format(id=repo_id)) return 0 class RepoSetPriorityCommand( RepoCommand, cmd="set-priority", help=_("Set the priority of a package repository"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument("id", type=str, help=_("repository identifier")) if gc.is_cli_autocomplete: from .cli_completion import repo_id_completer_builder a.completer = repo_id_completer_builder(gc) p.add_argument("priority", type=int, help=_("new priority value")) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from ..config.editor import ConfigEditor from ..config.schema import KEY_REPOS_PRIORITY logger = cfg.logger repo_id: str = args.id priority: int = args.priority with ConfigEditor.work_on_user_local_config(cfg) as editor: if not editor.update_repos_entry(repo_id, {KEY_REPOS_PRIORITY: priority}): logger.F( _("no repo with id '{id}' found in user config").format(id=repo_id) ) return 1 editor.stage() logger.I( _("repo '{id}' priority set to {priority}").format( id=repo_id, priority=priority ) ) return 0 ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/state.py000066400000000000000000000272571520522431500207660ustar00rootroot00000000000000import datetime import json import os import pathlib from typing import Any, Iterable, Iterator, TypedDict, TYPE_CHECKING from dataclasses import dataclass from .pkg_manifest import BoundPackageManifest from .protocols import ProvidesPackageManifests if TYPE_CHECKING: # for avoiding heavy import from .protocols import ProvidesPackageManifests class PackageInstallationRecord(TypedDict): """Record of a package installation.""" repo_id: str category: str name: str version: str host: str # For binary packages, empty for blobs install_path: str install_time: str # ISO format datetime @dataclass class PackageInstallationInfo: """Information about an installed package.""" repo_id: str category: str name: str version: str host: str # For binary packages, empty for blobs install_path: str install_time: datetime.datetime def to_record(self) -> PackageInstallationRecord: """Convert to a record for JSON serialization.""" return PackageInstallationRecord( repo_id=self.repo_id, category=self.category, name=self.name, version=self.version, host=self.host, install_path=self.install_path, install_time=self.install_time.isoformat(), ) @classmethod def from_record( cls, record: PackageInstallationRecord, ) -> "PackageInstallationInfo": """Create from a record.""" return cls( repo_id=record["repo_id"], category=record["category"], name=record["name"], version=record["version"], host=record["host"], install_path=record["install_path"], install_time=datetime.datetime.fromisoformat(record["install_time"]), ) class RuyipkgGlobalStateStore: def __init__(self, root: os.PathLike[Any]) -> None: self.root = pathlib.Path(root) self._installs_file = self.root / "installs.json" self._installs_cache: dict[str, PackageInstallationInfo] | None = None def ensure_state_dir(self) -> None: """Ensure the state directory exists.""" self.root.mkdir(parents=True, exist_ok=True) def purge_installation_info(self) -> None: """Purge installation records.""" self._installs_file.unlink(missing_ok=True) self._installs_cache = None # if the state dir is empty, remove it try: self.root.rmdir() except OSError: pass def _load_installs(self) -> dict[str, PackageInstallationInfo]: """Load installation records from disk.""" if self._installs_cache is not None: return self._installs_cache self.ensure_state_dir() if not self._installs_file.exists(): self._installs_cache = {} return self._installs_cache try: with open(self._installs_file, "r", encoding="utf-8") as f: data = json.load(f) installs = {} for key, record in data.items(): installs[key] = PackageInstallationInfo.from_record(record) self._installs_cache = installs return self._installs_cache except (json.JSONDecodeError, KeyError, ValueError): # If file is corrupted, start fresh self._installs_cache = {} return self._installs_cache def _save_installs(self) -> None: """Save installation records to disk.""" if self._installs_cache is None: return self.ensure_state_dir() data = {} for key, info in self._installs_cache.items(): data[key] = info.to_record() # Write atomically by writing to temp file then renaming temp_file = self._installs_file.with_suffix(".tmp") try: with open(temp_file, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) temp_file.replace(self._installs_file) except Exception: # Clean up temp file if something went wrong if temp_file.exists(): temp_file.unlink() raise def _get_installation_key( self, repo_id: str, category: str, name: str, version: str, host: str = "", ) -> str: """Get the key used to store installation info.""" if host: # Use a format that includes host for binary packages return f"{repo_id}:{category}/{name} {version} host={host}" return f"{repo_id}:{category}/{name} {version}" def record_installation( self, repo_id: str, category: str, name: str, version: str, host: str, install_path: str, ) -> None: """Record a successful package installation.""" installs = self._load_installs() key = self._get_installation_key(repo_id, category, name, version, host) info = PackageInstallationInfo( repo_id=repo_id, category=category, name=name, version=version, host=host, install_path=install_path, install_time=datetime.datetime.now(), ) installs[key] = info self._save_installs() def remove_installation( self, repo_id: str, category: str, name: str, version: str, host: str = "", ) -> bool: """Remove an installation record.""" installs = self._load_installs() key = self._get_installation_key(repo_id, category, name, version, host) if key in installs: del installs[key] self._save_installs() return True return False def get_installation( self, repo_id: str, category: str, name: str, version: str, host: str = "", ) -> PackageInstallationInfo | None: """Get information about a specific installation.""" installs = self._load_installs() key = self._get_installation_key(repo_id, category, name, version, host) return installs.get(key) def is_package_installed( self, repo_id: str, category: str, name: str, version: str, host: str = "", ) -> bool: """Check if a package is installed.""" return self.get_installation(repo_id, category, name, version, host) is not None def list_installed_packages(self) -> list[PackageInstallationInfo]: """List all installed packages.""" installs = self._load_installs() return list(installs.values()) class BoundInstallationStateStore(ProvidesPackageManifests): def __init__( self, rgs: RuyipkgGlobalStateStore, mr: "ProvidesPackageManifests" ) -> None: self._rgs = rgs self._mr = mr def _get_installed_manifest( self, info: PackageInstallationInfo, ) -> BoundPackageManifest | None: """Get the bound manifest for an installed package, or None if not found in repo.""" return self._mr.get_pkg(info.name, info.category, info.version) def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]: """Iterate over all installed package manifests.""" installed_pkgs = self._rgs.list_installed_packages() for info in installed_pkgs: if m := self._get_installed_manifest(info): yield m def iter_pkgs( self, ) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: """Iterate over installed packages grouped by category and name.""" installed_pkgs = self._rgs.list_installed_packages() # Group by category and name result: dict[str, dict[str, dict[str, BoundPackageManifest]]] = {} for info in installed_pkgs: if m := self._get_installed_manifest(info): if info.category not in result: result[info.category] = {} if info.name not in result[info.category]: result[info.category][info.name] = {} result[info.category][info.name][info.version] = m for category, cat_pkgs in result.items(): for pkg_name, pkg_vers in cat_pkgs.items(): yield (category, pkg_name, pkg_vers) def iter_pkg_vers( self, name: str, category: str | None = None, ) -> Iterable[BoundPackageManifest]: """Iterate over installed versions of a specific package.""" installed_pkgs = self._rgs.list_installed_packages() for info in installed_pkgs: if info.name == name and (category is None or info.category == category): if m := self._get_installed_manifest(info): yield m def get_pkg( self, name: str, category: str, ver: str, ) -> BoundPackageManifest | None: """Returns the package manifest by exact match, or None if not found.""" installed_pkgs = self._rgs.list_installed_packages() for info in installed_pkgs: if info.name == name and info.category == category and info.version == ver: if m := self._get_installed_manifest(info): return m # Package is installed but not found in current repo break return None def get_pkg_latest_ver( self, name: str, category: str | None = None, include_prerelease_vers: bool = False, ) -> BoundPackageManifest: """Get the latest installed version of a package.""" from .pkg_manifest import is_prerelease installed_vers = list(self.iter_pkg_vers(name, category)) if not installed_vers: raise KeyError(f"No installed versions found for package '{name}'") if not include_prerelease_vers: installed_vers = [ pm for pm in installed_vers if not is_prerelease(pm.semver) ] if not installed_vers: raise KeyError( f"No non-prerelease installed versions found for package '{name}'" ) # Find the latest version latest = max(installed_vers, key=lambda pm: pm.semver) return latest # To be removed later along with slug support def get_pkg_by_slug(self, slug: str) -> BoundPackageManifest | None: """Get an installed package by its slug.""" installed_pkgs = self._rgs.list_installed_packages() for info in installed_pkgs: if m := self._get_installed_manifest(info): if m.slug == slug: return m return None # Useful helpers def iter_upgradable_pkgs( self, include_prereleases: bool = False, ) -> Iterator[tuple[BoundPackageManifest, str, bool]]: """Iterate over installed packages that have newer versions available. Yields ``(installed_pm, new_version_str, repo_migrated)`` tuples. ``repo_migrated`` is True when the latest version comes from a different repo than the installed version.""" for installed_pm in self.iter_pkg_manifests(): latest_pm: BoundPackageManifest try: latest_pm = self._mr.get_pkg_latest_ver( installed_pm.name, installed_pm.category, include_prereleases, ) except KeyError: # package not found in the repo, skip it continue if latest_pm.semver > installed_pm.semver: migrated = latest_pm.repo_id != installed_pm.repo_id yield (installed_pm, str(latest_pm.semver), migrated) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/unpack.py000066400000000000000000000273271520522431500211250ustar00rootroot00000000000000import mmap import os import shutil import subprocess from typing import Any, BinaryIO, NoReturn, Protocol from ..i18n import _ from ..log import RuyiLogger from ..utils import ar, prereqs from .unpack_method import ( UnpackMethod, UnrecognizedPackFormatError, determine_unpack_method, ) class SupportsRead(Protocol): def read(self, n: int = -1, /) -> bytes: ... def do_unpack( logger: RuyiLogger, filename: str, dest: str | os.PathLike[Any] | None, strip_components: int, unpack_method: UnpackMethod, stream: BinaryIO | SupportsRead | None = None, prefixes_to_unpack: list[str] | None = None, ) -> None: match unpack_method: case UnpackMethod.AUTO: raise ValueError("the auto unpack method must be resolved prior to use") case UnpackMethod.RAW: return _do_copy_raw(filename, dest) case ( UnpackMethod.TAR_AUTO | UnpackMethod.TAR | UnpackMethod.TAR_BZ2 | UnpackMethod.TAR_GZ | UnpackMethod.TAR_LZ4 | UnpackMethod.TAR_XZ | UnpackMethod.TAR_ZST ): return _do_unpack_tar( logger, filename, dest, strip_components, unpack_method, stream, prefixes_to_unpack, ) case UnpackMethod.ZIP: # TODO: handle strip_components somehow; the unzip(1) command currently # does not have such support. return _do_unpack_zip(logger, filename, dest) case UnpackMethod.DEB: return _do_unpack_deb(logger, filename, dest) case UnpackMethod.GZ: # bare gzip file return _do_unpack_bare_gz(logger, filename, dest) case UnpackMethod.BZ2: # bare bzip2 file return _do_unpack_bare_bzip2(logger, filename, dest) case UnpackMethod.LZ4: # bare lz4 file return _do_unpack_bare_lz4(logger, filename, dest) case UnpackMethod.XZ: # bare xz file return _do_unpack_bare_xz(logger, filename, dest) case UnpackMethod.ZST: # bare zstd file return _do_unpack_bare_zstd(logger, filename, dest) case _: raise UnrecognizedPackFormatError(filename) def do_unpack_or_symlink( logger: RuyiLogger, filename: str, dest: str | os.PathLike[Any] | None, strip_components: int, unpack_method: UnpackMethod, stream: BinaryIO | SupportsRead | None = None, prefixes_to_unpack: list[str] | None = None, ) -> None: try: return do_unpack( logger, filename, dest, strip_components, unpack_method, stream, prefixes_to_unpack, ) except UnrecognizedPackFormatError: # just symlink into destination return do_symlink(filename, dest) def _do_copy_raw( src_path: str, destdir: str | os.PathLike[Any] | None, ) -> None: src_filename = os.path.basename(src_path) if destdir is None: # symlink into CWD dest = src_filename else: dest = os.path.join(destdir, src_filename) shutil.copy(src_path, dest) def do_symlink( src_path: str, destdir: str | os.PathLike[Any] | None, ) -> None: src_filename = os.path.basename(src_path) if destdir is None: # symlink into CWD dest = src_filename else: dest = os.path.join(destdir, src_filename) # avoid the hassle and pitfalls around relative paths and symlinks, and # just point to the target using absolute path symlink_target = os.path.abspath(src_path) os.symlink(symlink_target, dest) def _do_unpack_tar( logger: RuyiLogger, filename: str, dest: str | os.PathLike[Any] | None, strip_components: int, unpack_method: UnpackMethod, stream: SupportsRead | None, prefixes_to_unpack: list[str] | None = None, ) -> None: argv = ["tar", "-x"] match unpack_method: case UnpackMethod.TAR | UnpackMethod.TAR_AUTO: pass case UnpackMethod.TAR_GZ: argv.append("-z") case UnpackMethod.TAR_BZ2: argv.append("-j") case UnpackMethod.TAR_LZ4: argv.append("--use-compress-program=lz4") case UnpackMethod.TAR_XZ: argv.append("-J") case UnpackMethod.TAR_ZST: argv.append("--zstd") case _: raise ValueError( f"do_unpack_tar cannot handle non-tar unpack method {unpack_method}" ) stdin: int | None = None if stream is not None: filename = "-" stdin = subprocess.PIPE argv.extend(("-f", filename, f"--strip-components={strip_components}")) if prefixes_to_unpack: if any(p.startswith("-") for p in prefixes_to_unpack): raise ValueError( "prefixes_to_unpack must not contain any item starting with '-'" ) argv.extend(prefixes_to_unpack) logger.D(f"about to call tar: argv={argv}") p = subprocess.Popen(argv, cwd=dest, stdin=stdin) retcode: int if stream is None: retcode = p.wait() else: # this is only for pleasing the type-checker; it's statically true # because the assignment always happens due to the earlier # "stream is not None" branch. assert p.stdin is not None bufsize = 4 * mmap.PAGESIZE while True: buf = stream.read(bufsize) if not buf: break p.stdin.write(buf) p.stdin.close() retcode = p.wait() if retcode != 0: raise RuntimeError( _("untar failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_zip( logger: RuyiLogger, filename: str, dest: str | os.PathLike[Any] | None, ) -> None: argv = ["unzip", filename] if dest is not None: argv.extend(("-d", str(dest))) logger.D(f"about to call unzip: argv={argv}") retcode = subprocess.call(argv, cwd=dest) if retcode != 0: raise RuntimeError( _("unzip failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_bare_gz( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: # the suffix may not be ".gz" so do this generically dest_filename = os.path.splitext(os.path.basename(filename))[0] argv = ["gunzip", "-c", filename] if destdir is not None: os.chdir(destdir) logger.D(f"about to call gunzip: argv={argv}") with open(dest_filename, "wb") as out: retcode = subprocess.call(argv, stdout=out) if retcode != 0: raise RuntimeError( _("gunzip failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_bare_bzip2( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: # the suffix may not be ".bz2" so do this generically dest_filename = os.path.splitext(os.path.basename(filename))[0] argv = ["bzip2", "-dc", filename] if destdir is not None: os.chdir(destdir) logger.D(f"about to call bzip2: argv={argv}") with open(dest_filename, "wb") as out: retcode = subprocess.call(argv, stdout=out) if retcode != 0: raise RuntimeError( _("bzip2 failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_bare_lz4( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: # the suffix may not be ".lz4" so do this generically dest_filename = os.path.splitext(os.path.basename(filename))[0] argv = ["lz4", "-dk", filename, f"./{dest_filename}"] logger.D(f"about to call lz4: argv={argv}") retcode = subprocess.call(argv, cwd=destdir) if retcode != 0: raise RuntimeError( _("lz4 failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_bare_xz( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: # the suffix may not be ".xz" so do this generically dest_filename = os.path.splitext(os.path.basename(filename))[0] argv = ["xz", "-d", "-c", filename] if destdir is not None: os.chdir(destdir) logger.D(f"about to call xz: argv={argv}") with open(dest_filename, "wb") as out: retcode = subprocess.call(argv, stdout=out) if retcode != 0: raise RuntimeError( _("xz failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_bare_zstd( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: # the suffix may not be ".zst" so do this generically dest_filename = os.path.splitext(os.path.basename(filename))[0] argv = ["zstd", "-d", filename, "-o", f"./{dest_filename}"] logger.D(f"about to call zstd: argv={argv}") retcode = subprocess.call(argv, cwd=destdir) if retcode != 0: raise RuntimeError( _("zstd failed: command {cmd} returned {retcode}").format( cmd=" ".join(argv), retcode=retcode, ) ) def _do_unpack_deb( logger: RuyiLogger, filename: str, destdir: str | os.PathLike[Any] | None, ) -> None: with ar.ArpyArchiveWrapper(filename) as a: for f in a.infolist(): name = f.name.decode("utf-8") if name.startswith("data.tar"): inner_unpack_method = determine_unpack_method(name) return _do_unpack_tar( logger, name, destdir, 0, inner_unpack_method, a.open(f), ) raise RuntimeError( _("file '{filename}' does not appear to be a deb").format( filename=filename, ) ) def _get_unpack_cmds_for_method(m: UnpackMethod) -> list[str]: match m: case UnpackMethod.UNKNOWN | UnpackMethod.RAW | UnpackMethod.DEB: return [] case UnpackMethod.GZ: return ["gunzip"] case UnpackMethod.BZ2: return ["bzip2"] case UnpackMethod.LZ4: return ["lz4"] case UnpackMethod.XZ: return ["xz"] case UnpackMethod.ZST: return ["zstd"] case UnpackMethod.TAR | UnpackMethod.TAR_AUTO: return ["tar"] case UnpackMethod.TAR_GZ: return ["tar", "gunzip"] case UnpackMethod.TAR_BZ2: return ["tar", "bzip2"] case UnpackMethod.TAR_LZ4: return ["tar", "lz4"] case UnpackMethod.TAR_XZ: return ["tar", "xz"] case UnpackMethod.TAR_ZST: return ["tar", "zstd"] case UnpackMethod.ZIP: return ["unzip"] case UnpackMethod.AUTO: raise ValueError(f"the unpack method {m} must be resolved prior to use") def ensure_unpack_cmd_for_method( logger: RuyiLogger, m: UnpackMethod, ) -> None | NoReturn: required_cmds = _get_unpack_cmds_for_method(m) if not required_cmds: return None return prereqs.ensure_cmds(logger, required_cmds, interactive_retry=True) ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/unpack_method.py000066400000000000000000000031331520522431500224520ustar00rootroot00000000000000import enum import re from typing import Final from ..i18n import _ RE_TARBALL: Final = re.compile(r"\.tar(?:\.gz|\.bz2|\.lz4|\.xz|\.zst)?$") class UnpackMethod(enum.StrEnum): UNKNOWN = "" AUTO = "auto" TAR_AUTO = "tar.auto" RAW = "raw" GZ = "gz" BZ2 = "bz2" LZ4 = "lz4" XZ = "xz" ZST = "zst" TAR = "tar" TAR_GZ = "tar.gz" TAR_BZ2 = "tar.bz2" TAR_LZ4 = "tar.lz4" TAR_XZ = "tar.xz" TAR_ZST = "tar.zst" ZIP = "zip" DEB = "deb" class UnrecognizedPackFormatError(Exception): def __init__(self, filename: str) -> None: self.filename = filename def __str__(self) -> str: return _("don't know how to unpack file {filename}").format( filename=self.filename, ) def determine_unpack_method( filename: str, ) -> UnpackMethod: filename_lower = filename.lower() if m := RE_TARBALL.search(filename_lower): return UnpackMethod(m.group(0)[1:]) if filename_lower.endswith(".deb"): return UnpackMethod.DEB if filename_lower.endswith(".zip"): return UnpackMethod.ZIP if filename_lower.endswith(".gz"): # bare gzip file return UnpackMethod.GZ if filename_lower.endswith(".bz2"): # bare bzip2 file return UnpackMethod.BZ2 if filename_lower.endswith(".lz4"): # bare lz4 file return UnpackMethod.LZ4 if filename_lower.endswith(".xz"): # bare xz file return UnpackMethod.XZ if filename_lower.endswith(".zst"): # bare zstd file return UnpackMethod.ZST return UnpackMethod.UNKNOWN ruyisdk-ruyi-1f00e2e/ruyi/ruyipkg/update_cli.py000066400000000000000000000051161520522431500217450ustar00rootroot00000000000000import argparse from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig class UpdateCommand( RootCommand, cmd="update", help=_("Update RuyiSDK repo and packages"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: a = p.add_argument( "--repo", type=str, default=None, help=_("only sync the repo with this ID"), ) if gc.is_cli_autocomplete: from .cli_completion import repo_id_completer_builder a.completer = repo_id_completer_builder(gc) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from . import news from .state import BoundInstallationStateStore logger = cfg.logger mr = cfg.repo repo_id: str | None = args.repo if repo_id is not None: try: mr.sync_one(repo_id) except ValueError: logger.F(_("no active repo with id '{id}'").format(id=repo_id)) return 1 else: mr.sync_all() # check for upgradable packages bis = BoundInstallationStateStore(cfg.ruyipkg_global_state, mr) upgradable = list(bis.iter_upgradable_pkgs(cfg.include_prereleases)) if upgradable: logger.stdout( _( "\nNewer versions are available for some of your installed packages:\n" ) ) for pm, new_ver, migrated in upgradable: logger.stdout( f" - [bold]{pm.category}/{pm.name}[/]: [yellow]{pm.ver}[/] -> [green]{new_ver}[/]" ) if migrated: logger.W( _( "package '{category}/{name}' was installed from " "repo '{repo}' but the latest version is in a " "different repo" ).format( category=pm.category, name=pm.name, repo=pm.repo_id, ) ) logger.stdout( _( """ Re-run [yellow]ruyi install[/] to upgrade, and don't forget to re-create any affected virtual environments.""" ) ) news.maybe_notify_unread_news(cfg, False) return 0 ruyisdk-ruyi-1f00e2e/ruyi/telemetry/000077500000000000000000000000001520522431500175775ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/telemetry/__init__.py000066400000000000000000000000001520522431500216760ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/telemetry/aggregate.py000066400000000000000000000044361520522431500221060ustar00rootroot00000000000000from typing import Iterable, TypeAlias, TypedDict, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import NotRequired from ..utils.node_info import NodeInfo from .event import TelemetryEvent class AggregatedTelemetryEvent(TypedDict): time_bucket: str kind: str params: list[tuple[str, str]] count: int class UploadPayload(TypedDict): fmt: int nonce: str ruyi_version: str report_uuid: "NotRequired[str]" """Optional field in case the client wishes to report this, and nothing else. If `installation` is present, this field is ignored.""" installation: "NotRequired[NodeInfo | None]" """More detailed installation info that the client has user consent to report.""" events: list[AggregatedTelemetryEvent] """Aggregated telemetry events that the client has user consent to upload.""" def stringify_param_val(v: object) -> str: if v is None: return "null" if isinstance(v, bool): return "1" if v else "0" if isinstance(v, bytes): return v.decode("utf-8") if isinstance(v, str): return v return str(v) AggregateKey: TypeAlias = tuple[tuple[str, str], ...] def _make_aggregate_key(ev: TelemetryEvent) -> AggregateKey: param_list = [(k, stringify_param_val(v)) for k, v in ev["params"].items()] param_list.sort() return tuple([("", ev["kind"])] + param_list) def aggregate_events( events: Iterable[TelemetryEvent], ) -> Iterable[AggregatedTelemetryEvent]: # dict[time_bucket, dict[AggregateKey, count]] buf: dict[str, dict[AggregateKey, int]] = {} for raw_ev in events: time_bucket = raw_ev.get("time_bucket") if time_bucket is None: continue if time_bucket not in buf: buf[time_bucket] = {} agg_key = _make_aggregate_key(raw_ev) if agg_key not in buf[time_bucket]: buf[time_bucket][agg_key] = 1 else: buf[time_bucket][agg_key] += 1 for time_bucket in sorted(buf.keys()): bucket_events = buf[time_bucket] for agg_key in sorted(bucket_events.keys()): yield { "time_bucket": time_bucket, "kind": agg_key[0][1], "params": list(agg_key[1:]), "count": bucket_events[agg_key], } ruyisdk-ruyi-1f00e2e/ruyi/telemetry/event.py000066400000000000000000000017261520522431500213000ustar00rootroot00000000000000from typing import TypedDict, TypeGuard, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import NotRequired class TelemetryEvent(TypedDict): fmt: int time_bucket: "NotRequired[str]" # canonically "YYYYMMDDHHMM" kind: str params: dict[str, object] def is_telemetry_event(x: object) -> TypeGuard[TelemetryEvent]: if not isinstance(x, dict): return False if not 3 <= len(x.keys()) <= 4: return False try: if not isinstance(x["fmt"], int): return False if not isinstance(x["kind"], str): return False if not isinstance(x["params"], dict): return False except KeyError: return False try: if not isinstance(x["time_bucket"], str): return False if len(x["time_bucket"]) != 12: return False if not x["time_bucket"].isdigit(): return False except KeyError: pass return True ruyisdk-ruyi-1f00e2e/ruyi/telemetry/provider.py000066400000000000000000000561611520522431500220140ustar00rootroot00000000000000import calendar import datetime import functools import json import pathlib import time from typing import Callable, TYPE_CHECKING, cast import uuid from ..i18n import _, d_ from ..log import RuyiLogger from ..utils.node_info import NodeInfo, gather_node_info from .scope import TelemetryScope from .store import TelemetryStore if TYPE_CHECKING: # for avoiding circular import from ..config import GlobalConfig FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/" TELEMETRY_CONSENT_AND_UPLOAD_DESC = d_( """ RuyiSDK collects minimal usage data in the form of just a version number of the running [yellow]ruyi[/], to help us improve the product. With your consent, RuyiSDK may also collect additional non-tracking usage data to be sent periodically. The data will be recorded and processed by RuyiSDK team-managed servers located in the Chinese mainland. [green]By default, nothing leaves your machine[/], and you can also turn off usage data collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and upload more usage data. You can change this setting at any time by running [yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/]. We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK team can better understand adoption. If you choose to opt out, this will be the only data to be ever uploaded, without any tracking ID being generated or kept. Thank you for helping us build a better experience! """ ) TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = d_( "Do you agree to have usage data periodically uploaded?" ) TELEMETRY_OPTOUT_PROMPT = d_("\nDo you want to opt out of telemetry entirely?") MALFORMED_TELEMETRY_STATE_MSG = d_( "malformed telemetry state: unable to determine upload weekday, nothing will be uploaded" ) def next_utc_weekday(wday: int, now: float | None = None) -> int: t = time.gmtime(now) mday_delta = wday - t.tm_wday if mday_delta < 0: mday_delta += 7 next_t = ( t.tm_year, t.tm_mon, t.tm_mday + mday_delta, 0, # tm_hour 0, # tm_min 0, # tm_sec 0, # tm_wday 0, # tm_yday -1, # tm_isdst ) return calendar.timegm(next_t) def set_telemetry_mode( gc: "GlobalConfig", mode: str, consent_time: datetime.datetime | None = None, show_cli_feedback: bool = True, ) -> None: """Set telemetry mode and consent time (if applicable) in the user preference.""" from ..config.editor import ConfigEditor from ..config import schema logger = gc.logger if mode == "on": if consent_time is None: consent_time = datetime.datetime.now().astimezone() else: # clear any previously recorded consent time consent_time = None # First, persist the changes to user config with ConfigEditor.work_on_user_local_config(gc) as ed: ed.set_value((schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE), mode) if consent_time is not None: ed.set_value( (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT), consent_time, ) else: ed.unset_value( (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT) ) ed.stage() # Then, apply the changes to the running instance's GlobalConfig # TelemetryProvider instance (if any) will pick them up automatically # because the properties are backed by GlobalConfig. gc.set_by_key( (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE), mode, ) gc.set_by_key( (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT), consent_time, ) if not show_cli_feedback: return match mode: case "on": logger.I(_("telemetry data uploading is now enabled")) logger.I( _( "you can opt out at any time by running [yellow]ruyi telemetry optout[/]" ) ) case "local": logger.I(_("telemetry mode is now set to local collection only")) logger.I( _( "you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]" ) ) logger.I( _("or opt out at any time by running [yellow]ruyi telemetry optout[/]") ) case "off": logger.I(_("telemetry data collection is now disabled")) logger.I( _( "you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]" ) ) case _: raise ValueError(f"invalid telemetry mode: {mode}") class TelemetryProvider: def __init__(self, gc: "GlobalConfig", minimal: bool) -> None: self.state_root = pathlib.Path(gc.telemetry_root) self._discard_events = False self._gc = gc self._is_first_run = False self._stores: dict[TelemetryScope, TelemetryStore] = {} self._upload_on_exit = False self.minimal = minimal # create the PM store self.init_store(TelemetryScope(None)) # create per-repo stores for all active repos for entry in gc.repo_entries: if entry.active: self.init_store(TelemetryScope(entry.id)) @property def logger(self) -> RuyiLogger: return self._gc.logger @property def local_mode(self) -> bool: return self._gc.telemetry_mode == "local" @property def upload_consent_time(self) -> datetime.datetime | None: if self.minimal or self.local_mode: return None return self._gc.telemetry_upload_consent_time def store(self, scope: TelemetryScope) -> TelemetryStore | None: return self._stores.get(scope) def init_store(self, scope: TelemetryScope) -> None: store_root = self.state_root api_url_fn: Callable[[], str | None] | None = None repo_name = scope.repo_name if repo_name is not None: repo_name_str: str = repo_name store_root = store_root / "repos" / repo_name_str def _f(rn: str = repo_name_str) -> str | None: # access the repo attribute lazily to speed up CLI startup return self._gc.repo.get_telemetry_api_url("repo", repo_id=rn) api_url_fn = _f else: # configure the PM telemetry endpoint api_url_fn = functools.partial(self._detect_pm_api_url, self._gc) store = TelemetryStore( self.logger, scope, store_root, api_url_factory=api_url_fn, ) self._stores[scope] = store def _detect_pm_api_url(self, gc: "GlobalConfig") -> str | None: url = FALLBACK_PM_TELEMETRY_ENDPOINT cfg_src = "fallback" if gc.override_pm_telemetry_url is not None: cfg_src = "local config" url = gc.override_pm_telemetry_url else: if repo_provided_url := gc.repo.get_telemetry_api_url("pm"): cfg_src = "repo" url = repo_provided_url self.logger.D( f"configured PM telemetry endpoint via {cfg_src}: {url or '(n/a)'}" ) return url @property def installation_file(self) -> pathlib.Path: return self.state_root / "installation.json" @property def minimal_installation_marker_file(self) -> pathlib.Path: return self.state_root / "minimal-installation-marker" def check_first_run_status(self) -> None: """Check if this is the first run of the application by checking if installation file exists. This must be done before init_installation() is potentially called. """ self._is_first_run = ( not self.installation_file.exists() and not self.minimal_installation_marker_file.exists() ) @property def is_first_run(self) -> bool: """Check if this is the first run of the application.""" return self._is_first_run def init_installation(self, force_reinit: bool) -> NodeInfo | None: if self.minimal: # be extra safe by not reading or writing installation data at all # in minimal mode self._init_minimal_installation_marker(force_reinit) return None installation_file = self.installation_file if installation_file.exists() and not force_reinit: return self._read_installation_data() # either this is a fresh installation or we're forcing a refresh installation_id = uuid.uuid4() self.logger.D( f"initializing telemetry data store, installation_id={installation_id.hex}" ) self.state_root.mkdir(parents=True, exist_ok=True) # (over)write installation data installation_data = gather_node_info(installation_id) with open(installation_file, "wb") as fp: fp.write(json.dumps(installation_data).encode("utf-8")) return installation_data def _init_minimal_installation_marker(self, force_reinit: bool) -> None: if self.minimal_installation_marker_file.exists() and not force_reinit: return self.logger.D("initializing minimal installation marker file") self.state_root.mkdir(parents=True, exist_ok=True) # just touch the file self.minimal_installation_marker_file.touch() def _read_installation_data(self) -> NodeInfo | None: with open(self.installation_file, "rb") as fp: return cast(NodeInfo, json.load(fp)) def _upload_weekday(self) -> int | None: if self.minimal: return None try: installation_data = self._read_installation_data() except FileNotFoundError: # init the node info if it's gone installation_data = self.init_installation(False) if installation_data is None: return None try: report_uuid_prefix = int(installation_data["report_uuid"][:8], 16) except ValueError: return None return report_uuid_prefix % 7 # 0 is Monday def _has_upload_consent(self, time_now: float | None = None) -> bool: if self.upload_consent_time is None: return False if time_now is None: time_now = time.time() return self.upload_consent_time.timestamp() <= time_now def _print_upload_schedule_notice(self, upload_wday: int, now: float) -> None: next_upload_day_ts = next_utc_weekday(upload_wday, now) next_upload_day = time.localtime(next_upload_day_ts) next_upload_day_end = time.localtime(next_upload_day_ts + 86400) next_upload_day_str = time.strftime("%Y-%m-%d %H:%M:%S %z", next_upload_day) next_upload_day_end_str = time.strftime( "%Y-%m-%d %H:%M:%S %z", next_upload_day_end, ) if self._is_upload_day(now): for scope, store in self._stores.items(): has_uploaded_today = self._has_uploaded_today( store.last_upload_timestamp, now, ) if has_uploaded_today: if last_upload_time := store.last_upload_timestamp: last_upload_time_str = time.strftime( "%Y-%m-%d %H:%M:%S %z", time.localtime(last_upload_time) ) self.logger.I( _( "scope {scope}: usage information has already been uploaded today at {last_upload_time_str}" ).format( scope=scope, last_upload_time_str=last_upload_time_str, ) ) else: self.logger.I( _( "scope {scope}: usage information has already been uploaded sometime today" ).format( scope=scope, ) ) else: self.logger.I( _( "scope {scope}: the next upload will happen [bold green]today[/] if not already" ).format( scope=scope, ) ) else: self.logger.I( _("the next upload will happen anytime [yellow]ruyi[/] is executed:") ) self.logger.I( _( " - between [bold green]{time_start}[/] and [bold green]{time_end}[/]" ).format( time_start=next_upload_day_str, time_end=next_upload_day_end_str, ) ) self.logger.I(_(" - or if the last upload is more than a week ago")) def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None: if self.minimal: if for_cli_verbose_output: self.logger.I( _( "telemetry mode is [green]off[/]: nothing is collected or uploaded after the first run" ) ) return now = time.time() upload_wday = self._upload_weekday() if upload_wday is None: if for_cli_verbose_output: self.logger.W(MALFORMED_TELEMETRY_STATE_MSG) else: self.logger.D(MALFORMED_TELEMETRY_STATE_MSG) return upload_wday_name = self._gc.babel_locale.days["format"]["wide"][upload_wday] if self.local_mode: if for_cli_verbose_output: self.logger.I( _( "telemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requested" ) ) return if self._has_upload_consent(now) and not for_cli_verbose_output: self.logger.D("user has consented to telemetry upload") return if for_cli_verbose_output: self.logger.I( _( "telemetry mode is [green]on[/]: usage data is collected and periodically uploaded" ) ) self.logger.I( _( "non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {weekday}[/]" ).format( weekday=upload_wday_name, ) ) else: self.logger.W( _( "this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {weekday}[/]" ).format( weekday=upload_wday_name, ) ) self._print_upload_schedule_notice(upload_wday, now) if not for_cli_verbose_output: self.logger.I(_("in order to hide this banner:")) self.logger.I(_(" - opt out with [yellow]ruyi telemetry optout[/]")) self.logger.I( _(" - or give consent with [yellow]ruyi telemetry consent[/]") ) def _next_upload_day(self, time_now: float | None = None) -> int | None: upload_wday = self._upload_weekday() if upload_wday is None: return None return next_utc_weekday(upload_wday, time_now) def _is_upload_day(self, time_now: float | None = None) -> bool: if time_now is None: time_now = time.time() if upload_day := self._next_upload_day(time_now): return upload_day <= time_now return False def _has_uploaded_today( self, last_upload_time: float | None, time_now: float | None = None, ) -> bool: if time_now is None: time_now = time.time() if upload_day := self._next_upload_day(time_now): upload_day_end = upload_day + 86400 if last_upload_time is not None: return upload_day <= last_upload_time < upload_day_end return False def record(self, scope: TelemetryScope, kind: str, **params: object) -> None: if self.minimal: self.logger.D( f"minimal telemetry mode enabled, discarding event '{kind}' for scope {scope}" ) return if store := self.store(scope): return store.record(kind, **params) self.logger.D( f"no telemetry store for scope {scope}, discarding event '{kind}'" ) def discard_events(self, v: bool = True) -> None: self._discard_events = v def _should_proceed_with_upload( self, scope: TelemetryScope, explicit_request: bool, cron_mode: bool, now: float, ) -> tuple[bool, str]: # proceed to uploading if forced (explicit requested or _upload_on_exit) # regardless of schedule if explicit_request: return True, "explicit request" if self._upload_on_exit: return True, "first-run upload on exit" # this is not an explicitly requested upload, so only proceed if today # is the day, or if the last upload is more than a week ago # # the last-upload-more-than-a-week-ago check is to avoid situations # where the user has not run ruyi for a long time, thus missing # the scheduled upload day. # # cron jobs are a mitigation, but we cannot rely on them either, because: # # * ruyi is more likely installed user-locally than system-wide, so # users may not set up cron jobs for themselves; # * telemetry data is always recorded per user so system-wide cron jobs # cannot easily access this data. last_upload_time: float | None = None if store := self.store(scope): last_upload_time = store.last_upload_timestamp if not self._is_upload_day(now): if last_upload_time is not None and now - last_upload_time >= 7 * 86400: return True, "last upload more than a week ago" return False, "not upload day" # now we're sure today is the day # if we're in cron mode, proceed as if it's an explicit request; # otherwise, only proceed if mode is "on" and we haven't uploaded yet today # for this scope if cron_mode: return True, "cron mode upload on upload day" if self._gc.telemetry_mode != "on": return False, "telemetry mode not 'on'" if not self._has_uploaded_today(last_upload_time, now): return True, "upload day, not yet uploaded today" return False, "upload day, already uploaded today" def flush(self, *, upload_now: bool = False, cron_mode: bool = False) -> None: """ Flush collected telemetry data to persistent store, and upload if needed. :param upload_now: Upload data right now regardless of schedule. :type upload_now: bool :param cron_mode: Whether this flush is called from a cron job. If true, non-upload-day uploads will be skipped, otherwise acts just like explicit uploads via `ruyi telemetry upload`. :type cron_mode: bool """ # We may be self-uninstalling and purging all state data, and in this # case we don't want to record anything (thus re-creating directories). if self._discard_events: self.logger.D("discarding collected telemetry data") return now = time.time() def should_proceed(scope: TelemetryScope) -> tuple[bool, str]: return self._should_proceed_with_upload( scope, explicit_request=upload_now, cron_mode=cron_mode, now=now, ) if self.minimal: if not self._upload_on_exit: self.logger.D("skipping upload for non-first-run in minimal mode") return for scope, store in self._stores.items(): go_ahead, reason = should_proceed(scope) self.logger.D( f"minimal telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}" ) if not go_ahead: continue store.upload_minimal() return for scope, store in self._stores.items(): self.logger.D(f"flushing telemetry to persistent store for scope {scope}") store.persist(now) go_ahead, reason = should_proceed(scope) self.logger.D( f"regular telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}" ) if not go_ahead: continue self._prepare_data_for_upload(store) store.upload_staged_payloads() def _prepare_data_for_upload(self, store: TelemetryStore) -> None: installation_data: NodeInfo | None = None if store.scope.is_pm: try: installation_data = self._read_installation_data() except FileNotFoundError: # should not happen due to is_upload_day() initializing it for us # beforehand, but proceed without node info nonetheless pass return store.prepare_data_for_upload(installation_data) def oobe_prompt(self) -> None: """Ask whether the user consents to a first-run telemetry upload, and persist the user's exact telemetry choice.""" if self._gc.is_telemetry_optout: # user has already explicitly opted out via the environment variable, # don't bother asking return # We always report installation info on first run, regardless of # user's telemetry choice. In case the user opts out, only do a one-time # upload now, and never upload anything again. self._upload_on_exit = True from ..cli import user_input self.logger.stdout(_(TELEMETRY_CONSENT_AND_UPLOAD_DESC)) if not user_input.ask_for_yesno_confirmation( self.logger, _(TELEMETRY_CONSENT_AND_UPLOAD_PROMPT), False, ): # ask if the user wants to opt out entirely if user_input.ask_for_yesno_confirmation( self.logger, _(TELEMETRY_OPTOUT_PROMPT), False, ): set_telemetry_mode(self._gc, "off") return # user wants to stay in local mode # explicitly record the preference, so we don't have to worry about # us potentially changing defaults yet another time set_telemetry_mode(self._gc, "local") return consent_time = datetime.datetime.now().astimezone() set_telemetry_mode(self._gc, "on", consent_time) ruyisdk-ruyi-1f00e2e/ruyi/telemetry/scope.py000066400000000000000000000021551520522431500212650ustar00rootroot00000000000000from typing import Literal, TypeAlias, TypeGuard TelemetryScopeConfig: TypeAlias = Literal["pm"] | Literal["repo"] def is_telemetry_scope_config(x: object) -> TypeGuard[TelemetryScopeConfig]: if not isinstance(x, str): return False match x: case "pm" | "repo": return True case _: return False class TelemetryScope: def __init__(self, repo_name: str | None) -> None: self._repo_name = repo_name def __repr__(self) -> str: return f"TelemetryScope(repo_name={self._repo_name})" def __str__(self) -> str: if self._repo_name: return f"repo:{self._repo_name}" return "pm" def __hash__(self) -> int: # behave like the inner field return hash(self._repo_name) def __eq__(self, value: object) -> bool: if not isinstance(value, TelemetryScope): return False return self._repo_name == value._repo_name @property def repo_name(self) -> str | None: return self._repo_name @property def is_pm(self) -> bool: return self._repo_name is None ruyisdk-ruyi-1f00e2e/ruyi/telemetry/store.py000066400000000000000000000235301520522431500213100ustar00rootroot00000000000000from functools import cached_property import json import os import pathlib import re import time from typing import Callable, Final, Iterable import uuid from ..log import RuyiLogger from ..utils.node_info import NodeInfo from ..utils.url import urljoin_for_sure from .aggregate import UploadPayload, aggregate_events from .event import TelemetryEvent, is_telemetry_event from .scope import TelemetryScope # e.g. "run.202410201845.d06ca5d668e64fec833ed3e6eb926a2c.ndjson" RE_RAW_EVENT_FILENAME: Final = re.compile( r"^run\.(?P\d{12})\.(?P[0-9a-f]{32})\.ndjson$" ) def get_time_bucket(timestamp: int | float | time.struct_time | None = None) -> str: if timestamp is None: return time.strftime("%Y%m%d%H%M") elif isinstance(timestamp, float) or isinstance(timestamp, int): timestamp = time.localtime(timestamp) return time.strftime("%Y%m%d%H%M", timestamp) def time_bucket_from_filename(filename: str) -> str | None: if m := RE_RAW_EVENT_FILENAME.match(filename): return m.group("time_bucket") return None class TelemetryStore: def __init__( self, logger: RuyiLogger, scope: TelemetryScope, store_root: pathlib.Path, api_url: str | None = None, api_url_factory: Callable[[], str | None] | None = None, ) -> None: self._logger = logger self.scope = scope self.store_root = store_root self._api_url = api_url self._api_url_factory = api_url_factory self._events: list[TelemetryEvent] = [] @cached_property def api_url(self) -> str | None: if u := self._api_url: return u if f := self._api_url_factory: return f() return None @property def raw_events_dir(self) -> pathlib.Path: return self.store_root / "raw" @property def upload_stage_dir(self) -> pathlib.Path: return self.store_root / "staged" @property def uploaded_dir(self) -> pathlib.Path: return self.store_root / "uploaded" @property def last_upload_marker_file(self) -> pathlib.Path: return self.store_root / ".stamp-last-upload" @property def last_upload_timestamp(self) -> float | None: try: return self.last_upload_marker_file.stat().st_mtime except FileNotFoundError: return None def record_upload_timestamp(self, time_now: float | None = None) -> None: if time_now is None: time_now = time.time() # We may not have store_root existing yet if we're in minimal telemetry # mode self.store_root.mkdir(parents=True, exist_ok=True) f = self.last_upload_marker_file f.touch() os.utime(f, (time_now, time_now)) def record(self, kind: str, **params: object) -> None: self._events.append({"fmt": 1, "kind": kind, "params": params}) def discard_events(self, v: bool = True) -> None: self._discard_events = v def persist(self, now: float | None = None) -> None: if not self._events: self._logger.D(f"scope {self.scope}: no event to persist") return now = time.time() if now is None else now self._logger.D(f"scope {self.scope}: flushing telemetry to persistent store") raw_events_dir = self.raw_events_dir raw_events_dir.mkdir(parents=True, exist_ok=True) # TODO: for now it is safe to not lock, because flush() is only ever # called at program exit time rough_time = get_time_bucket(now) rand = uuid.uuid4().hex batch_events_file = raw_events_dir / f"run.{rough_time}.{rand}.ndjson" with open(batch_events_file, "wb") as fp: for e in self._events: payload = json.dumps(e) fp.write(payload.encode("utf-8")) fp.write(b"\n") self._logger.D( f"scope {self.scope}: persisted {len(self._events)} telemetry event(s)" ) def read_back_raw_events(self) -> Iterable[TelemetryEvent]: try: for f in self.raw_events_dir.glob("run.*.ndjson"): time_bucket = time_bucket_from_filename(f.name) with open(f, "r", encoding="utf-8", newline=None) as fp: for line in fp: try: obj = json.loads(line) except json.JSONDecodeError: # losing some malformed telemetry events is okay continue if not is_telemetry_event(obj): # ditto continue if time_bucket is not None and "time_bucket" not in obj: obj["time_bucket"] = time_bucket yield obj except FileNotFoundError: pass def purge_raw_events(self) -> None: files = list(self.raw_events_dir.glob("run.*.ndjson")) for f in files: f.unlink(missing_ok=True) def gen_upload_staging_filename(self, nonce: str) -> pathlib.Path: return self.upload_stage_dir / f"staged.{nonce}.json" def prepare_data_for_upload(self, installation_data: NodeInfo | None) -> None: # import ruyi.version here because this package is on the CLI startup # critical path, and version probing is costly there from ..version import RUYI_SEMVER aggregate_data = list(aggregate_events(self.read_back_raw_events())) payload_nonce = uuid.uuid4().hex # for server-side dedup purposes payload: UploadPayload = { "fmt": 1, "nonce": payload_nonce, "ruyi_version": RUYI_SEMVER, "events": aggregate_data, } if installation_data is not None: payload["installation"] = installation_data dest_path = self.gen_upload_staging_filename(payload_nonce) self.upload_stage_dir.mkdir(parents=True, exist_ok=True) dest_path.write_text(json.dumps(payload), encoding="utf-8") self.purge_raw_events() def prepare_data_for_minimal_upload(self) -> bytes: """Prepare a minimal upload payload with no installation data and no events. Used when user has not consented to telemetry collection but also not explicitly opted out, for gaining minimal insight into adoption. """ # import ruyi.version here because this package is on the CLI startup # critical path, and version probing is costly there from ..version import RUYI_SEMVER payload_nonce = uuid.uuid4().hex # for server-side dedup purposes # We don't have installation data, and cannot have it initialized # in this case because absence of installation data means no user # consent. And making up a persistent installation ID is not a # choice either, because "installation ID" resembles "advertising # ID" a lot, which is considered personally identifiable information # (PII) and not allowed to be collected without user consent. # # So, resort to re-using the completely random nonce as the report # UUID, which does not allow for server-side correlation but at least # allows for some insight into end-user adoption. payload: UploadPayload = { "fmt": 1, "nonce": payload_nonce, "ruyi_version": RUYI_SEMVER, "report_uuid": payload_nonce, "events": [], } return json.dumps(payload).encode("utf-8") def upload_minimal(self) -> None: if not self.api_url: return p = self.prepare_data_for_minimal_upload() self.upload_one_staged_payload(p, self.api_url) self.record_upload_timestamp() def upload_staged_payloads(self) -> None: if not self.api_url: return try: staged_payloads = list(self.upload_stage_dir.glob("staged.*.json")) except FileNotFoundError: return try: self.uploaded_dir.mkdir(parents=True, exist_ok=True) except OSError: return for f in staged_payloads: self.upload_one_staged_payload(f, self.api_url) self.record_upload_timestamp() def upload_one_staged_payload( self, f: pathlib.Path | bytes, endpoint: str, ) -> None: # import ruyi.version here because this package is on the CLI startup # critical path, and version probing is costly there from ..version import RUYI_USER_AGENT api_path = urljoin_for_sure(endpoint, "upload-v1") if isinstance(f, pathlib.Path): self._logger.D( f"scope {self.scope}: about to upload payload {f} to {api_path}" ) data = f.read_bytes() else: self._logger.D( f"scope {self.scope}: about to upload in-memory payload to {api_path}" ) data = f import requests resp = requests.post( api_path, data=data, headers={"User-Agent": RUYI_USER_AGENT}, allow_redirects=True, timeout=5, ) if not (200 <= resp.status_code < 300): self._logger.D( f"scope {self.scope}: telemetry upload failed: status code {resp.status_code}, content {resp.content.decode('utf-8', 'replace')}" ) return self._logger.D( f"scope {self.scope}: telemetry upload ok: status code {resp.status_code}" ) if isinstance(f, pathlib.Path): # move to completed dir # TODO: rotation try: f.rename(self.uploaded_dir / f.name) except OSError as e: self._logger.D( f"scope {self.scope}: failed to move uploaded payload away: {e}" ) ruyisdk-ruyi-1f00e2e/ruyi/telemetry/telemetry_cli.py000066400000000000000000000077501520522431500230230ustar00rootroot00000000000000import argparse import datetime from typing import TYPE_CHECKING from ..cli.cmd import RootCommand from ..i18n import _ if TYPE_CHECKING: from ..cli.completion import ArgumentParser from ..config import GlobalConfig # Telemetry preference commands class TelemetryCommand( RootCommand, cmd="telemetry", has_main=True, has_subcommands=True, help=_("Manage your telemetry preferences"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: # https://github.com/python/cpython/issues/67037 prevents the registration # of undocumented subcommands, so a preferred usage of # "ruyi telemetry cron-upload" is not possible right now. p.add_argument( "--cron-upload", action="store_true", dest="cron_upload", default=False, help=argparse.SUPPRESS, ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: cron_upload: bool = args.cron_upload if not cron_upload: args._parser.print_help() # pylint: disable=protected-access return 0 # the rest are implementation of "--cron-upload" cfg.telemetry.flush(cron_mode=True) return 0 class TelemetryConsentCommand( TelemetryCommand, cmd="consent", aliases=["on"], help=_("Give consent to telemetry data uploads"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .provider import set_telemetry_mode now = datetime.datetime.now().astimezone() set_telemetry_mode(cfg, "on", now) return 0 class TelemetryLocalCommand( TelemetryCommand, cmd="local", help=_("Set telemetry mode to local collection only"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .provider import set_telemetry_mode set_telemetry_mode(cfg, "local") return 0 class TelemetryOptoutCommand( TelemetryCommand, cmd="optout", aliases=["off"], help=_("Opt out of telemetry data collection"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: from .provider import set_telemetry_mode set_telemetry_mode(cfg, "off") return 0 class TelemetryStatusCommand( TelemetryCommand, cmd="status", help=_("Print the current telemetry mode"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: p.add_argument( "--verbose", "-v", action="store_true", help=_("Enable verbose output"), ) @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: verbose: bool = args.verbose if not verbose: cfg.logger.stdout(cfg.telemetry_mode) return 0 if cfg.telemetry is None: cfg.logger.I( _("telemetry mode is [green]off[/]: no further data will be collected") ) return 0 cfg.telemetry.print_telemetry_notice(for_cli_verbose_output=True) return 0 class TelemetryUploadCommand( TelemetryCommand, cmd="upload", help=_("Upload collected telemetry data now"), ): @classmethod def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: pass @classmethod def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: cfg.telemetry.flush(upload_now=True) # disable the flush at program exit because we have just done that cfg.telemetry.discard_events() return 0 ruyisdk-ruyi-1f00e2e/ruyi/utils/000077500000000000000000000000001520522431500167255ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/utils/__init__.py000066400000000000000000000000001520522431500210240ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/ruyi/utils/ar.py000066400000000000000000000043371520522431500177100ustar00rootroot00000000000000from contextlib import AbstractContextManager from typing import BinaryIO, TYPE_CHECKING if TYPE_CHECKING: from types import TracebackType import arpy class ArpyArchiveWrapper(arpy.Archive, AbstractContextManager["arpy.Archive"]): """Compatibility shim for arpy.Archive, for easy interop with both arpy 1.x and 2.x.""" def __init__( self, filename: str | None = None, fileobj: BinaryIO | None = None, ) -> None: super().__init__(filename=filename, fileobj=fileobj) def __enter__(self) -> arpy.Archive: if hasattr(super(), "__enter__"): # in case we're working with a newer arpy version that has a # non-trivial __enter__ implementation return super().__enter__() # backport of arpy 2.x __enter__ implementation return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: "TracebackType | None", ) -> None: if hasattr(super(), "__exit__"): return super().__exit__(exc_type, exc_value, traceback) # backport of arpy 2.x __exit__ implementation self.close() def infolist(self) -> list[arpy.ArchiveFileHeader]: if hasattr(super(), "infolist"): return super().infolist() # backport of arpy 2.x infolist() self.read_all_headers() return [ header for header in self.headers if header.type in ( arpy.HEADER_BSD, arpy.HEADER_NORMAL, arpy.HEADER_GNU, ) ] def open(self, name: bytes | arpy.ArchiveFileHeader) -> arpy.ArchiveFileData: if hasattr(super(), "open"): return super().open(name) # backport of arpy 2.x open() if isinstance(name, bytes): ar_file = self.archived_files.get(name) if ar_file is None: raise KeyError("There is no item named %r in the archive" % (name,)) return ar_file if name not in self.headers: raise KeyError("Provided header does not match this archive") return arpy.ArchiveFileData(ar_obj=self, header=name) ruyisdk-ruyi-1f00e2e/ruyi/utils/ci.py000066400000000000000000000056321520522431500177000ustar00rootroot00000000000000from typing import Mapping def is_running_in_ci(os_environ: Mapping[str, str]) -> bool: """Simplified and quick CI check meant for basic judgement.""" if os_environ.get("CI", "") == "true": return True elif os_environ.get("TF_BUILD", "") == "True": return True return False def probe_for_ci(os_environ: Mapping[str, str]) -> str | None: # https://www.appveyor.com/docs/environment-variables/ if os_environ.get("APPVEYOR", "").lower() == "true": return "appveyor" # https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#system-variables-devops-services elif os_environ.get("TF_BUILD", "") == "True": return "azure" # https://circleci.com/docs/variables/#built-in-environment-variables elif os_environ.get("CIRCLECI", "") == "true": return "circleci" # https://cirrus-ci.org/guide/writing-tasks/#environment-variables elif os_environ.get("CIRRUS_CI", "") == "true": return "cirrus" # https://gitea.com/gitea/act_runner/pulls/113 # this should be checked before GHA because upstream maintains compatibility # with GHA by also providing GHA-style preset variables # TODO: also detect Forgejo elif os_environ.get("GITEA_ACTIONS", "") == "true": return "gitea" # https://gitee.com/help/articles/4358#article-header8 elif "GITEE_PIPELINE_NAME" in os_environ: return "gitee" # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables elif os_environ.get("GITHUB_ACTIONS", "") == "true": return "github" # https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#predefined-variables elif os_environ.get("GITLAB_CI", "") == "true": return "gitlab" # https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables # may have false-negatives but likely no false-positives elif "JENKINS_URL" in os_environ: return "jenkins" # https://gitee.com/openeuler/mugen # seems nothing except $OET_PATH is guaranteed elif "OET_PATH" in os_environ: return "mugen" # there seems to be no designated marker for openQA, test a couple of # hopefully ubiquitous variables to avoid going through the entire key set elif "OPENQA_CONFIG" in os_environ or "OPENQA_URL" in os_environ: return "openqa" # https://docs.travis-ci.com/user/environment-variables/#default-environment-variables elif os_environ.get("TRAVIS", "") == "true": return "travis" # https://docs.koderover.com/zadig/Zadig%20v3.1/project/build/ # https://github.com/koderover/zadig/blob/v3.1.0/pkg/microservice/jobexecutor/core/service/job.go#L117 elif os_environ.get("ZADIG", "") == "true": return "zadig" elif os_environ.get("CI", "") == "true": return "unidentified" return None ruyisdk-ruyi-1f00e2e/ruyi/utils/frontmatter.py000066400000000000000000000020341520522431500216430ustar00rootroot00000000000000# Minimal frontmatter support for Markdown, because [python-frontmatter] is # not packaged in major Linux distributions, complicating packaging work. # # Only the YAML frontmatter is supported here, unlike python-frontmatter # which supports additionally JSON and TOML frontmatter formats. # # [python-frontmatter]: https://github.com/eyeseast/python-frontmatter import re from typing import Final import yaml FRONTMATTER_BOUNDARY_RE: Final = re.compile(r"(?m)^-{3,}\s*$") class Post: def __init__(self, metadata: dict[str, object] | None, content: str) -> None: self._md = metadata self.content = content def get(self, key: str) -> object | None: return None if self._md is None else self._md.get(key) def loads(s: str) -> Post: m = FRONTMATTER_BOUNDARY_RE.match(s) if m is None: return Post(None, s) x = FRONTMATTER_BOUNDARY_RE.split(s, 2) if len(x) != 3: return Post(None, s) fm, content = x[1], x[2] metadata = yaml.safe_load(fm) return Post(metadata, content) ruyisdk-ruyi-1f00e2e/ruyi/utils/git.py000066400000000000000000000141301520522431500200610ustar00rootroot00000000000000from contextlib import AbstractContextManager import pathlib from typing import Any, TYPE_CHECKING from pygit2 import GitError, Oid from pygit2.callbacks import RemoteCallbacks from pygit2.repository import Repository try: from pygit2.remotes import TransferProgress except ModuleNotFoundError: # pygit2 < 1.14.0 # see https://github.com/libgit2/pygit2/commit/a8b2421bea550292 # # import-untyped: the current pygit2 type stubs were written after the # `remote` -> `remotes` rename, so no stubs for it from pygit2.remote import TransferProgress # type: ignore[import-not-found,import-untyped,no-redef,unused-ignore] # for compatibility with <1.14.0, cannot `from pygit2.enums import MergeAnalysis` # see https://github.com/libgit2/pygit2/pull/1251 from pygit2 import ( GIT_MERGE_ANALYSIS_UNBORN, GIT_MERGE_ANALYSIS_FASTFORWARD, GIT_MERGE_ANALYSIS_UP_TO_DATE, ) from rich.progress import Progress, TaskID from rich.text import Text if TYPE_CHECKING: from typing_extensions import Self from ..i18n import _ from ..log import RuyiLogger def human_readable_path_of_repo(repo: Repository) -> pathlib.Path: """ Returns a human-readable path of the repository. If the repository is a submodule, returns the path to the parent module. """ repo_path = pathlib.Path(repo.path) return repo_path.parent if repo_path.name == ".git" else repo_path class RemoteGitProgressIndicator( RemoteCallbacks, AbstractContextManager["RemoteGitProgressIndicator"], ): def __init__(self) -> None: super().__init__() self.p = Progress() self.task: TaskID | None = None self._last_stats: TransferProgress | None = None self._task_name: str = "" def __enter__(self) -> "Self": self.p.__enter__() return self def __exit__(self, exc_type: Any, exc_value: Any, tb: Any) -> None: return self.p.__exit__(exc_type, exc_value, tb) # Compatibility with pygit2 < 1.8.0. def progress(self, string: str) -> None: return self.sideband_progress(string) def sideband_progress(self, string: str) -> None: self.p.console.print("\r", Text(string), sep="", end="") def transfer_progress(self, stats: TransferProgress) -> None: new_phase = False task_name: str = self._task_name total: int = 0 completed: int = 0 if ( self._last_stats is None or self._last_stats.received_objects != stats.received_objects ): task_name = _("transferring objects") total = stats.total_objects completed = stats.received_objects elif self._last_stats.indexed_deltas != stats.indexed_deltas: task_name = _("processing deltas") total = stats.total_deltas completed = stats.indexed_deltas elif self._last_stats.received_bytes != stats.received_bytes: # we don't render the received size at the moment pass new_phase = self._task_name != task_name if new_phase: self.task = self.p.add_task(task_name, total=total, completed=completed) self._task_name = task_name else: if self.task is not None: self.p.update(self.task, total=total, completed=completed) self._last_stats = stats # based on https://stackoverflow.com/questions/27749418/implementing-pull-with-pygit2 def pull_ff_or_die( logger: RuyiLogger, repo: Repository, remote_name: str, remote_url: str, branch_name: str, *, allow_auto_management: bool, ) -> None: remote = repo.remotes[remote_name] if remote.url != remote_url: if not allow_auto_management: logger.F( _( "URL of remote '[yellow]{remote}[/]' does not match expected URL" ).format(remote=remote_name) ) repo_path = human_readable_path_of_repo(repo) logger.I(_("repository: [yellow]{path}[/]").format(path=repo_path)) logger.I(_("expected remote URL: [yellow]{url}[/]").format(url=remote_url)) logger.I(_("actual remote URL: [yellow]{url}[/]").format(url=remote.url)) logger.I(_("please [bold red]fix the repo settings manually[/]")) raise SystemExit(1) logger.D( f"updating url of remote {remote_name} from {remote.url} to {remote_url}" ) repo.remotes.set_url(remote_name, remote_url) # this needs manual refreshing remote = repo.remotes[remote_name] logger.D("fetching") try: with RemoteGitProgressIndicator() as pr: remote.fetch(callbacks=pr) except GitError as e: logger.F( _("failed to fetch from remote URL {url}: {reason}").format( url=remote_url, reason=e ) ) raise SystemExit(1) from e remote_head_ref = repo.lookup_reference(f"refs/remotes/{remote_name}/{branch_name}") remote_head: Oid if isinstance(remote_head_ref.target, Oid): remote_head = remote_head_ref.target else: assert isinstance(remote_head_ref.target, str) remote_head = Oid(hex=remote_head_ref.target) merge_analysis, _mp = repo.merge_analysis(remote_head) if merge_analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE: # nothing to do logger.D("repo state already up-to-date") return if merge_analysis & (GIT_MERGE_ANALYSIS_UNBORN | GIT_MERGE_ANALYSIS_FASTFORWARD): # simple fast-forwarding is enough in both cases logger.D(f"fast-forwarding repo to {remote_head}") tgt = repo.get(remote_head) assert tgt is not None repo.checkout_tree(tgt) logger.D(f"updating branch {branch_name} HEAD") local_branch_ref = repo.lookup_reference(f"refs/heads/{branch_name}") local_branch_ref.set_target(remote_head) repo.head.set_target(remote_head) return # cannot handle these cases logger.F(_("cannot fast-forward repo to newly fetched state")) logger.I(_("manual intervention is required to avoid data loss")) raise SystemExit(1) ruyisdk-ruyi-1f00e2e/ruyi/utils/global_mode.py000066400000000000000000000125411520522431500215460ustar00rootroot00000000000000import abc import os from typing import Final, Mapping, Protocol, Sequence, runtime_checkable import ruyi ENV_DEBUG: Final = "RUYI_DEBUG" ENV_EXPERIMENTAL: Final = "RUYI_EXPERIMENTAL" ENV_FORCE_ALLOW_ROOT: Final = "RUYI_FORCE_ALLOW_ROOT" ENV_TELEMETRY_OPTOUT_KEY: Final = "RUYI_TELEMETRY_OPTOUT" ENV_VENV_ROOT_KEY: Final = "RUYI_VENV" TRUTHY_ENV_VAR_VALUES: Final = {"1", "true", "x", "y", "yes"} def is_env_var_truthy(env: Mapping[str, str], var: str) -> bool: if v := env.get(var): return v.lower() in TRUTHY_ENV_VAR_VALUES return False @runtime_checkable class ProvidesGlobalMode(Protocol): @property def argv0(self) -> str: ... @property def main_file(self) -> str: ... @property def self_exe(self) -> str: ... @property def is_debug(self) -> bool: ... @property def is_experimental(self) -> bool: ... @property def is_packaged(self) -> bool: ... @property def is_porcelain(self) -> bool: ... @property def is_telemetry_optout(self) -> bool: ... @property def is_cli_autocomplete(self) -> bool: ... @property def venv_root(self) -> str | None: ... class GlobalModeProvider(metaclass=abc.ABCMeta): """ Abstract base class for global mode providers. """ @property @abc.abstractmethod def argv0(self) -> str: return "" @property @abc.abstractmethod def main_file(self) -> str: return "" @property @abc.abstractmethod def self_exe(self) -> str: return "" def record_self_exe(self, argv0: str, main_file: str, self_exe: str) -> None: pass @property @abc.abstractmethod def is_debug(self) -> bool: return False @property @abc.abstractmethod def is_experimental(self) -> bool: return False @property @abc.abstractmethod def is_packaged(self) -> bool: return False @property @abc.abstractmethod def is_porcelain(self) -> bool: return False @is_porcelain.setter @abc.abstractmethod def is_porcelain(self, v: bool) -> None: pass @property @abc.abstractmethod def is_telemetry_optout(self) -> bool: return False @property @abc.abstractmethod def is_cli_autocomplete(self) -> bool: return False @property @abc.abstractmethod def venv_root(self) -> str | None: return None def _guess_porcelain_from_argv(argv: list[str]) -> bool: """ Guess if the current invocation is a "porcelain" command based on the arguments passed, without requiring the ``argparse`` machinery to be completely initialized. """ # If the first argument is `--porcelain`, we assume it's a porcelain command. # This is currently accurate as the porcelain flag is only possible at this # position right now. return len(argv) > 1 and argv[1] == "--porcelain" def is_cli_completion_script_requested(argv: Sequence[str]) -> bool: for arg in argv: if arg.split("=", 1)[0] == "--output-completion-script": return True return False def _probe_cli_autocomplete(env: Mapping[str, str]) -> bool: """ Probe if the current invocation is an argcomplete subprocess based on the environment, without requiring the ``argparse`` machinery to be completely initialized. """ return "_ARGCOMPLETE" in env class EnvGlobalModeProvider(GlobalModeProvider): def __init__( self, env: Mapping[str, str] | None = None, argv: list[str] | None = None, ) -> None: if env is None: env = os.environ if argv is None: argv = [] self._argv0 = "" self._main_file = "" self._self_exe = "" self._is_debug = is_env_var_truthy(env, ENV_DEBUG) self._is_experimental = is_env_var_truthy(env, ENV_EXPERIMENTAL) self._is_porcelain = _guess_porcelain_from_argv(argv) self._is_telemetry_optout = is_env_var_truthy(env, ENV_TELEMETRY_OPTOUT_KEY) # We have to lift this piece of implementation detail out of argcomplete, # as the argcomplete import is very costly in terms of startup time. self._is_cli_autocomplete = _probe_cli_autocomplete(env) self._venv_root = env.get(ENV_VENV_ROOT_KEY) @property def argv0(self) -> str: return self._argv0 @property def main_file(self) -> str: return self._main_file @property def self_exe(self) -> str: return self._self_exe def record_self_exe(self, argv0: str, main_file: str, self_exe: str) -> None: self._argv0 = argv0 self._main_file = main_file self._self_exe = self_exe @property def is_debug(self) -> bool: return self._is_debug @property def is_experimental(self) -> bool: return self._is_experimental @property def is_packaged(self) -> bool: return hasattr(ruyi, "__compiled__") @property def is_porcelain(self) -> bool: return self._is_porcelain @is_porcelain.setter def is_porcelain(self, v: bool) -> None: self._is_porcelain = v @property def is_telemetry_optout(self) -> bool: return self._is_telemetry_optout @property def is_cli_autocomplete(self) -> bool: return self._is_cli_autocomplete @property def venv_root(self) -> str | None: return self._venv_root ruyisdk-ruyi-1f00e2e/ruyi/utils/l10n.py000066400000000000000000000047421520522431500200600ustar00rootroot00000000000000import locale from typing import Iterable, NamedTuple class LangAndRegion(NamedTuple): raw: str lang: str region: str | None def lang_code_to_lang_region(lang_code: str, guess_region: bool) -> LangAndRegion: if not guess_region and "_" not in lang_code: return LangAndRegion(lang_code, lang_code, None) lang_region_str = locale.normalize(lang_code).split(".")[0] parts = lang_region_str.split("_", 2) if len(parts) == 1: return LangAndRegion(lang_code, lang_region_str, None) return LangAndRegion(lang_code, parts[0], parts[1]) def match_lang_code( req: str, avail: Iterable[str], ) -> str: """Returns a proper available language code based on a list of available language codes, and a request.""" if not isinstance(avail, set) or not isinstance(avail, frozenset): avail = set(avail) # return the only one choice if this is the case if len(avail) == 1: return next(iter(avail)) # try exact match if req in avail: return req return _match_lang_code_slowpath( lang_code_to_lang_region(req, True), [lang_code_to_lang_region(x, False) for x in avail], ) def _match_lang_code_slowpath( req: LangAndRegion, avail: list[LangAndRegion], ) -> str: # pick one with the requested region if req.region is not None: for x in avail: if x.region == req.region: return x.raw # if no match, pick one with the requested language for x in avail: if x.lang == req.lang: return x.raw # neither matches, fallback to (en_US, en, en_*, zh_CN, zh, zh_*) # in that order fallback_en = {x.region: x.raw for x in avail if x.lang == "en"} if fallback_en: if "US" in fallback_en: return fallback_en["US"] if None in fallback_en: return fallback_en[None] return fallback_en[sorted(x for x in fallback_en.keys() if x is not None)[0]] fallback_zh = {x.region: x.raw for x in avail if x.lang == "zh"} if fallback_zh: if "CN" in fallback_zh: return fallback_zh["CN"] if None in fallback_zh: return fallback_zh[None] return fallback_zh[sorted(x for x in fallback_zh.keys() if x is not None)[0]] # neither en nor zh is available (which is highly unlikely at present) # pick the first available one as a last resort # sort the list before picking for determinism return sorted(x.raw for x in avail)[0] ruyisdk-ruyi-1f00e2e/ruyi/utils/markdown.py000066400000000000000000000051361520522431500211260ustar00rootroot00000000000000from rich.console import Console, ConsoleOptions, RenderResult from rich.markdown import CodeBlock, Heading, Markdown, MarkdownContext from rich.syntax import Syntax from rich.text import Text class SlimHeading(Heading): def on_enter(self, context: MarkdownContext) -> None: try: # the heading level is indicated in the tag name in rich >= 13.2.0, # e.g. self.tag == 'h1', but directly stored in earlier versions # as self.level. # # see https://github.com/Textualize/rich/commit/a20c3d5468d02a55 heading_level = int(self.tag[1:]) # type: ignore[attr-defined,unused-ignore] except AttributeError: heading_level = self.level # type: ignore[attr-defined,unused-ignore] context.enter_style(self.style_name) self.text = Text("#" * heading_level + " ", context.current_style) def __rich_console__( self, console: Console, options: ConsoleOptions, ) -> RenderResult: yield self.text # inspired by https://github.com/Textualize/rich/issues/3154 class NonWrappingCodeBlock(CodeBlock): def __rich_console__( self, console: Console, options: ConsoleOptions, ) -> RenderResult: # re-enable non-wrapping options locally for code blocks render_options = options.update(no_wrap=True, overflow="ignore") code = str(self.text).rstrip() syntax = Syntax( code, self.lexer_name, theme=self.theme, word_wrap=False, # not supported in rich <= 12.4.0 (Textualize/rich#2247) but fortunately # zero padding is the default anyway # padding=0, ) return syntax.highlight(code).__rich_console__(console, render_options) class RuyiStyledMarkdown(Markdown): elements = Markdown.elements elements["fence"] = NonWrappingCodeBlock elements["heading_open"] = SlimHeading # rich < 13.2.0 # see https://github.com/Textualize/rich/commit/745bd99e416c2806 # it doesn't hurt to just unconditionally add them like below elements["code"] = NonWrappingCodeBlock elements["code_block"] = NonWrappingCodeBlock elements["heading"] = SlimHeading def __rich_console__( self, console: Console, options: ConsoleOptions, ) -> RenderResult: # we have to undo the ruyi-global console's non-wrapping setting # for proper CLI rendering of long lines render_options = options.update(no_wrap=False, overflow="fold") return super().__rich_console__(console, render_options) ruyisdk-ruyi-1f00e2e/ruyi/utils/mounts.py000066400000000000000000000022721520522431500206270ustar00rootroot00000000000000"""Utilities for parsing mount information from /proc/self/mounts.""" import pathlib import re from typing import NamedTuple class MountInfo(NamedTuple): source: str target: str fstype: str options: list[str] @property def source_path(self) -> pathlib.Path: return pathlib.Path(self.source) @property def source_is_blkdev(self) -> bool: return self.source_path.is_block_device() def parse_mounts(contents: str | None = None) -> list[MountInfo]: if contents is None: try: with open("/proc/self/mounts", "r", encoding="utf-8") as f: contents = f.read() except OSError: return [] mounts: list[MountInfo] = [] for line in contents.splitlines(): parts = line.split() if len(parts) < 4: continue source, target, fstype, opts = parts[:4] options = opts.split(",") source = _unescape_octals(source) target = _unescape_octals(target) mounts.append(MountInfo(source, target, fstype, options)) return mounts def _unescape_octals(s: str) -> str: return re.sub(r"\\([0-3][0-7]{2})", lambda m: chr(int(m.group(1), 8)), s) ruyisdk-ruyi-1f00e2e/ruyi/utils/node_info.py000066400000000000000000000147131520522431500212450ustar00rootroot00000000000000import glob import os import pathlib import platform import re import subprocess import sys from typing import Final, Mapping, TypedDict, TYPE_CHECKING import uuid if TYPE_CHECKING: from typing_extensions import NotRequired from .ci import probe_for_ci class NodeInfo(TypedDict): v: int report_uuid: str arch: str ci: str libc_name: str libc_ver: str os: str os_release_id: str os_release_version_id: str shell: str riscv_machine: "NotRequired[RISCVMachineInfo]" class RISCVMachineInfo(TypedDict): model_name: str cpu_count: int isa: str uarch: str uarch_csr: str mmu: str def probe_for_libc() -> tuple[str, str]: r = platform.libc_ver() if r[0] and r[1]: return r # check for musl ld.so at the upstream standard paths, because # platform.libc_ver() as of Python 3.12 does not know how to handle musl # # see https://wiki.musl-libc.org/guidelines-for-distributions musl_lds = glob.glob("/lib/ld-musl-*.so.1") if musl_lds: # run it and check for "Version *.*.*" # in case of multiple hits (hybrid-architecture sysroot?), hope the # first one that successfully returns something is the native one for p in musl_lds: if ver := _try_get_musl_ver(p): return ("musl", ver) return ("unknown", "unknown") _MUSL_VERSION_RE: Final = re.compile(rb"(?m)^Version ([0-9.]+)$") def _try_get_musl_ver(ldso_path: str) -> str | None: res = subprocess.run([ldso_path], stderr=subprocess.PIPE) if m := _MUSL_VERSION_RE.search(res.stderr): return m.group(1).decode("ascii", "ignore") return None def _try_parse_hex(v: str) -> int | None: if not v.startswith("0x"): return None try: return int(v[2:], 16) except ValueError: return None def probe_for_riscv_machine_info( model_name: str | None = None, cpuinfo_data: str | None = None, ) -> RISCVMachineInfo | None: if model_name is None: try: with open( "/sys/firmware/devicetree/base/model", "r", encoding="utf-8", ) as fp: model_name = fp.read().strip(" \n\t\x00") except Exception: pass if not model_name: model_name = "unknown" if cpuinfo_data is None: try: with open("/proc/cpuinfo", "r", encoding="utf-8") as fp: cpuinfo_data = fp.read() except Exception: pass cpu_count = 0 isa, mmu, uarch = "unknown", "unknown", "unknown" mvendorid: int | None = None marchid: int | None = None mimpid: int | None = None if cpuinfo_data is not None: for line in cpuinfo_data.split("\n"): if not line: continue try: k, v = line.split(": ", 1) except ValueError: # malformed line: non-empty but no ": " continue k = k.strip(" \t") v = v.strip() match k: case "processor": cpu_count += 1 case "isa": isa = v case "mmu": mmu = v case "uarch": uarch = v case "mvendorid": mvendorid = _try_parse_hex(v) case "marchid": marchid = _try_parse_hex(v) case "mimpid": mimpid = _try_parse_hex(v) case _: continue if mvendorid is not None and marchid is not None and mimpid is not None: uarch_csr = f"{mvendorid:x}:{marchid:x}:{mimpid:x}" else: uarch_csr = "unknown" return { "model_name": model_name, "cpu_count": cpu_count, "isa": isa, "mmu": mmu, "uarch": uarch, "uarch_csr": uarch_csr, } def probe_for_shell(os_environ: Mapping[str, str]) -> str: if x := os_environ.get("SHELL"): return os.path.basename(x) return "unknown" def probe_for_container_runtime(os_environ: Mapping[str, str]) -> str: """Check if we are likely running in a container. Probes FS and environment for signatures of known container runtimes.""" # check environment markers first if "KUBERNETES_SERVICE_HOST" in os_environ: return "kubernetes" if "container" in os_environ: v = os_environ["container"].lower() if v == "oci": return "other-oci-compliant" # could be e.g. "lxc", "lxc-libvirt", "systemd-nspawn", etc. return v # check for filesystem markers if os.path.exists("/run/.containerenv"): return "podman" # Docker must be checked after Podman if os.path.exists("/.dockerenv"): return "docker" try: v = pathlib.Path("/run/systemd/container").read_text(encoding="utf-8").strip() if v: return v.lower() except Exception: pass if _probe_for_wsl(): return "wsl" return "unknown" def _probe_for_wsl() -> bool: if sys.platform != "linux": return False # http://github.com/Microsoft/WSL/issues/423#issuecomment-221627364 for path in ("/proc/sys/kernel/osrelease", "/proc/version"): try: ver = pathlib.Path(path).read_text(encoding="utf-8") except Exception: continue return "Microsoft" in ver or "WSL" in ver return False def gather_node_info(report_uuid: uuid.UUID | None = None) -> NodeInfo: arch = platform.machine() libc = probe_for_libc() os_release = platform.freedesktop_os_release() os_version = os_release.get("VERSION_CODENAME") # works on e.g. Debian if not os_version: os_version = os_release.get("VERSION_ID") # works on e.g. openEuler, Gentoo if not os_version: os_version = "unknown" data: NodeInfo = { "v": 1, "report_uuid": report_uuid.hex if report_uuid is not None else uuid.uuid4().hex, "arch": arch, "ci": probe_for_ci(os.environ) or "maybe-not", "libc_name": libc[0], "libc_ver": libc[1], "os": sys.platform, "os_release_id": os_release.get("ID", "unknown"), "os_release_version_id": os_version, "shell": probe_for_shell(os.environ), } if arch.startswith("riscv"): if riscv_machine := probe_for_riscv_machine_info(): data["riscv_machine"] = riscv_machine return data ruyisdk-ruyi-1f00e2e/ruyi/utils/nuitka.py000066400000000000000000000021301520522431500205660ustar00rootroot00000000000000import os import sys def get_nuitka_self_exe() -> str: try: # Assume we're a Nuitka onefile build, so our parent process is the onefile # bootstrap process. The onefile bootstrapper puts "our path" in the # undocumented environment variable $NUITKA_ONEFILE_BINARY, which works # on both Linux and Windows. return os.environ["NUITKA_ONEFILE_BINARY"] except KeyError: # It seems we are instead launched from the extracted onefile tempdir. # Assume our name is "ruyi" in this case; directory is available in # Nuitka metadata. import ruyi return os.path.join(ruyi.__compiled__.containing_dir, "ruyi") def get_argv0() -> str: import ruyi try: if ruyi.__compiled__.original_argv0 is not None: return ruyi.__compiled__.original_argv0 except AttributeError: # Either we're not packaged with Nuitka, or the Nuitka used is # without our original_argv0 patch, in which case we cannot do any # better than simply returning sys.argv[0]. pass return sys.argv[0] ruyisdk-ruyi-1f00e2e/ruyi/utils/porcelain.py000066400000000000000000000045621520522431500212620ustar00rootroot00000000000000from contextlib import AbstractContextManager import enum import io import json import sys from types import TracebackType from typing import Protocol, TypedDict, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Self class PorcelainEntityType(enum.StrEnum): LogV1 = "log-v1" NewsItemV1 = "newsitem-v1" PkgListOutputV1 = "pkglistoutput-v1" EntityListOutputV1 = "entitylistoutput-v1" RepoEntryV1 = "repoentry-v1" CheckDiagnosticV1 = "checkdiagnostic-v1" class PorcelainEntity(TypedDict): ty: PorcelainEntityType class PorcelainBinarySink(Protocol): def flush(self) -> None: ... def write(self, b: bytes, /) -> int: ... class PorcelainTextSink(Protocol): def flush(self) -> None: ... def write(self, s: str, /) -> int: ... class PorcelainOutput(AbstractContextManager["PorcelainOutput"]): def __init__( self, *, binary_out: PorcelainBinarySink | None = None, text_out: PorcelainTextSink | None = None, ) -> None: self._txt_out: PorcelainTextSink | None self._bin_out: PorcelainBinarySink | None if binary_out is None and text_out is None: if isinstance(sys.stdout, io.TextIOWrapper): self._bin_out = sys.stdout.buffer self._txt_out = None else: self._bin_out = None self._txt_out = sys.stdout return if binary_out is not None and text_out is not None: raise ValueError("cannot specify both binary_out and text_out") self._txt_out = text_out self._bin_out = binary_out def __enter__(self) -> "Self": return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> bool | None: if self._txt_out is not None: self._txt_out.flush() if self._bin_out is not None: self._bin_out.flush() return None def emit(self, obj: PorcelainEntity) -> None: s = json.dumps(obj, ensure_ascii=False, separators=(",", ":")) if self._txt_out is not None: self._txt_out.write(s) self._txt_out.write("\n") return assert self._bin_out is not None self._bin_out.write(s.encode("utf-8")) self._bin_out.write(b"\n") ruyisdk-ruyi-1f00e2e/ruyi/utils/prereqs.py000066400000000000000000000042051520522431500207610ustar00rootroot00000000000000import shutil import sys from typing import Final, Iterable, NoReturn from ..cli.user_input import pause_before_continuing from ..i18n import _ from ..log import RuyiLogger, humanize_list def has_cmd_in_path(cmd: str) -> bool: return shutil.which(cmd) is not None _CMDS: Final = ( "bzip2", "gunzip", "lz4", "tar", "xz", "zstd", "unzip", # commands used by the device provisioner "sudo", "dd", "fastboot", ) _CMD_PRESENCE_MAP: Final[dict[str, bool]] = {} def init_cmd_presence_map() -> None: _CMD_PRESENCE_MAP.clear() for cmd in _CMDS: _CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd) def ensure_cmds( logger: RuyiLogger, cmds: Iterable[str], interactive_retry: bool = True, ) -> None | NoReturn: # only allow interactive retry if stdin is a TTY interactive_retry = interactive_retry and sys.stdin.isatty() while True: if not _CMD_PRESENCE_MAP or interactive_retry: init_cmd_presence_map() # in case any command's availability is not cached in advance for cmd in cmds: if cmd not in _CMD_PRESENCE_MAP: _CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd) absent_cmds = sorted( cmd for cmd in cmds if not _CMD_PRESENCE_MAP.get(cmd, False) ) if not absent_cmds: return None cmds_str = humanize_list(absent_cmds, item_color="yellow") prompt = _( "The command(s) {cmds} cannot be found in PATH, which [yellow]ruyi[/] requires" ).format(cmds=cmds_str) if not interactive_retry: logger.F(prompt) logger.I(_("please install and retry")) sys.exit(1) logger.W(prompt) logger.I( _( "please install them and press [green]Enter[/] to retry, or [green]Ctrl+C[/] to exit" ) ) try: pause_before_continuing(logger) except EOFError: logger.I(_("exiting due to EOF")) sys.exit(1) except KeyboardInterrupt: logger.I(_("exiting due to keyboard interrupt")) sys.exit(1) ruyisdk-ruyi-1f00e2e/ruyi/utils/ssl_patch.py000066400000000000000000000130651520522431500212640ustar00rootroot00000000000000import ctypes import os import ssl import sys from typing import Final, NamedTuple import certifi from ..i18n import _ from ..log import RuyiConsoleLogger, RuyiLogger from .global_mode import EnvGlobalModeProvider _orig_get_default_verify_paths: Final = ssl.get_default_verify_paths _cached_paths: ssl.DefaultVerifyPaths | None = None def get_system_ssl_default_verify_paths() -> ssl.DefaultVerifyPaths: global _cached_paths if _cached_paths is None: # no way to pass in the logger because of the function signature # so we have to use a new logger gm = EnvGlobalModeProvider(os.environ) _cached_paths = _get_system_ssl_default_verify_paths(RuyiConsoleLogger(gm)) return _cached_paths def _get_system_ssl_default_verify_paths(logger: RuyiLogger) -> ssl.DefaultVerifyPaths: orig_paths = _orig_get_default_verify_paths() if sys.platform != "linux": return orig_paths result: ssl.DefaultVerifyPaths | None = None # imitate the stdlib flow but with overridden data source try: parts = _query_linux_system_ssl_default_cert_paths(logger) if parts is None: logger.W(_("failed to probe system libcrypto")) else: result = to_ssl_paths(parts) except Exception as e: logger.D(f"cannot get system libcrypto default cert paths: {e}") if result is None: logger.D("falling back to probing hard-coded paths") result = probe_fallback_verify_paths() if result != orig_paths: logger.D( "get_default_verify_paths() values differ between bundled and system libssl" ) logger.D(f"bundled: {orig_paths}") logger.D(f" system: {result}") return result def to_ssl_paths(parts: tuple[str, str, str, str]) -> ssl.DefaultVerifyPaths | None: cafile = os.environ.get(parts[0], parts[1]) capath = os.environ.get(parts[2], parts[3]) is_cafile_present = os.path.isfile(cafile) is_capath_present = os.path.isdir(capath) if not is_cafile_present and not is_capath_present: return None # must do "else None" like the stdlib, despite the type annotation being just "str" return ssl.DefaultVerifyPaths( cafile if is_cafile_present else None, # type: ignore[arg-type] capath if is_capath_present else None, # type: ignore[arg-type] *parts, ) def _decode_fsdefault_or_none(val: int | None) -> str: if val is None: return "" s = ctypes.c_char_p(val) if s.value is None: return "" return s.value.decode(sys.getfilesystemencoding()) def _query_linux_system_ssl_default_cert_paths( logger: RuyiLogger, soname: str | None = None, ) -> tuple[str, str, str, str] | None: if soname is None: # check libcrypto instead of libssl, because if the system libssl is # newer than the bundled one, the system libssl will depend on the # bundled libcrypto that may lack newer ELF symbol version(s). The # functions actually reside in libcrypto, after all. for soname in ("libcrypto.so", "libcrypto.so.3", "libcrypto.so.1.1"): try: return _query_linux_system_ssl_default_cert_paths(logger, soname) except OSError as e: logger.D(f"soname {soname} not working: {e}") continue return None # dlopen-ing the bare soname will get us the system library lib = ctypes.CDLL(soname) lib.X509_get_default_cert_file_env.restype = ctypes.c_void_p lib.X509_get_default_cert_file.restype = ctypes.c_void_p lib.X509_get_default_cert_dir_env.restype = ctypes.c_void_p lib.X509_get_default_cert_dir.restype = ctypes.c_void_p result = ( _decode_fsdefault_or_none(lib.X509_get_default_cert_file_env()), _decode_fsdefault_or_none(lib.X509_get_default_cert_file()), _decode_fsdefault_or_none(lib.X509_get_default_cert_dir_env()), _decode_fsdefault_or_none(lib.X509_get_default_cert_dir()), ) logger.D(f"got defaults from system libcrypto {soname}") logger.D(f"X509_get_default_cert_file_env() = {result[0]}") logger.D(f"X509_get_default_cert_file() = {result[1]}") logger.D(f"X509_get_default_cert_dir_env() = {result[2]}") logger.D(f"X509_get_default_cert_dir() = {result[3]}") return result class WellKnownCALocation(NamedTuple): cafile: str capath: str WELL_KNOWN_CA_LOCATIONS: Final[list[WellKnownCALocation]] = [ # Debian-based distros WellKnownCALocation("/usr/lib/ssl/cert.pem", "/usr/lib/ssl/certs"), # RPM-based distros WellKnownCALocation("/etc/pki/tls/cert.pem", "/etc/pki/tls/certs"), # Most others WellKnownCALocation("/etc/ssl/cert.pem", "/etc/ssl/certs"), ] def probe_fallback_verify_paths() -> ssl.DefaultVerifyPaths: for loc in WELL_KNOWN_CA_LOCATIONS: is_file_present = os.path.isfile(loc.cafile) is_dir_present = os.path.isdir(loc.capath) if not is_file_present and not is_dir_present: continue return ssl.DefaultVerifyPaths( loc.cafile if is_file_present else None, # type: ignore[arg-type] loc.capath if is_dir_present else None, # type: ignore[arg-type] "SSL_CERT_FILE", loc.cafile, "SSL_CERT_DIR", loc.capath, ) # fall back to certifi cafile = certifi.where() return ssl.DefaultVerifyPaths( cafile, None, # type: ignore[arg-type] "SSL_CERT_FILE", cafile, "SSL_CERT_DIR", "/etc/ssl/certs", ) ssl.get_default_verify_paths = get_system_ssl_default_verify_paths ruyisdk-ruyi-1f00e2e/ruyi/utils/templating.py000066400000000000000000000017061520522431500214470ustar00rootroot00000000000000import shlex from typing import Any, Final, Callable, Tuple from jinja2 import BaseLoader, Environment, TemplateNotFound from ..resource_bundle import get_template_str class EmbeddedLoader(BaseLoader): def __init__(self) -> None: pass def get_source( self, environment: Environment, template: str, ) -> Tuple[str, str | None, Callable[[], bool] | None]: if payload := get_template_str(template): return payload, None, None raise TemplateNotFound(template) _JINJA_ENV: Final = Environment( loader=EmbeddedLoader(), autoescape=False, # we're not producing HTML auto_reload=False, # we're serving statically embedded assets keep_trailing_newline=True, # to make shells happy ) _JINJA_ENV.filters["sh"] = shlex.quote def render_template_str(template_name: str, data: dict[str, Any]) -> str: tmpl = _JINJA_ENV.get_template(template_name) return tmpl.render(data) ruyisdk-ruyi-1f00e2e/ruyi/utils/toml.py000066400000000000000000000066551520522431500202660ustar00rootroot00000000000000from contextlib import AbstractContextManager from types import TracebackType from typing import Iterable import tomlkit from tomlkit.container import Container from tomlkit.items import Array, Comment, InlineTable, Item, Table, Trivia, Whitespace class NoneValue(Exception): """Used to indicate that a None value is to be dumped in TOML. Because TOML does not support None natively, this means special handling is needed.""" def __str__(self) -> str: return "NoneValue()" def __repr__(self) -> str: return "NoneValue()" def with_indent(item: Item, spaces: int = 2) -> Item: item.indent(spaces) return item def inline_table_with_spaces() -> "InlineTableWithSpaces": return InlineTableWithSpaces(Container(), Trivia(), new=True) class InlineTableWithSpaces(InlineTable, AbstractContextManager[InlineTable]): def __init__( self, value: Container, trivia: Trivia, new: bool = False, ) -> None: super().__init__(value, trivia, new) def __enter__(self) -> InlineTable: self.add(tomlkit.ws(" ")) return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> bool | None: self.add(tomlkit.ws(" ")) return None def _into_item(x: Item | str) -> Item: if isinstance(x, Item): return x return tomlkit.string(x) def str_array( args: Iterable[Item | str], *, multiline: bool = False, indent: int = 2, ) -> Array: items = [_into_item(i).indent(indent) for i in args] return Array(items, Trivia(), multiline=multiline) def sorted_table(x: dict[str, str]) -> Table: y = tomlkit.table() for k in sorted(x.keys()): y.add(k, x[k]) return y def extract_header_comments( doc: Container, ) -> list[str]: comments: list[str] = [] # ignore leading whitespaces is_skipping_leading_ws = True for _key, item in doc.body: if isinstance(item, Whitespace): if is_skipping_leading_ws: continue # this is part of the header comments comments.append(item.as_string()) elif isinstance(item, Comment): is_skipping_leading_ws = False comments.append(item.as_string()) else: # we reached the first non-comment item break return comments def extract_footer_comments( doc: Container, ) -> list[str]: comments: list[str] = [] # ignore trailing whitespaces is_skipping_trailing_ws = True for _key, item in reversed(doc.body): if isinstance(item, Whitespace): if is_skipping_trailing_ws: continue # this is part of the footer comments comments.append(item.as_string()) elif isinstance(item, Comment): is_skipping_trailing_ws = False comments.append(item.as_string()) else: # we reached the first non-comment item break # if the footer comment was preceded by a table, then the comment would be # nested inside the table and invisible in top-level doc.body, so we would # have to check the last item as well if not comments: last_elem = doc.body[-1][1].value if isinstance(last_elem, Container): return extract_footer_comments(last_elem) return list(reversed(comments)) ruyisdk-ruyi-1f00e2e/ruyi/utils/url.py000066400000000000000000000002721520522431500201020ustar00rootroot00000000000000from urllib import parse def urljoin_for_sure(base: str, url: str) -> str: if base.endswith("/"): return parse.urljoin(base, url) return parse.urljoin(base + "/", url) ruyisdk-ruyi-1f00e2e/ruyi/utils/xdg_basedir.py000066400000000000000000000052771520522431500215650ustar00rootroot00000000000000# Re-implementation of necessary XDG Base Directory Specification semantics # without pyxdg, which is under LGPL and not updated for the latest spec # revision (0.6 vs 0.8 released in 2021). import os import pathlib from typing import Iterable, NamedTuple class XDGPathEntry(NamedTuple): path: pathlib.Path is_global: bool def _paths_from_env(env: str, default: str) -> Iterable[pathlib.Path]: v = os.environ.get(env, default) for p in v.split(":"): yield pathlib.Path(p) class XDGBaseDir: def __init__(self, app_name: str) -> None: self.app_name = app_name @property def cache_home(self) -> pathlib.Path: v = os.environ.get("XDG_CACHE_HOME", "") return pathlib.Path(v) if v else pathlib.Path.home() / ".cache" @property def config_home(self) -> pathlib.Path: v = os.environ.get("XDG_CONFIG_HOME", "") return pathlib.Path(v) if v else pathlib.Path.home() / ".config" @property def data_home(self) -> pathlib.Path: v = os.environ.get("XDG_DATA_HOME", "") return pathlib.Path(v) if v else pathlib.Path.home() / ".local" / "share" @property def state_home(self) -> pathlib.Path: v = os.environ.get("XDG_STATE_HOME", "") return pathlib.Path(v) if v else pathlib.Path.home() / ".local" / "state" @property def config_dirs(self) -> Iterable[XDGPathEntry]: # from highest precedence to lowest for p in _paths_from_env("XDG_CONFIG_DIRS", "/etc/xdg"): yield XDGPathEntry(p, True) @property def data_dirs(self) -> Iterable[XDGPathEntry]: # from highest precedence to lowest for p in _paths_from_env("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/"): yield XDGPathEntry(p, True) # derived info @property def app_cache(self) -> pathlib.Path: return self.cache_home / self.app_name @property def app_config(self) -> pathlib.Path: return self.config_home / self.app_name @property def app_data(self) -> pathlib.Path: return self.data_home / self.app_name @property def app_state(self) -> pathlib.Path: return self.state_home / self.app_name @property def app_config_dirs(self) -> Iterable[XDGPathEntry]: # from highest precedence to lowest yield XDGPathEntry(self.app_config, False) for e in self.config_dirs: yield XDGPathEntry(e.path / self.app_name, e.is_global) @property def app_data_dirs(self) -> Iterable[XDGPathEntry]: # from highest precedence to lowest yield XDGPathEntry(self.app_data, False) for e in self.data_dirs: yield XDGPathEntry(e.path / self.app_name, e.is_global) ruyisdk-ruyi-1f00e2e/ruyi/version.py000066400000000000000000000011751520522431500176300ustar00rootroot00000000000000from typing import Final from .i18n import d_ RUYI_SEMVER: Final = "0.49.0" RUYI_USER_AGENT: Final = f"ruyi/{RUYI_SEMVER}" COPYRIGHT_NOTICE: Final = d_( """\ Copyright (C) Institute of Software, Chinese Academy of Sciences (ISCAS). All rights reserved. License: Apache-2.0 \ """ ) MPL_REDIST_NOTICE: Final = d_( """\ This distribution of ruyi contains code licensed under the Mozilla Public License 2.0 (https://mozilla.org/MPL/2.0/). You can get the respective project's sources from the project's official website: * certifi: https://github.com/certifi/python-certifi \ """ ) ruyisdk-ruyi-1f00e2e/scripts/000077500000000000000000000000001520522431500162645ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/scripts/_image_tag_base.sh000066400000000000000000000036611520522431500216740ustar00rootroot00000000000000#!/bin/bash # this file is meant to be sourced _COMMON_DIST_IMAGE_TAG="ghcr.io/ruyisdk/ruyi-python-dist:20260423" # Map of `uname -m` outputs to Debian arch name convention which Ruyi adopts # # This list is incomplete: it currently only contains architectures for which # Docker-based dist builds are supported, and that are officially supported # by the RuyiSDK project. This means if you want to build for an architecture # that is not officially supported, and if `uname -m` output differs from # the Debian name for it, you will have to specify the correct arch name on # the command line. declare -A _UNAME_ARCH_MAP=( ["aarch64"]="arm64" ["i686"]="i386" ["riscv64"]="riscv64" ["x86_64"]="amd64" ) convert_uname_arch_to_ruyi() { echo "${_UNAME_ARCH_MAP["$1"]:-"$1"}" } declare -A _RUYI_DIST_IMAGE_TAGS=( ["amd64"]="$_COMMON_DIST_IMAGE_TAG" ["arm64"]="$_COMMON_DIST_IMAGE_TAG" ["riscv64"]="$_COMMON_DIST_IMAGE_TAG" ) is_docker_dist_build_supported() { local arch="$1" [[ -n "${_RUYI_DIST_IMAGE_TAGS["$arch"]}" ]] } ensure_docker_dist_build_supported() { local arch="$1" local loglevel=error if [[ -n "$RUYI_DIST_FORCE_IMAGE_TAG" ]]; then loglevel=warning fi if ! is_docker_dist_build_supported "$arch"; then echo "$loglevel: unsupported arch $arch for Docker-based dist builds" >&2 echo "info: supported arches:" "${!_RUYI_DIST_IMAGE_TAGS[@]}" >&2 if [[ $loglevel == error ]]; then echo "info: you can set RUYI_DIST_FORCE_IMAGE_TAG (and maybe RUYI_DIST_GOARCH) if you insist" >&2 exit 1 fi fi } image_tag_base() { local arch="$1" if [[ -n "$RUYI_DIST_FORCE_IMAGE_TAG" ]]; then echo "warning: forcing use of dist image $RUYI_DIST_FORCE_IMAGE_TAG" >&2 echo "$RUYI_DIST_FORCE_IMAGE_TAG" return 0 fi ensure_docker_dist_build_supported "$arch" echo "${_RUYI_DIST_IMAGE_TAGS["$arch"]}" } ruyisdk-ruyi-1f00e2e/scripts/build-and-push-dist-image.sh000077500000000000000000000004311520522431500234560ustar00rootroot00000000000000#!/bin/bash set -e MY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$MY_DIR/_image_tag_base.sh" cd "$MY_DIR/dist-image" exec docker buildx build --rm \ --platform "linux/amd64,linux/arm64,linux/riscv64" \ -t "$(image_tag_base amd64)" \ --push \ . ruyisdk-ruyi-1f00e2e/scripts/build-pygit2.py000077500000000000000000000146001520522431500211550ustar00rootroot00000000000000#!/usr/bin/env python3 import os import platform import shutil import subprocess import sys import tomllib from typing import cast PYGIT2_SETUPTOOLS_PATCH = """ --- a/build.sh +++ b/build.sh @@ -82,6 +82,8 @@ if [ "$CIBUILDWHEEL" = "1" ]; then else # Create a virtual environment $PYTHON -m venv $PREFIX + # install setuptools in the venv for python3.12+ + $PREFIX/bin/pip install -U setuptools pycparser cd ci fi """ PYGIT2_OPENSSL_NO_DOCS_PATCH = """ --- a/build.sh +++ b/build.sh @@ -134,7 +134,7 @@ if [ -n "$OPENSSL_VERSION" ]; then # Linux tar xf $FILENAME.tar.gz cd $FILENAME - ./Configure shared --prefix=$PREFIX --libdir=$PREFIX/lib + ./Configure shared no-apps no-docs no-tests --prefix=$PREFIX --libdir=$PREFIX/lib make make install OPENSSL_PREFIX=$(pwd) """ def get_pygit2_src_uri(tag: str) -> tuple[str, str]: filename = f"{tag}.tar.gz" return (filename, f"https://github.com/libgit2/pygit2/archive/refs/tags/{filename}") def log(s: str, fgcolor: int = 32, group: bool = False) -> None: # we cannot import rich because this script is executed before # `poetry install` in the dist build process print(f"\x1b[1;{fgcolor}m{s}\x1b[m", file=sys.stderr, flush=True) if group: begin_group(s) def is_in_gha() -> bool: return "GITHUB_ACTIONS" in os.environ def begin_group(title: str) -> None: if is_in_gha(): print(f"::group::{title}", flush=True) def end_group() -> None: if is_in_gha(): print("::endgroup::", flush=True) def main() -> None: build_root = os.environ["RUYI_DIST_BUILD_DIR"] workdir = os.path.join(build_root, "ruyi-pygit2") ensure_dir(workdir) cache_root = os.environ["RUYI_DIST_CACHE_DIR"] ensure_dir(cache_root) pygit2_ver = get_pygit2_version() log(f"resolved pygit2 version {pygit2_ver}") pygit2_cache_rev = 1 # bump this to force rebuild (e.g. for bumping indirect deps) pygit2_wheel_path = ensure_pygit2_wheel( pygit2_ver, workdir, cache_root, pygit2_cache_rev, ) # this will print a header suitable for our logging purposes begin_group("pip install the pygit2 wheel") subprocess.run(("pip", "install", pygit2_wheel_path), check=True) end_group() log("informing poetry about the wheel", group=True) subprocess.run(("poetry", "add", "--lock", pygit2_wheel_path), check=True) end_group() def ensure_dir(d: str) -> None: try: os.mkdir(d) except FileExistsError: pass def ensure_pygit2_wheel(ver: str, workdir: str, cache_root: str, cache_rev: int) -> str: cache_key = get_cache_key("pygit2", ver, cache_rev) cache_dir = os.path.join(cache_root, cache_key) try: wheel_file = [ i for i in os.listdir(cache_dir) if os.path.splitext(i)[1] == ".whl" ][0] wheel_path = os.path.join(cache_dir, wheel_file) log(f"found cached pygit2 (cache rev {cache_rev}) at {wheel_path}") except (FileNotFoundError, IndexError): log(f"cached pygit2 (cache rev {cache_rev}) not found, building") wheel_path = build_pygit2(ver, workdir) log(f"caching built wheel {wheel_path} to {cache_dir}") ensure_dir(cache_dir) dest_path = os.path.join(cache_dir, os.path.basename(wheel_path)) shutil.copyfile(wheel_path, dest_path) return wheel_path def build_pygit2(pygit2_ver: str, workdir: str) -> str: pygit2_tag = f"v{pygit2_ver}" pygit2_src_filename, pygit2_src_uri = get_pygit2_src_uri(pygit2_tag) pygit2_workdir = os.path.join(workdir, f"pygit2-{pygit2_ver}") # download the source log(f"downloading {pygit2_src_uri}", group=True) subprocess.run( ("wget", "-O", pygit2_src_filename, pygit2_src_uri), cwd=workdir, check=True, ) end_group() # unpack the source log(f"unpacking {pygit2_src_filename}") subprocess.run(("tar", "-xf", pygit2_src_filename), cwd=workdir, check=True) log("patching pygit2 for Python 3.12+ builds") subprocess.run( ("patch", "-Np1"), cwd=pygit2_workdir, input=PYGIT2_SETUPTOOLS_PATCH.encode("utf-8"), check=True, ) log("disabling docs generation during pygit2 openssl build") subprocess.run( ("patch", "-Np1"), cwd=pygit2_workdir, input=PYGIT2_OPENSSL_NO_DOCS_PATCH.encode("utf-8"), check=True, ) # build wheel extra_env = get_pygit2_wheel_build_env(pygit2_workdir) log("extra envvar(s):", fgcolor=36) for k, v in extra_env.items(): log(f" {k}: {v}", fgcolor=36) os.environ[k] = v log("building pygit2 wheel", group=True) subprocess.run( ("sh", "build.sh", "wheel", "bundle"), cwd=pygit2_workdir, check=True, ) end_group() pygit2_distdir = os.path.join(pygit2_workdir, "wheelhouse") pygit2_wheel_name = find_built_wheel_name_in(pygit2_distdir) return os.path.join(pygit2_distdir, pygit2_wheel_name) def get_pygit2_version() -> str: # assume CWD is project root, which is guaranteed to be the case (see # end of file) with open("poetry.lock", "rb") as fp: info = tomllib.load(fp) pygit2 = [pkg for pkg in info["package"] if pkg["name"] == "pygit2"][0] return cast(str, pygit2["version"]) def get_pygit2_wheel_build_env(pygit2_dir: str) -> dict[str, str]: with open(os.path.join(pygit2_dir, "pyproject.toml"), "rb") as fp: pyproject = tomllib.load(fp) r: dict[str, str] = pyproject["tool"]["cibuildwheel"]["environment"] if "LIBGIT2" in r: # this is unnecessary del r["LIBGIT2"] if platform.machine() == "riscv64": # auditwheel 6.1.0+ has manylinux policies for riscv64, but the default # is too low for our environment # bump it up r["AUDITWHEEL_PLAT"] = "manylinux_2_35_riscv64" # This controls the build type for the CMake-built libgit2, and defaults to # Debug otherwise in the pygit2 build.sh. r["BUILD_TYPE"] = "Release" return r def find_built_wheel_name_in(path: str) -> str: return [x for x in os.listdir(path) if os.path.splitext(x)[1] == ".whl"][0] def get_cache_key(pkg_name: str, version: str, cache_rev: int) -> str: return f"{pkg_name}-{version}-cache{cache_rev}" if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) main() ruyisdk-ruyi-1f00e2e/scripts/bump-release.py000077500000000000000000000131021520522431500212170ustar00rootroot00000000000000#!/usr/bin/env python3 import argparse import os import shutil import subprocess import sys import time import semver import tomlkit OPS_CHOICES = ( "alpha", # Make an alpha pre-release "beta", # Make a beta pre-release "release", # Make an official release "date", # Bump the datestamp "major", # Bump the major version "minor", # Bump the minor version "patch", # Bump the patch version "commit", # Actually make the changes and perform `git commit` ) def main(argv: list[str]) -> int: a = argparse.ArgumentParser() a.add_argument( "ops", choices=OPS_CHOICES, metavar="OP", nargs="+", help="Bumping operation to make", ) args = a.parse_args(argv[1:]) ops: list[str] = args.ops # assume CWD is project root, which is guaranteed to be the case (see # end of file) # first read current version with open("pyproject.toml", "rb") as fp: pyproject = tomlkit.load(fp) curr_ver_str: str = pyproject["project"]["version"] # type: ignore[assignment,index,unused-ignore] curr_ver = semver.Version.parse(curr_ver_str) prerelease = curr_ver.prerelease testing_kind: str | None = None datestamp: str | None = None if prerelease: testing_kind, datestamp = prerelease.split(".", 1) new_ver_components = curr_ver.to_dict() assert isinstance(new_ver_components["major"], int) assert isinstance(new_ver_components["minor"], int) assert isinstance(new_ver_components["patch"], int) commit = False for op in ops: match op: case "alpha" | "beta" | "date": datestamp = time.strftime("%Y%m%d") if op != "date": testing_kind = op new_ver_components["prerelease"] = f"{testing_kind}.{datestamp}" case "release": new_ver_components["prerelease"] = None case "major": new_ver_components["major"] += 1 new_ver_components["minor"] = 0 new_ver_components["patch"] = 0 case "minor": new_ver_components["minor"] += 1 new_ver_components["patch"] = 0 case "patch": new_ver_components["patch"] += 1 case "commit": commit = True break case _: raise NotImplementedError(f"unhandled op '{op}'") new_ver = semver.Version(**new_ver_components) # type: ignore[arg-type] new_ver_str = str(new_ver) if new_ver_str == curr_ver_str: # due to our adoption of CalVer in prerelease numbering, we have # to wait for another day if a release of the curr kind is # already made today print("error: version unchanged after bumping", file=sys.stderr) return 1 if not commit: print(f"info: would bump {curr_ver_str} to {new_ver_str}") print('info: changes are not made; re-run with "commit" op') return 0 print(f"info: bumping {curr_ver_str} to {new_ver_str}") git_path = shutil.which("git") if git_path is None: print("error: git not found in PATH", file=sys.stderr) return 1 # TODO: ensure staging area is clean before touching files? _bump_pyproject_toml("pyproject.toml", pyproject, new_ver_str, False) _bump_pyproject_toml("contrib/poetry-1.x/pyproject.toml", None, new_ver_str, True) _bump_ruyi_version_py("ruyi/version.py", new_ver_str) touched_files = [ "pyproject.toml", "contrib/poetry-1.x/pyproject.toml", "ruyi/version.py", ] subprocess.run([git_path, "add"] + touched_files, check=True) if sys.stdin.isatty(): stdin_target = os.readlink(f"/proc/self/fd/{sys.stdin.fileno()}") print(f"info: setting GPG_TTY to {stdin_target} for git commit") os.environ["GPG_TTY"] = stdin_target commit_title = f"build: bump self version to {new_ver_str}" subprocess.run([git_path, "commit", "-s", "-m", commit_title], check=True) if sys.stdin.isatty(): # display the commit we just made for manual inspection, in case # something gets unintentionally included, if the current session is # interactive sys.stdout.flush() os.execv(git_path, ["git", "show"]) return 0 def _bump_pyproject_toml( file: str, obj: tomlkit.TOMLDocument | None, new_ver: str, poetry1: bool, ) -> None: if obj is None: with open(file, "rb") as fp: obj = tomlkit.load(fp) if poetry1: obj["tool"]["poetry"]["version"] = new_ver # type: ignore[index,unused-ignore] else: obj["project"]["version"] = new_ver # type: ignore[index,unused-ignore] with open(file, "wb") as fp: fp.write(tomlkit.dumps(obj).encode("utf-8")) _RUYI_SEMVER_LINE_PREFIX = "RUYI_SEMVER: Final =" def _bump_ruyi_version_py(file: str, new_ver: str) -> None: lines = [] with open(file, "r") as fp: for line in fp: if not line.startswith(_RUYI_SEMVER_LINE_PREFIX): lines.append(line) continue # we want an escaped string that's to be quoted with double # quotes, but repr() by default gives us single quotes new_ver_escaped = repr(new_ver)[1:-1].replace('"', '\\"') lines.append(f'{_RUYI_SEMVER_LINE_PREFIX} "{new_ver_escaped}"\n') with open(file, "wb") as fp: fp.write("".join(lines).encode("utf-8")) if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) sys.exit(main(sys.argv)) ruyisdk-ruyi-1f00e2e/scripts/collect-dep-versions.sh000077500000000000000000000063111520522431500226650ustar00rootroot00000000000000#!/usr/bin/env bash : "${_INSIDE_DOCKER:=false}" ARCH_APT_PKGS=( libc6 python3 python3-pygit2 python3-yaml ) ARCH_DNF_PKGS=( glibc python3 python3-pygit2 python3-pyyaml ) NOARCH_PKGS=( python3-argcomplete python3-arpy python3-babel python3-certifi python3-fastjsonschema python3-jinja2 python3-requests python3-rich python3-semver python3-tomlkit python3-typing-extensions ) main() { local image_tag="$1" if "$_INSIDE_DOCKER"; then # shellcheck disable=SC1091 [[ -f /etc/os-release ]] && source /etc/os-release : "${PRETTY_NAME:=$image_tag}" if command -v apt-get > /dev/null; then probe_apt "$PRETTY_NAME" exit $? fi if command -v dnf > /dev/null; then probe_dnf "$PRETTY_NAME" exit $? fi echo "error: unknown package manager for container image $image_tag" >&2 exit 1 fi if [[ -z $image_tag ]]; then echo "usage: $0 " >&2 exit 2 fi local args=( --rm -e _INSIDE_DOCKER=true -v "${BASH_SOURCE[0]}:/inner.sh" "$image_tag" "/inner.sh" "$image_tag" ) exec docker run "${args[@]}" } _strip_pkgver_suffix() { local v="$1" v="${v%-*}" v="${v%+*}" echo "$v" } _query_apt_pkgver() { local pkg="$1" local result result="$(apt-cache show "$pkg" 2> /dev/null | grep -E '^Version: ' | head -n1 | sed -E 's/^Version: //')" echo "${result:-:x:}" } probe_apt() { local pretty_name="$1" local arch_data_row="| $pretty_name |" local noarch_data_row="| $pretty_name |" local pkgver apt-get update for pkg in "${ARCH_APT_PKGS[@]}"; do printf "querying apt for %s..." "$pkg" pkgver="$(_query_apt_pkgver "$pkg")" echo " $pkgver" arch_data_row="$arch_data_row $(_strip_pkgver_suffix "$pkgver") |" done for pkg in "${NOARCH_PKGS[@]}"; do printf "querying apt for %s..." "$pkg" pkgver="$(_query_apt_pkgver "$pkg")" echo " $pkgver" noarch_data_row="$noarch_data_row $(_strip_pkgver_suffix "$pkgver") |" done echo echo "$arch_data_row" echo echo "$noarch_data_row" echo } _query_dnf_pkgver() { local pkg="$1" local result result="$(dnf info "$pkg" 2> /dev/null | grep -E '^Version +: ' | head -n1 | sed -E 's/^Version +: //')" echo "${result:-:x:}" } probe_dnf() { local pretty_name="$1" local arch_data_row="| $pretty_name |" local noarch_data_row="| $pretty_name |" local pkgver dnf check-update for pkg in "${ARCH_DNF_PKGS[@]}"; do printf "querying dnf for %s..." "$pkg" pkgver="$(_query_dnf_pkgver "$pkg")" echo " $pkgver" arch_data_row="$arch_data_row $(_strip_pkgver_suffix "$pkgver") |" done for pkg in "${NOARCH_PKGS[@]}"; do printf "querying dnf for %s..." "$pkg" pkgver="$(_query_dnf_pkgver "$pkg")" echo " $pkgver" noarch_data_row="$noarch_data_row $(_strip_pkgver_suffix "$pkgver") |" done echo echo "$arch_data_row" echo echo "$noarch_data_row" echo } main "$@" ruyisdk-ruyi-1f00e2e/scripts/dist-gha.sh000077500000000000000000000016361520522431500203310ustar00rootroot00000000000000#!/bin/bash MY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # this has to mirror the setup in the GHA workflow and scripts/dist.sh export CCACHE_DIR=/github/workspace/build-cache/ccache export POETRY_CACHE_DIR=/github/workspace/build-cache/poetry-cache export RUYI_DIST_BUILD_DIR=/github/workspace/build export RUYI_DIST_CACHE_DIR=/github/workspace/build-cache/ruyi-dist-cache export RUYI_DIST_INNER_CONTAINERIZED=x export RUYI_DIST_INNER=x export RUYI_DIST_ADDITIONAL_INDEX_URL=https://mirror.iscas.ac.cn/ruyisdk/dist/python-wheels/simple/ "$MY_DIR"/dist.sh "$@" ret=$? # fix the cache directory's ownership if necessary cache_uid="$(stat -c '%u' /github/workspace/build-cache)" workspace_uid="$(stat -c '%u' /github/workspace)" if [[ $cache_uid -ne $workspace_uid ]]; then echo "fixing ownership of build cache directory" chown -Rv --reference=/github/workspace /github/workspace/build-cache fi exit $ret ruyisdk-ruyi-1f00e2e/scripts/dist-image/000077500000000000000000000000001520522431500203075ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/scripts/dist-image/Dockerfile000066400000000000000000000013321520522431500223000ustar00rootroot00000000000000# syntax=docker/dockerfile:1 ARG TARGETARCH # glibc baseline is 2.36 for x86_64 and arm64, which is Debian 12. # However Debian 12 does not support riscv64, so make it deepin 25 instead, # for the slightly lower glibc baseline (2.38). FROM --platform=linux/amd64 debian:12 AS base-amd64 FROM --platform=linux/arm64 debian:12 AS base-arm64 FROM --platform=linux/riscv64 linuxdeepin/deepin:crimson-riscv64 AS base-riscv64 FROM base-$TARGETARCH ARG TARGETARCH ARG BUILDER_UID=1000 ARG BUILDER_GID=1000 COPY --link ./prepare-distro.sh /x/ RUN /x/prepare-distro.sh COPY --link ./build-python.sh /x/ RUN /x/build-python.sh COPY --link ./prepare-poetry.sh /x/ RUN /x/prepare-poetry.sh USER $BUILDER_UID:$BUILDER_GID WORKDIR /home/b ruyisdk-ruyi-1f00e2e/scripts/dist-image/build-python.sh000077500000000000000000000014111520522431500232610ustar00rootroot00000000000000#!/bin/bash set -e # Nuitka now requires final versions of Python, and while a final version of # python3.12 is in repo, having a newer version would mean a better security # posture, and possibility to have static libpython linkage should needs arise. # # See: https://github.com/Nuitka/Nuitka/commit/54f2a2222abedf92d45b8f397233cfb3bef340c5 # Python 3.14.4 + Nuitka 2.8.10 produces ruyi binaries that segfault during # apply_config (ruyi init time) PYTHON_V=3.13.13 pushd /tmp wget "https://www.python.org/ftp/python/${PYTHON_V}/Python-${PYTHON_V}.tar.xz" mkdir py-src py-build tar -xf "Python-${PYTHON_V}.tar.xz" --strip-components=1 -C py-src pushd py-build ../py-src/configure --prefix=/usr/local make -j make install popd rm -rf py-src py-build Python-*.tar.xz popd ruyisdk-ruyi-1f00e2e/scripts/dist-image/prepare-distro.sh000077500000000000000000000104431520522431500236100ustar00rootroot00000000000000#!/usr/bin/env bash # shellcheck disable=SC1091 . /etc/os-release : "${ID:=}" : "${VERSION_ID:=}" case "$ID" in debian|deepin|ubuntu) _PM=apt ;; fedora|openEuler) _PM=dnf ;; *) echo "error: unrecognized distro ID '$ID'" >&2 exit 2 ;; esac case "$ID:$VERSION_ID" in debian:12) _SYS_LLVM_VER=16 _SYS_PYTHON_VER=3.11 ;; deepin:25) _SYS_LLVM_VER=19 _SYS_PYTHON_VER=3.12 ;; openEuler:*) # no special handling needed for Python devel libs _SYS_LLVM_VER=20 ;; ubuntu:24.04) _SYS_LLVM_VER=20 _SYS_PYTHON_VER=3.12 ;; *) echo "error: unrecognized distro version '$ID:$VERSION_ID'" >&2 exit 2 ;; esac main() { add_builder_user case "$_PM" in apt) prepare_apt_distro ;; dnf) prepare_dnf_distro ;; *) exit 2 ;; esac } add_builder_user() { _getent_result="$(getent passwd "$BUILDER_UID")" if [[ -n $_getent_result ]]; then ln -s "$(cut -f6 -d: <<<"$_getent_result")" /home/b else groupadd -g "$BUILDER_GID" b useradd -d /home/b -m -g "$BUILDER_GID" -u "$BUILDER_UID" -s /bin/bash b fi } prepare_apt_distro() { export DEBIAN_FRONTEND=noninteractive export DEBCONF_NONINTERACTIVE_SEEN=true # HTTPS needs ca-certificates to work # Debian 12+ and Ubuntu 24.04+ both use deb822 files under /etc/apt/sources.list.d/ case "$ID" in debian) sed -E -i 's@http://deb\.debian\.org/@http://mirrors.huaweicloud.com/@g' /etc/apt/sources.list.d/* ;; ubuntu) sed -E -i 's@http://(archive|ports)\.ubuntu\.com/@http://mirrors.huaweicloud.com/@g' /etc/apt/sources.list.d/* ;; deepin) sed -E -i 's@https://community-packages\.deepin\.com@https://mirrors.huaweicloud.com/deepin@' /etc/apt/sources.list ;; esac # Non-interactive configuration of tzdata debconf-set-selections < str: # Similar to ruyipkg/host.py but in the format of f"{os}-{arch}" # (separated with a hyphen instead of slash) os = sys.platform if os == "win32": os = "windows" arch = platform.machine().lower() match arch: case "amd64" | "em64t": arch = "x86_64" case "arm64": arch = "aarch64" case "x86": arch = "i686" return f"{os}-{arch}" def main() -> None: epoch = int(time.time()) target_host = make_canonicalized_host_for_progcache() vers = get_versions() INFO.print(f"Target host : [cyan]{target_host}") INFO.print(f"Project Git commit : [cyan]{vers['git_commit']}") INFO.print(f"Project SemVer : [cyan]{vers['semver']}") INFO.print(f"Version for use by Nuitka: [cyan]{vers['nuitka_ver']}") build_root = os.environ["RUYI_DIST_BUILD_DIR"] exe_name = "ruyi.exe" if sys.platform == "win32" else "ruyi" output_file = os.path.join(build_root, exe_name) cache_root = os.environ["RUYI_DIST_CACHE_DIR"] ensure_dir(cache_root) cache_key = get_cache_key(vers["git_commit"]) cached_output_dir = pathlib.Path(cache_root) / cache_key cached_output_file = cached_output_dir / exe_name try: shutil.copyfile(cached_output_file, output_file) os.chmod(output_file, 0o755) INFO.print(f"cache hit at [cyan]{cached_output_file}[/], skipping build") return except FileNotFoundError: pass ext_outdir = os.path.join(build_root, "_exts") ensure_dir(ext_outdir) add_pythonpath(ext_outdir) # Compile LGPL module(s) into own extensions, if any if LGPL_MODULES: INFO.print("\nBuilding LGPL extension(s)\n") for name in LGPL_MODULES: make_nuitka_ext(name, ext_outdir) # Finally the main program INFO.print("\nBuilding Ruyi executable\n") begin_group("Building Ruyi executable") call_nuitka( "--standalone", "--onefile", "--assume-yes-for-downloads", "--output-filename=ruyi", f"--output-dir={build_root}", "--no-deployment-flag=self-execution", f"--product-version={vers['nuitka_ver']}", f"--onefile-tempdir-spec={{CACHE_DIR}}/ruyi/progcache/{vers['semver']}/{target_host}", "--include-package=charset_normalizer", "--include-package=pygments.formatters", "--include-package=pygments.lexers", "--include-package=pygments.styles", "--include-package=rich._unicode_data", "--include-package=_cffi_backend", # https://github.com/Nuitka/Nuitka/issues/2505 "--windows-icon-from-ico=resources/ruyi.ico", "--show-scons", "./ruyi/__main__.py", ) end_group() begin_group("Cache maintenance") INFO.print(f"\ncaching output to [cyan]{cached_output_file}") ensure_dir(cached_output_dir) shutil.copyfile(output_file, cached_output_file) os.chmod(cached_output_file, 0o755) ts = cached_output_dir / "timestamp" ts.write_text(f"{epoch}\n") delete_cached_files_older_than_days(cache_root, 21, epoch) end_group() def is_in_gha() -> bool: return "GITHUB_ACTIONS" in os.environ def begin_group(title: str) -> None: if is_in_gha(): print(f"::group::{title}", flush=True) def end_group() -> None: if is_in_gha(): print("::endgroup::", flush=True) def ensure_dir(d: str | pathlib.Path) -> None: try: os.mkdir(d) except FileExistsError: pass def get_cache_key(git_commit: str) -> str: return f"ruyi-g{git_commit}" def delete_cached_files_older_than_days(root: str, days: int, epoch: int) -> None: max_ts_delta = days * 86400 epoch_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch)) INFO.print( f"purging cache contents older than [cyan]{days}[/] days from [cyan]now={epoch_str}" ) root_path = pathlib.Path(root) dirs_to_remove: list[tuple[pathlib.Path, int | None]] = [] for f in root_path.iterdir(): if f.name.startswith("pygit2"): INFO.print(f"ignoring library artifact cache [cyan]{f}") continue ts: int | None try: ts = int((f / "timestamp").read_text().strip(), 10) except (FileNotFoundError, ValueError): dirs_to_remove.append((f, None)) continue if ts - epoch >= max_ts_delta: dirs_to_remove.append((f, ts)) for f, ts in dirs_to_remove: if ts is None: INFO.print( f"removing [cyan]{f}[/] ([yellow]timestamp absent or invalid)[/]" ) else: ts_time = time.gmtime(ts) ts_str = time.strftime("%Y-%m-%dT%H:%M:%SZ", ts_time) INFO.print(f"removing [cyan]{f}[/] (created [yellow]{ts_str}[/])") shutil.rmtree(f) def call_nuitka(*args: str) -> None: nuitka_args = [ # https://stackoverflow.com/questions/64761870/python-subprocess-doesnt-inherit-virtual-environment sys.executable, # "python", "-m", "nuitka", ] nuitka_args.extend(args) subprocess.run(nuitka_args, check=True) def add_pythonpath(path: str) -> None: old_path = os.environ.get("PYTHONPATH", "") new_path = path if not old_path else f"{path}{os.pathsep}{old_path}" os.environ["PYTHONPATH"] = new_path def make_nuitka_ext(module_name: str, out_dir: str) -> None: mod = __import__(module_name) mod_dir = os.path.dirname(cast(str, mod.__file__)) INFO.print(f"Building [cyan]{module_name}[/] at [cyan]{mod_dir}[/] into extension") begin_group(f"Building {module_name} into extension") call_nuitka( "--module", mod_dir, f"--include-package={module_name}", f"--output-dir={out_dir}", ) end_group() def get_versions() -> dict[str, str]: # assume CWD is project root, which is guaranteed to be the case (see # end of file) with open("pyproject.toml", "rb") as fp: pyproject = tomllib.load(fp) try: version = pyproject["project"]["version"] except KeyError: # In case the packaging environment has Poetry 1.x metadata switched # in version = pyproject["tool"]["poetry"]["version"] return { "git_commit": get_git_commit(), "semver": version, "nuitka_ver": to_version_for_nuitka(version), } def get_git_commit() -> str: repo = Repository(".") return str(repo.head.target) PRERELEASE_NUITKA_PATCH_VER_MAP = { "alpha": 10000, "beta": 20000, "rc": 30000, } def to_version_for_nuitka(version: str) -> str: """ Figure out the Windows-style version string for Nuitka, from the input semver string. * `X.Y.Z` -> `X.Y.Z.0` * `X.Y.Z-alpha.YYYYMMDD` -> `X.(Y-1).1YYYY.MMDD0` * `X.Y.Z-beta.YYYYMMDD` -> `X.(Y-1).2YYYY.MMDD0` * `X.Y.Z-rc.YYYYMMDD` -> `X.(Y-1).3YYYY.MMDD0` The strange mapping is due to Nuitka (actually Windows?) requiring each part to fit in an u16. """ sv = Version.parse(version) if not sv.prerelease: return f"{version}.0" n_major = sv.major n_minor = sv.minor - 1 prerelease_kind, ymd_str = sv.prerelease.split(".") y, md = divmod(int(ymd_str), 10000) n_patch = PRERELEASE_NUITKA_PATCH_VER_MAP[prerelease_kind] + y n_extra = md * 10 return f"{n_major}.{n_minor}.{n_patch}.{n_extra}" if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) main() ruyisdk-ruyi-1f00e2e/scripts/dist.ps1000066400000000000000000000003431520522431500176540ustar00rootroot00000000000000# Activate the Poetry venv & ((poetry env info --path) + "\Scripts\activate.ps1") $Env:CLCACHE_DIR = "\clcache" $Env:RUYI_DIST_BUILD_DIR = "\build" $Env:RUYI_DIST_CACHE_DIR = "\ruyi-dist-cache" python "scripts\dist-inner.py" ruyisdk-ruyi-1f00e2e/scripts/dist.sh000077500000000000000000000165661520522431500176040ustar00rootroot00000000000000#!/bin/bash set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" cd "$REPO_ROOT" source "${REPO_ROOT}/scripts/_image_tag_base.sh" green() { if [[ $2 == group ]]; then if [[ -n $GITHUB_ACTIONS ]]; then echo "::group::$1" return fi fi printf "\x1b[1;32m%s\x1b[m\n" "$1" } endgroup() { if [[ -n $GITHUB_ACTIONS ]]; then echo "::endgroup::" fi } do_inner() { local arch="$1" if [[ -n $RUYI_DIST_INNER_CONTAINERIZED ]]; then cd /home/b # shellcheck disable=SC1091 . ./venv/bin/activate : "${RUYI_DIST_BUILD_DIR:?RUYI_DIST_BUILD_DIR is expected for containerized builds}" : "${POETRY_CACHE_DIR:?POETRY_CACHE_DIR is expected for containerized builds}" : "${CCACHE_DIR:?CCACHE_DIR is expected for containerized builds}" : "${RUYI_DIST_CACHE_DIR:?RUYI_DIST_CACHE_DIR is expected for containerized builds}" else # we're running in the host environment # give defaults for the directories local tmp_prefix="$REPO_ROOT/tmp" : "${CCACHE_DIR:=$tmp_prefix/ccache.$arch}" : "${POETRY_CACHE_DIR:=$tmp_prefix/poetry-cache.$arch}" : "${RUYI_DIST_BUILD_DIR:=$tmp_prefix/build.$arch}" : "${RUYI_DIST_CACHE_DIR:=$tmp_prefix/ruyi-dist-cache.$arch}" export CCACHE_DIR POETRY_CACHE_DIR RUYI_DIST_BUILD_DIR RUYI_DIST_CACHE_DIR fi mkdir -p "$RUYI_DIST_BUILD_DIR" "$POETRY_CACHE_DIR" "$CCACHE_DIR" "$RUYI_DIST_CACHE_DIR" : "${VIRTUAL_ENV:?you must build in a Python virtual environment}" : "${MAKEFLAGS:=-j$(nproc)}" export MAKEFLAGS if [[ -n $CI ]]; then green "current user info" group id endgroup green "home directory contents" group echo "pwd: $(pwd)" ls -alF . endgroup green "repo contents" group echo "REPO_ROOT: $REPO_ROOT" ls -alF "$REPO_ROOT" endgroup green "ruyi-dist-cache contents" group ls -alF "$RUYI_DIST_CACHE_DIR" endgroup if [[ ! -O $REPO_ROOT ]]; then green "adding the repo to the list of Git safe directories" git config --global --add safe.directory "$REPO_ROOT" fi fi [[ -n $RUYI_DIST_INNER_CONTAINERIZED ]] && cd "$REPO_ROOT" # build dep(s) with extension(s) if no prebuilt artifact is available on PyPI # for now this is / these are: # # - pygit2 case "$arch" in amd64|arm64|ppc64el) ;; # current as of pygit2 1.18.1 *) if [[ -n $RUYI_DIST_ADDITIONAL_INDEX_URL ]]; then poetry source add -p supplemental ruyi-dist "$RUYI_DIST_ADDITIONAL_INDEX_URL" # override pygit2 source manually, so we don't have to parse out # its version specifier in order to re-add after removing cat >> pyproject.toml < /dev/null for patch_file in "$REPO_ROOT"/scripts/patches/nuitka/*.patch; do echo " * $(basename "$patch_file")" patch -Np1 < "$patch_file" done popd > /dev/null endgroup fi exec ./scripts/dist-inner.py } do_docker_build() { local arch="$1" local goarch="${RUYI_DIST_GOARCH:-$arch}" local CCACHE_DIR="$REPO_ROOT/tmp/ccache.${arch}" local POETRY_CACHE_DIR="$REPO_ROOT/tmp/poetry-cache.${arch}" local RUYI_DIST_BUILD_DIR="$REPO_ROOT/tmp/build.${arch}" local RUYI_DIST_CACHE_DIR="$REPO_ROOT/tmp/ruyi-dist-cache.${arch}" mkdir -p "$CCACHE_DIR" "$POETRY_CACHE_DIR" "$RUYI_DIST_BUILD_DIR" "$RUYI_DIST_CACHE_DIR" docker_args=( --rm -i # required to be able to interrupt the build with ^C --platform "linux/${goarch}" -v "$REPO_ROOT":/home/b/ruyi -v "$CCACHE_DIR":/ccache -v "$POETRY_CACHE_DIR":/poetry-cache -v "$RUYI_DIST_BUILD_DIR":/build -v "$RUYI_DIST_CACHE_DIR":/ruyi-dist-cache -e RUYI_DIST_INNER=x -e RUYI_DIST_INNER_CONTAINERIZED=x -e CCACHE_DIR=/ccache -e POETRY_CACHE_DIR=/poetry-cache -e RUYI_DIST_BUILD_DIR=/build -e RUYI_DIST_CACHE_DIR=/ruyi-dist-cache ) if [[ -n $RUYI_DIST_ADDITIONAL_INDEX_URL ]]; then docker_args+=( -e RUYI_DIST_ADDITIONAL_INDEX_URL="${RUYI_DIST_ADDITIONAL_INDEX_URL}" ) fi # only allocate pty if currently running interactively # check if stdout is a tty if [[ -t 1 ]]; then docker_args+=( -t ) fi docker_args+=( "$(image_tag_base "$arch")" /home/b/ruyi/scripts/dist.sh "$arch" ) exec docker run "${docker_args[@]}" } main() { if [[ -n $RUYI_DIST_INNER ]]; then do_inner "$@" fi local host_arch="$1" local build_arch local build_arch_is_officially_supported=false build_arch="$(convert_uname_arch_to_ruyi "$(uname -m)")" if is_docker_dist_build_supported "$build_arch"; then build_arch_is_officially_supported=true fi if [[ -z $host_arch ]]; then host_arch="$build_arch" echo "usage: $0 [arch]" >&2 echo "info: defaulting to build machine arch $build_arch" >&2 fi if is_docker_dist_build_supported "$host_arch"; then do_docker_build "$host_arch" else echo "warning: Docker-based dist builds for architecture $host_arch is not supported" >&2 if [[ -n "$RUYI_DIST_FORCE_IMAGE_TAG" ]]; then # but this is explicitly requested so... do_docker_build "$host_arch" else # Because of the way Nuitka works, cross builds cannot be supported. # # But without knowledge of the Debian name for the user's arch, we # cannot know whether the user is actually doing native builds on # their arch, with $build_arch expected to differ from `uname -m` # output. # # On the other hand, if the build arch is supported, when # $host_arch differs from $build_arch we can indeed be sure that # the build will fail. if [[ $build_arch != "$host_arch" ]]; then if "$build_arch_is_officially_supported"; then echo "error: cross building is not possible with Nuitka" >&2 echo "info: to our knowledge, $host_arch is not the same as $build_arch" >&2 echo "info: please retry with $host_arch hardware / emulation / sysroot instead" >&2 exit 1 fi echo "warning: the requested arch $host_arch differs from the build machine arch $build_arch, but the build is not Docker-based" >&2 echo "warning: cross builds are not supported and will fail" >&2 fi echo "warning: your build may not be reproducible" >&2 do_inner "$@" fi fi } main "$@" ruyisdk-ruyi-1f00e2e/scripts/gen_matrix.py000077500000000000000000000111541520522431500210000ustar00rootroot00000000000000#!/usr/bin/env python3 import os import json import pathlib import pprint import sys from typing import NamedTuple, TypedDict def log(s: str, fgcolor: int = 32) -> None: # we cannot import rich because this script is executed before # `poetry install` in the dist build process print(f"\x1b[1;{fgcolor}m{s}\x1b[m", file=sys.stderr, flush=True) def is_ci_forced_for_all(pr_title: str) -> bool: return "[ci force-all]" in pr_title.lower() class Combo(NamedTuple): os: str arch: str self_hosted: bool run_on_pr: bool RunsOn = str | list[str] class MatrixEntry(TypedDict): arch: str build_output_name: str is_windows: bool job_name: str runs_on: RunsOn skip: bool upload_artifact_name: str needs_qemu: bool # https://github.blog/changelog/2025-01-16-linux-arm64-hosted-runners-now-available-for-free-in-public-repositories-public-preview/ GHA_PUBLIC_UBUNTU_RUNNER_NAMES = { "amd64": "ubuntu-24.04", "arm64": "ubuntu-24.04-arm", "riscv64": "ubuntu-24.04", } GHA_PUBLIC_RUNNER_ARCHES = { "ubuntu-24.04": "amd64", "ubuntu-24.04-arm": "arm64", "windows-latest": "amd64", } def gha_runner_needs_qemu(arch: str) -> bool: return arch != GHA_PUBLIC_RUNNER_ARCHES[GHA_PUBLIC_UBUNTU_RUNNER_NAMES[arch]] def runs_on(c: Combo) -> RunsOn: if c.self_hosted: return ["self-hosted", c.os, c.arch] match c.os: case "linux": return GHA_PUBLIC_UBUNTU_RUNNER_NAMES[c.arch] case "windows": return "windows-latest" case _: raise ValueError(f"unrecognized combo {c} for deriving runs_on property") def upload_artifact_name(c: Combo) -> str: if c.os == "windows": return f"ruyi.windows-{c.arch}.exe" return f"ruyi.{c.arch}" def build_output_name(c: Combo) -> str: return "ruyi.exe" if c.os == "windows" else "ruyi" def to_matrix_entry(c: Combo, should_run: bool) -> MatrixEntry: return { "arch": c.arch, "build_output_name": build_output_name(c), "is_windows": c.os == "windows", "job_name": f"dist build: {c.os.title()} {c.arch}", "runs_on": runs_on(c), "skip": not should_run, "upload_artifact_name": upload_artifact_name(c), "needs_qemu": gha_runner_needs_qemu(c.arch), } class MatrixFilter: def __init__( self, ref: str, event_name: str, pr_title: str, oses: set[str], ) -> None: self.ref = ref self.event_name = event_name self.force_all = is_ci_forced_for_all(pr_title) self.oses = oses def should_include(self, c: Combo) -> bool: return c.os in self.oses def should_run(self, c: Combo) -> bool: if self.event_name == "pull_request": return True if self.force_all else c.run_on_pr return True COMBOS: list[Combo] = [ Combo("linux", "amd64", False, True), Combo("linux", "arm64", False, False), # temporarily switch away from self-hosted runners due to resource migration Combo("linux", "riscv64", False, False), Combo("windows", "amd64", False, False), ] def main() -> None: # https://docs.github.com/en/actions/learn-github-actions/variables gh_ref = os.environ["GITHUB_REF"] gh_event = os.environ["GITHUB_EVENT_NAME"] pr_title = os.environ.get("RUYI_PR_TITLE", "") log(f"GITHUB_REF={gh_ref}") log(f"GITHUB_EVENT_NAME={gh_event}") log(f"RUYI_PR_TITLE='{pr_title}'") mf = MatrixFilter(gh_ref, gh_event, pr_title, set(sys.argv[1:])) result_includes = [ to_matrix_entry(c, mf.should_run(c)) for c in COMBOS if mf.should_include(c) ] # GitHub Actions doesn't like it if the matrix is empty, so we have to keep # at least one entry for the list, but otherwise we're free to drop the # skipped entries. # # This is useful for reducing CI times because self-hosted runner jobs tend # to finish slower even if nothing is to be done, due to the RPC costs. # For example, right now the riscv64 runner takes 1min just for the startup # and teardown overhead. if not all(entry["skip"] for entry in result_includes): # At least one job will remain after filtering. result_includes = [entry for entry in result_includes if not entry["skip"]] matrix = {"include": result_includes} log("resulting matrix:") for entry in result_includes: print(f"::group::Job {entry['job_name']}") pprint.pprint(entry) print("::endgroup::") outfile = pathlib.Path(os.environ["GITHUB_OUTPUT"]) outfile.write_text(f"matrix={json.dumps(matrix)}\n") if __name__ == "__main__": main() ruyisdk-ruyi-1f00e2e/scripts/i18n-helper.py000077500000000000000000000145371520522431500207070ustar00rootroot00000000000000#!/usr/bin/env python3 import argparse import os import pathlib import sys import tomllib from babel.messages.frontend import CommandLineInterface DOMAINS = ( "argparse", "ruyi", ) def main() -> int: parser = argparse.ArgumentParser( description="Helper script for Ruyi i18n tasks.", ) parser.add_argument( "action", choices=["refresh-pot", "init-po", "merge-po", "build-mo"], help="The action to perform.", ) parser.add_argument( "-l", "--locale", type=str, help="Locale code (for init-po action).", ) args = parser.parse_args() action: str = args.action locale: str | None = args.locale match action: case "refresh-pot": return _do_refresh_pot() case "init-po": if locale is None: print("fatal error: --locale must be specified for init-po action") return 1 return _do_init_po(locale) case "merge-po": if locale is None: print("fatal error: --locale must be specified for merge-po action") return 1 return _do_merge_po(locale) case "build-mo": if locale is None: print("fatal error: --locale must be specified for build-mo action") return 1 return _do_build_mo(locale) case _: print(f"fatal error: unknown action '{action}'") return 1 def _query_project_version() -> str: # assume CWD is project root, which is guaranteed to be the case (see # end of file) with open("pyproject.toml", "rb") as fp: poetry2_project = tomllib.load(fp) r = poetry2_project["project"]["version"] if not isinstance(r, str): print("fatal error: project.version not a string") raise SystemExit(1) return r def _invoke_babel(argv: list[str]) -> None: CommandLineInterface().run(argv) # type: ignore[no-untyped-call] # this part of babel lacks type information def _do_refresh_pot() -> int: for domain in DOMAINS: if generator := POT_GENERATORS.get(domain): print(f"Refreshing POT for domain '{domain}'...") generator() else: print(f"fatal error: no POT generator for domain '{domain}'") return 1 return 0 def _do_refresh_ruyi_pot() -> int: project_version = _query_project_version() babel_argv = [ "pybabel", "extract", # general nice-to-have options "--no-wrap", "--sort-by-file", # project metadata "--msgid-bugs-address=https://github.com/ruyisdk/ruyi/issues", "--copyright-holder=Institute of Software, Chinese Academy of Sciences (ISCAS)", "--project=ruyi", f"--version={project_version}", # additionally recognize our deferred i18n marker "-k", "d_", # and our i18n note tag "-c", "i18n NOTE:", # output file "-o", "resources/po/ruyi.pot", ] # add all source files project_srcdir = pathlib.Path("ruyi") babel_argv.extend(str(x) for x in project_srcdir.rglob("*.py")) _invoke_babel(babel_argv) return 0 def _do_refresh_argparse_pot() -> int: import re outpath = "resources/po/argparse.pot" babel_argv = [ "pybabel", "extract", # general nice-to-have options "--no-wrap", "--sort-by-file", # no project metadata for argparse which is Python stdlib # output file "-o", outpath, # add source file # for now this is only one file argparse.__file__, ] _invoke_babel(babel_argv) # pybabel computes location references relative to CWD, so # argparse.__file__ (an absolute path) produces deeply-prefixed # entries like "#: ../../../../../../usr/lib/python3.14/argparse.py:242". # Extract just the filename so the POT gets clean references like # "#: argparse.py:242". _POT_LOCATION_RE = re.compile(r"^(#\:\s*)(?:\S+/)*([^\s/]+:\d+)$", re.MULTILINE) with open(outpath, "r", encoding="utf-8") as fp: content = fp.read() content = _POT_LOCATION_RE.sub(r"\1\2", content) with open(outpath, "w", encoding="utf-8") as fp: fp.write(content) return 0 POT_GENERATORS = { "ruyi": _do_refresh_ruyi_pot, "argparse": _do_refresh_argparse_pot, } def _do_init_po(locale: str) -> int: for domain in DOMAINS: babel_argv = [ "pybabel", "init", # project metadata f"--domain={domain}", "-l", locale, # same formatting as the POT "--no-wrap", # assume the POT file is already there "-i", f"resources/po/{domain}.pot", # destination "-d", "resources/po", ] _invoke_babel(babel_argv) return 0 def _do_merge_po(locale: str) -> int: for domain in DOMAINS: babel_argv = [ "pybabel", "update", # project metadata f"--domain={domain}", "-l", locale, # same formatting as the POT "--no-wrap", # destination "-d", "resources/po", # input POT file "-i", f"resources/po/{domain}.pot", ] _invoke_babel(babel_argv) return 0 def _do_build_mo(locale: str) -> int: destdir = pathlib.Path("resources/bundled/locale") / locale / "LC_MESSAGES" destdir.mkdir(parents=True, exist_ok=True) for domain in DOMAINS: babel_argv = [ "pybabel", "compile", "-f", "--statistics", # project metadata f"--domain={domain}", "-l", locale, # destination directory "-d", "resources/bundled/locale", # input file "-i", f"resources/po/{locale}/LC_MESSAGES/{domain}.po", ] _invoke_babel(babel_argv) # regenerate resource bundle data from ruyi.resource_bundle.__main__ import main as resource_bundle_main resource_bundle_main() return 0 if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) sys.exit(main()) ruyisdk-ruyi-1f00e2e/scripts/install-baseline-deps.sh000077500000000000000000000015661520522431500230120ustar00rootroot00000000000000#!/bin/bash set -e main() { local pkglist=( # Package versions provided by Ubuntu 24.04 LTS. python3-argcomplete # 3.1.4 python3-arpy # 1.1.1 python3-babel # 2.10.3 python3-certifi # 2023.11.17 python3-fastjsonschema # 2.19.0 python3-jinja2 # 3.1.2 python3-pygit2 # 1.14.1 python3-requests # 2.31.0 python3-rich # 13.7.1 python3-semver # 2.10.2 python3-tomlkit # 0.12.4 python3-typing-extensions # 4.10.0 python3-yaml # 6.0.1 # for installing ourselves python3-pip # for running the test suite with purely system deps python3-pytest # 7.4.4 ) export DEBIAN_FRONTEND=noninteractive export DEBCONF_NONINTERACTIVE_SEEN=true sudo apt-get update -qqy sudo apt-get install -y "${pkglist[@]}" } main "$@" ruyisdk-ruyi-1f00e2e/scripts/lint-bundled-resources.sh000077500000000000000000000007431520522431500232200ustar00rootroot00000000000000#!/bin/bash REGEN_SCRIPT="./ruyi/resource_bundle/__main__.py" cd "$(dirname "${BASH_SOURCE[0]}")"/.. || exit if ! "$REGEN_SCRIPT"; then echo "error: syncing of resource bundle failed" >&2 exit 1 fi if ! git diff --exit-code ruyi/resource_bundle > /dev/null; then echo "error: resource bundle modified but not synced to Python package" >&2 echo "info: re-run $REGEN_SCRIPT to do so" >&2 exit 1 fi echo "info: ✅ resource bundle is properly synced" >&2 exit 0 ruyisdk-ruyi-1f00e2e/scripts/lint-cli-startup-flow.py000077500000000000000000000050351520522431500230240ustar00rootroot00000000000000#!/usr/bin/env python3 import sys import time # import these common stdlib modules beforehand, to help reduce clutter # these must be modules that does not significantly affect the ruyi CLI's # startup performance STDLIBS_TO_PRELOAD = [ "_struct", "argparse", "base64", "binascii", "bz2", "datetime", "functools", "gettext", "itertools", "lzma", "pathlib", "platform", "shutil", "struct", "typing", "os", "zlib", ] if sys.version_info >= (3, 14): STDLIBS_TO_PRELOAD.append("annotationlib") CURRENT_ALLOWLIST = { "ruyi", "ruyi.cli", "ruyi.cli.builtin_commands", "ruyi.cli.cmd", "ruyi.cli.completion", "ruyi.cli.config_cli", "ruyi.cli.self_cli", "ruyi.cli.version_cli", "ruyi.device", "ruyi.device.provision_cli", "ruyi.i18n", "ruyi.mux", "ruyi.mux.venv", "ruyi.mux.venv.venv_cli", "ruyi.pluginhost", "ruyi.pluginhost.plugin_cli", "ruyi.resource_bundle", "ruyi.resource_bundle.data", "ruyi.ruyipkg", "ruyi.ruyipkg.admin_cli", "ruyi.ruyipkg.cli_completion", # part of the argparse machinery "ruyi.ruyipkg.entity_cli", "ruyi.ruyipkg.host", # light-weight enough "ruyi.ruyipkg.install_cli", "ruyi.ruyipkg.list_cli", "ruyi.ruyipkg.list_filter", # part of the argparse machinery "ruyi.ruyipkg.news_cli", "ruyi.ruyipkg.profile_cli", "ruyi.ruyipkg.repo_cli", "ruyi.ruyipkg.update_cli", "ruyi.telemetry", "ruyi.telemetry.telemetry_cli", "ruyi.utils", "ruyi.utils.global_mode", # light-weight enough } def main() -> int: for lib in STDLIBS_TO_PRELOAD: __import__(lib) before = set(sys.modules.keys()) a = time.monotonic_ns() from ruyi.cli import builtin_commands b = time.monotonic_ns() print(f"Import of built-in commands took {((b - a) / 1_000_000):.2f} ms.") del builtin_commands after = set(sys.modules.keys()) modules_brought_in = after - before unwanted_modules = modules_brought_in - CURRENT_ALLOWLIST if not unwanted_modules: return 0 print( """\ Some previously unneeded modules are now imported during built-in commands initialization: """ ) for module in sorted(unwanted_modules): print(f" - {module}") print( """ Please assess the impact on CLI startup performance before: - allowing the module(s) by revising this script, or - deferring the import(s) so they do not slow down CLI startup. """ ) return 1 if __name__ == "__main__": sys.exit(main()) ruyisdk-ruyi-1f00e2e/scripts/lint-shell-scripts.sh000077500000000000000000000002161520522431500223620ustar00rootroot00000000000000#!/bin/bash set -e cd "$(dirname "${BASH_SOURCE[0]}")"/.. find resources scripts -name '*.sh' -print0 | xargs -0 shellcheck -P . -P scripts ruyisdk-ruyi-1f00e2e/scripts/lint-version-metadata.py000077500000000000000000000050071520522431500230520ustar00rootroot00000000000000#!/usr/bin/env python3 import ast import os import sys import tomllib def main() -> None: # assume CWD is project root, which is guaranteed to be the case (see # end of file) with open("pyproject.toml", "rb") as fp: poetry2_project = tomllib.load(fp) poetry2_version = poetry2_project["project"]["version"] with open("contrib/poetry-1.x/pyproject.toml", "rb") as fp: poetry1_project = tomllib.load(fp) poetry1_version = poetry1_project["tool"]["poetry"]["version"] if poetry1_version != poetry2_version: print("fatal error: Poetry 1.x metadata inconsistent with primary data source") print(f"info: primary pyproject.toml has project.version = '{poetry2_version}'") print(f"info: Poetry 1.x has tool.poetry.version = '{poetry1_version}'") sys.exit(1) print("info: project version consistent between primary and Poetry 1.x metadata") ret = lint_ruyi_version_str("ruyi/version.py", poetry2_version) if ret: print( "info: hint, if you want to refactor RUYI_SEMVER, you need to make changes to this lint too" ) sys.exit(ret) def lint_ruyi_version_str(filename: str, expected_ver: str) -> int: with open(filename, "rb") as fp: contents = fp.read() module = ast.parse(contents, filename) found_ver: str | None = None for stmt in module.body: if not isinstance(stmt, ast.AnnAssign): continue if not isinstance(stmt.target, ast.Name): continue if stmt.target.id == "RUYI_SEMVER": if not isinstance(stmt.value, ast.Constant): print("fatal error: RUYI_SEMVER not a constant") return 1 if not isinstance(stmt.value.value, str): print("fatal error: RUYI_SEMVER not a string") return 1 found_ver = stmt.value.value if found_ver is None: print("fatal error: RUYI_SEMVER annotation assignment not found") return 1 if found_ver != expected_ver: print( "fatal error: ruyi.version.RUYI_SEMVER inconsistent with primary data source" ) print(f"info: primary pyproject.toml has project.version = '{expected_ver}'") print(f"info: ruyi.version.RUYI_SEMVER = '{found_ver}'") return 1 print("info: ruyi.version.RUYI_SEMVER consistent with primary metadata") return 0 if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) main() ruyisdk-ruyi-1f00e2e/scripts/make-release-tag.py000077500000000000000000000052651520522431500217550ustar00rootroot00000000000000#!/usr/bin/env python3 import os import pathlib import subprocess import sys from typing import NoReturn from pygit2 import Repository from tomlkit_extras import TOMLDocumentDescriptor, load_toml_file try: from semver.version import Version # type: ignore[import-untyped,unused-ignore] except ModuleNotFoundError: from semver import VersionInfo as Version # type: ignore[import-untyped,unused-ignore] def render_tag_message(version: str) -> str: return f"Ruyi {version}" def fatal(msg: str) -> NoReturn: print(f"fatal: {msg}", file=sys.stderr) sys.exit(1) def main() -> None: # assume CWD is project root, which is guaranteed to be the case (see # end of file) pyproject = load_toml_file(pathlib.Path("pyproject.toml")) pyproject_desc = TOMLDocumentDescriptor(pyproject) project_table = pyproject_desc.get_table("project") version_field = project_table.fields["version"] lineno = version_field.line_no version = version_field.value if not isinstance(version, str): fatal(f"expected project.version to be a string, got {type(version)}") # Check if the version is a valid semver version try: Version.parse(version) except ValueError as e: fatal(f"invalid semver {version} in pyproject.toml: {e}") print(f"info: project version is {version}, defined at pyproject.toml:{lineno}") # Check if the tag is already present repo = Repository(".") try: tag_ref = repo.lookup_reference(f"refs/tags/{version}") except KeyError: tag_ref = None if tag_ref is not None: print(f"info: tag {version} already exists") # idempotence: don't fail the workflow with non-zero status code sys.exit(0) # Blame pyproject.toml to find the commit bumping the version blame = repo.blame("pyproject.toml") ver_bump_commit_id = blame.for_line(lineno).final_commit_id print(f"info: the version-bumping commit is {ver_bump_commit_id}") ver_bump_commit = repo.get(ver_bump_commit_id) if ver_bump_commit is None: fatal(f"could not find version-bumping commit {ver_bump_commit_id}") # Create the tag with Git command line to allow for GPG signing argv = ["git", "tag", "-m", render_tag_message(version)] if "RUYI_NO_GPG_SIGN" in os.environ: argv.extend(["-a", "--no-sign"]) else: argv.append("-s") argv.extend([version, str(ver_bump_commit_id)]) print(f"info: invoking git: {' '.join(argv)}") subprocess.run(argv, check=True) print(f"info: tag {version} created successfully") sys.exit(0) if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) main() ruyisdk-ruyi-1f00e2e/scripts/make-reproducible-source-tarball.sh000077500000000000000000000032061520522431500251330ustar00rootroot00000000000000#!/bin/bash set -e REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" TMPDIR='' _cleanup() { [[ -n $TMPDIR ]] && rm -rf "$TMPDIR" } get_repo_commit_time() { TZ=UTC0 git log -1 --format='tformat:%cd' --date='format:%Y-%m-%dT%H:%M:%SZ' } reproducible_tar() { local args=( --sort=name --format=posix --pax-option='exthdr.name=%d/PaxHeaders/%f' --pax-option='delete=atime,delete=ctime' --clamp-mtime --mtime="$SOURCE_EPOCH" --numeric-owner --owner=0 --group=0 "$@" ) LC_ALL=C tar "${args[@]}" } # shellcheck disable=SC2120 reproducible_gzip() { gzip -9 -n "$@" } main() { local version staging_dirname dest_dir cd "$REPO_ROOT" version="$(git describe)" staging_dirname="ruyi-$version" artifact_name="$staging_dirname.tar.gz" dest_dir="${1:=$REPO_ROOT/tmp}" SOURCE_EPOCH="$(get_repo_commit_time)" export SOURCE_EPOCH TMPDIR="$(mktemp -d)" trap _cleanup EXIT git clone --recurse-submodules "$REPO_ROOT" "$TMPDIR/$staging_dirname" pushd "$TMPDIR/$staging_dirname" > /dev/null # remove Git metadata find . -name .git -exec rm -rf '{}' '+' # set all file timestamps to $SOURCE_EPOCH find . -exec touch -md "$SOURCE_EPOCH" '{}' '+' popd > /dev/null pushd "$TMPDIR" > /dev/null reproducible_tar -cf - "./$staging_dirname" | reproducible_gzip > "$dest_dir/$artifact_name" popd > /dev/null echo "info: repo HEAD content is reproducibly packed at $dest_dir/$artifact_name" [[ -n $GITHUB_OUTPUT ]] && echo "artifact_name=$artifact_name" > "$GITHUB_OUTPUT" } main "$@" ruyisdk-ruyi-1f00e2e/scripts/organize-release-artifacts.py000077500000000000000000000051701520522431500240560ustar00rootroot00000000000000#!/usr/bin/env python3 import os import pathlib import shutil import sys import tomllib def main(argv: list[str]) -> int: if len(argv) != 2: print(f"usage: {argv[0]} ", file=sys.stderr) return 1 workdir = pathlib.Path(argv[1]).resolve() project_root = (pathlib.Path(os.path.dirname(__file__)) / "..").resolve() with open(project_root / "pyproject.toml", "rb") as fp: pyproject = tomllib.load(fp) try: version = pyproject["project"]["version"] except KeyError: # In case the packaging environment has Poetry 1.x metadata switched # in version = pyproject["tool"]["poetry"]["version"] # layout of release-artifacts-dir just after the download-artifacts@v4 # action: # # release-artifacts-dir # ├── ruyi-XXXXXXXX.tar.gz # │   └── ruyi-XXXXXXXX.tar.gz # ├── ruyi.amd64 # │   └── ruyi # ├── ruyi.arm64 # │   └── ruyi # ├── ruyi.riscv64 # │   └── ruyi # └── ruyi.windows-amd64.exe # └── ruyi.exe # # we want to organize it into the following layout: # # release-artifacts-dir # ├── ruyi-XXXXXXXX.tar.gz # ├── ruyi-.amd64 # ├── ruyi-.arm64 # └── ruyi-.riscv64 # # i.e. with the non-Linux build removed, with the directory structure # flattened, and with the semver attached. os.chdir(workdir) # for now, hardcode the exact artifacts we want included_arches = ("amd64", "arm64", "riscv64") wanted_names = {f"ruyi.{arch}" for arch in included_arches} names = os.listdir(".") for name in names: if name.endswith(".tar.gz"): src_path = os.path.join(name, name) tmp_path = f"{name}.new" print(f"moving tarball {src_path} outside") os.rename(src_path, tmp_path) os.rmdir(name) os.rename(tmp_path, name) continue if not name.startswith("ruyi"): print(f"ignoring {name}") continue if name not in wanted_names: print(f"removing unwanted {name}") shutil.rmtree(name) continue # assume name is ruyi.{arch} arch = name.rsplit(".", 1)[1] src_name = os.path.join(name, "ruyi") dest_name = f"ruyi-{version}.{arch}" print(f"moving {src_name} to {dest_name}") os.rename(src_name, dest_name) os.chmod(dest_name, 0o755) os.rmdir(name) return 0 if __name__ == "__main__": sys.exit(main(sys.argv)) ruyisdk-ruyi-1f00e2e/scripts/patches/000077500000000000000000000000001520522431500177135ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/scripts/patches/nuitka/000077500000000000000000000000001520522431500212065ustar00rootroot000000000000000001-workaround-libatomic-linkage-for-static-libpython-on.patch000066400000000000000000000021011520522431500347550ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/scripts/patches/nuitkaFrom 3fdc249394b912d347165af9d4d1f9910f06c482 Mon Sep 17 00:00:00 2001 From: WANG Xuerui Date: Tue, 8 Apr 2025 21:26:00 +0800 Subject: [PATCH 1/2] workaround libatomic linkage for static libpython on riscv --- nuitka/build/Backend.scons | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nuitka/build/Backend.scons b/nuitka/build/Backend.scons index f2f0016ba..ffc71984f 100644 --- a/nuitka/build/Backend.scons +++ b/nuitka/build/Backend.scons @@ -14,6 +14,7 @@ build process for itself, although it can be compiled using the same method. import sys import os +import platform import types sys.modules["nuitka"] = types.ModuleType("nuitka") @@ -760,6 +761,9 @@ elif env.exe_mode or env.dll_mode: if python_prefix_external != "/usr" and "linux" in sys.platform: env.Append(LIBS=["dl", "pthread", "util", "rt", "m"]) + if platform.machine().startswith("riscv"): + env.Append(LIBS=["atomic"]) + if env.gcc_mode: if clang_mode: env.Append(LINKFLAGS=["-Wl,--export-dynamic"]) -- 2.48.1 ruyisdk-ruyi-1f00e2e/scripts/set-gha-env.py000077500000000000000000000033331520522431500207610ustar00rootroot00000000000000#!/usr/bin/env python3 import os import tomllib from typing import cast def main() -> None: v = get_semver() set_release_mirror_url_for_gha(v) def get_semver() -> str: # assume CWD is project root, which is guaranteed to be the case (see # end of file) with open("pyproject.toml", "rb") as fp: pyproject = tomllib.load(fp) return cast(str, pyproject["project"]["version"]) def set_release_mirror_url_for_gha(version: str) -> None: release_url_base = "https://mirror.iscas.ac.cn/ruyisdk/ruyi/releases/" testing_url_base = "https://mirror.iscas.ac.cn/ruyisdk/ruyi/testing/" # Do not depend on external libraries so this can work in plain GHA # environment without any venv setup. See the SemVer spec -- as long as # we don't have build tags containing "-" we should be fine, which is # exactly the case. # # sv = Version.parse(version) # is_prerelease = sv.prerelease is_prerelease = "-" in version url_base = testing_url_base if is_prerelease else release_url_base url = f"{url_base}{version}/" set_gha_output("release_mirror_url", url) def set_gha_output(k: str, v: str) -> None: if "\n" in v: raise ValueError("this helper is only for small one-line outputs") # only do this when the GitHub Actions output file is available # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter outfile = os.environ.get("GITHUB_OUTPUT", "") if not outfile: return with open(outfile, "a", encoding="utf-8") as fp: fp.write(f"{k}={v}\n") if __name__ == "__main__": # cd to project root os.chdir(os.path.join(os.path.dirname(__file__), "..")) main() ruyisdk-ruyi-1f00e2e/stubs/000077500000000000000000000000001520522431500157355ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/stubs/arpy.pyi000066400000000000000000000046011520522431500174340ustar00rootroot00000000000000import io import types from typing import BinaryIO HEADER_BSD: int HEADER_GNU: int HEADER_GNU_TABLE: int HEADER_GNU_SYMBOLS: int HEADER_NORMAL: int HEADER_TYPES: dict[int, str] GLOBAL_HEADER_LEN: int HEADER_LEN: int class ArchiveFormatError(Exception): ... class ArchiveAccessError(IOError): ... class ArchiveFileHeader: type: int size: int timestamp: int uid: int | None gid: int | None mode: int offset: int name: bytes file_offset: int | None proxy_name: bytes def __init__(self, header: bytes, offset: int) -> None: ... class ArchiveFileData(io.IOBase): header: ArchiveFileHeader arobj: Archive last_offset: int def __init__(self, ar_obj: Archive, header: ArchiveFileHeader) -> None: ... def read(self, size: int | None = None) -> bytes: ... def tell(self) -> int: ... def seek(self, offset: int, whence: int = 0) -> int: ... def seekable(self) -> bool: ... def __enter__(self) -> ArchiveFileData: ... def __exit__( self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: types.TracebackType | None, ) -> None: ... class ArchiveFileDataThin(ArchiveFileData): file_path: str def __init__(self, ar_obj: Archive, header: ArchiveFileHeader) -> None: ... def read(self, size: int | None = None) -> bytes: ... class Archive: headers: list[ArchiveFileHeader] file: BinaryIO position: int reached_eof: bool file_data_class: type[ArchiveFileData] | type[ArchiveFileDataThin] next_header_offset: int gnu_table: dict[int, bytes] archived_files: dict[bytes, ArchiveFileData] def __init__( self, filename: str | None = None, fileobj: BinaryIO | None = None ) -> None: ... def read_next_header(self) -> ArchiveFileHeader | None: ... def __next__(self) -> ArchiveFileData: ... next = __next__ def __iter__(self) -> Archive: ... def read_all_headers(self) -> None: ... def close(self) -> None: ... def namelist(self) -> list[bytes]: ... def infolist(self) -> list[ArchiveFileHeader]: ... def open(self, name: bytes | ArchiveFileHeader) -> ArchiveFileData: ... def __enter__(self) -> Archive: ... def __exit__( self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: types.TracebackType | None, ) -> None: ... ruyisdk-ruyi-1f00e2e/stubs/fastjsonschema/000077500000000000000000000000001520522431500207455ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/stubs/fastjsonschema/__init__.pyi000066400000000000000000000024541520522431500232340ustar00rootroot00000000000000from typing import Callable, Mapping from .exceptions import ( JsonSchemaDefinitionException as JsonSchemaDefinitionException, JsonSchemaException as JsonSchemaException, JsonSchemaValueException as JsonSchemaValueException, ) from .version import VERSION as VERSION __all__ = [ "VERSION", "JsonSchemaException", "JsonSchemaValueException", "JsonSchemaDefinitionException", "validate", "compile", "compile_to_code", ] def validate( definition: object, data: object, handlers: Mapping[str, Callable[[str], object]] = {}, formats: Mapping[str, str | Callable[[object], bool]] = {}, use_default: bool = True, use_formats: bool = True, detailed_exceptions: bool = True, ): ... def compile( definition: object, handlers: Mapping[str, Callable[[str], object]] = {}, formats: Mapping[str, str | Callable[[object], bool]] = {}, use_default: bool = True, use_formats: bool = True, detailed_exceptions: bool = True, ) -> Callable[[object], object | None]: ... def compile_to_code( definition: object, handlers: Mapping[str, Callable[[str], object]] = {}, formats: Mapping[str, str | Callable[[object], bool]] = {}, use_default: bool = True, use_formats: bool = True, detailed_exceptions: bool = True, ) -> str: ... ruyisdk-ruyi-1f00e2e/stubs/fastjsonschema/exceptions.pyi000066400000000000000000000012001520522431500236420ustar00rootroot00000000000000import re SPLIT_RE: re.Pattern[str] class JsonSchemaException(ValueError): ... class JsonSchemaValueException(JsonSchemaException): message: str value: object | None name: str | None definition: object | None rule: str | None def __init__( self, message: str, value: object | None = None, name: str | None = None, definition: object | None = None, rule: str | None = None, ) -> None: ... @property def path(self) -> list[str]: ... @property def rule_definition(self) -> object: ... class JsonSchemaDefinitionException(JsonSchemaException): ... ruyisdk-ruyi-1f00e2e/stubs/fastjsonschema/version.pyi000066400000000000000000000000151520522431500231510ustar00rootroot00000000000000VERSION: str ruyisdk-ruyi-1f00e2e/tests/000077500000000000000000000000001520522431500157375ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/__init__.py000066400000000000000000000000001520522431500200360ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/config/000077500000000000000000000000001520522431500172045ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/config/__init__.py000066400000000000000000000000001520522431500213030ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/config/test_editor.py000066400000000000000000000050731520522431500221100ustar00rootroot00000000000000import pathlib import tomllib import pytest from ruyi.config.editor import ConfigEditor from ruyi.config.errors import ( InvalidConfigKeyError, InvalidConfigSectionError, InvalidConfigValueTypeError, MalformedConfigFileError, ProtectedGlobalConfigError, ) @pytest.fixture def temp_config_file(tmp_path: pathlib.Path) -> pathlib.Path: return tmp_path / "config.toml" def test_enter_exit(temp_config_file: pathlib.Path) -> None: editor = ConfigEditor(temp_config_file) with editor as e: assert e is editor e.set_value("telemetry.mode", "off") # no stage() so no file writing assert not temp_config_file.exists() def test_set_value(temp_config_file: pathlib.Path) -> None: with ConfigEditor(temp_config_file) as e: with pytest.raises(InvalidConfigKeyError): e.set_value("invalid_key", "value") with pytest.raises(InvalidConfigValueTypeError): e.set_value("telemetry.mode", True) with pytest.raises(InvalidConfigValueTypeError): e.set_value("telemetry.mode", 1) with pytest.raises(ProtectedGlobalConfigError): e.set_value("installation.externally_managed", True) e.set_value("telemetry.mode", "off") e.stage() with open(temp_config_file, "rb") as fp: content = tomllib.load(fp) assert "installation" not in content assert content["telemetry"]["mode"] == "off" def test_unset_value_remove_section(temp_config_file: pathlib.Path) -> None: with ConfigEditor(temp_config_file) as e: e.set_value("telemetry.mode", "off") e.set_value("repo.remote", "http://test.example.com") e.stage() with open(temp_config_file, "rb") as fp: content = tomllib.load(fp) assert content["repo"]["remote"] == "http://test.example.com" assert content["telemetry"]["mode"] == "off" with ConfigEditor(temp_config_file) as e: e.unset_value("telemetry.mode") e.remove_section("repo") e.stage() with pytest.raises(InvalidConfigSectionError): e.remove_section("foo") with open(temp_config_file, "rb") as fp: content = tomllib.load(fp) assert "repo" not in content assert "telemetry" in content assert "mode" not in content["telemetry"] def test_malformed_config_file_error(temp_config_file: pathlib.Path) -> None: with open(temp_config_file, "wb") as fp: fp.write(b"repo = 1\n") with pytest.raises(MalformedConfigFileError): with ConfigEditor(temp_config_file) as e: e.set_value("repo.branch", "foo") ruyisdk-ruyi-1f00e2e/tests/config/test_repos_config.py000066400000000000000000000215131520522431500232740ustar00rootroot00000000000000"""Tests for [[repos]] config parsing and validation.""" from typing import TYPE_CHECKING from ruyi.config import GlobalConfig if TYPE_CHECKING: from tests.fixtures import MockGlobalModeProvider from ruyi.log import RuyiLogger class TestReposConfigParsing: def test_no_repos_section( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) # Only the default entry should be present. assert len(gc.repo_entries) == 1 assert gc.repo_entries[0].id == "ruyisdk" def test_single_extra_repo( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "my-vendor", "remote": "https://example.invalid/repo.git", "priority": 50, } ] }, is_global_scope=False, ) # Clear cached property to pick up the new entries. if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] assert len(gc.repo_entries) == 2 ids = [e.id for e in gc.repo_entries] assert "ruyisdk" in ids assert "my-vendor" in ids def test_repos_sorted_by_priority( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "high-prio", "remote": "https://example.invalid/high.git", "priority": 100, }, { "id": "low-prio", "remote": "https://example.invalid/low.git", "priority": -10, }, ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] entries = gc.repo_entries assert len(entries) == 3 # Should be sorted by priority ascending. priorities = [e.priority for e in entries] assert priorities == sorted(priorities) def test_rejects_invalid_id( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "INVALID-CAPS", "remote": "https://example.invalid/repo.git", }, { "id": "", "remote": "https://example.invalid/repo.git", }, { "id": "-starts-with-dash", "remote": "https://example.invalid/repo.git", }, ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] # None of the invalid entries should be added. assert len(gc.repo_entries) == 1 assert gc.repo_entries[0].id == "ruyisdk" def test_rejects_reserved_id( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "ruyisdk", "remote": "https://example.invalid/repo.git", "priority": 999, } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] # Should still have only the default entry. assert len(gc.repo_entries) == 1 assert gc.repo_entries[0].id == "ruyisdk" assert gc.repo_entries[0].priority == 0 # unmodified def test_rejects_duplicate_ids( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "vendor-a", "remote": "https://example.invalid/a.git", "priority": 10, }, { "id": "vendor-a", "remote": "https://example.invalid/a-dup.git", "priority": 20, }, ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] vendor_entries = [e for e in gc.repo_entries if e.id == "vendor-a"] assert len(vendor_entries) == 1 assert vendor_entries[0].priority == 10 # first one wins def test_rejects_no_remote_no_local( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "broken", } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] assert len(gc.repo_entries) == 1 assert gc.repo_entries[0].id == "ruyisdk" def test_rejects_relative_local_path( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "bad-path", "local": "relative/path", } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] assert len(gc.repo_entries) == 1 def test_local_only_repo( self, tmp_path: "object", mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "local-only", "local": "/tmp/some/repo", "priority": 5, } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] entries = gc.repo_entries local_entry = [e for e in entries if e.id == "local-only"] assert len(local_entry) == 1 assert local_entry[0].local_path == "/tmp/some/repo" assert local_entry[0].remote == "" assert local_entry[0].active is True def test_defaults_for_optional_fields( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "minimal", "remote": "https://example.invalid/repo.git", } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] entry = [e for e in gc.repo_entries if e.id == "minimal"][0] assert entry.name == "minimal" # defaults to id assert entry.branch == "main" assert entry.priority == 0 assert entry.active is True assert entry.local_path is None def test_inactive_repo( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: gc = GlobalConfig(mock_gm, ruyi_logger) gc._apply_config( { "repos": [ { "id": "disabled", "remote": "https://example.invalid/repo.git", "active": False, } ] }, is_global_scope=False, ) if "repo_entries" in gc.__dict__: del gc.__dict__["repo_entries"] entry = [e for e in gc.repo_entries if e.id == "disabled"][0] assert entry.active is False ruyisdk-ruyi-1f00e2e/tests/config/test_schema.py000066400000000000000000000052171520522431500220620ustar00rootroot00000000000000import datetime import pytest from ruyi.config.errors import InvalidConfigValueError from ruyi.config.schema import decode_value, encode_value, _decode_single_type_value from ruyi.utils.toml import NoneValue def test_decode_value_bool() -> None: assert decode_value("installation.externally_managed", "true") is True assert _decode_single_type_value(None, "true", bool) is True assert _decode_single_type_value(None, "false", bool) is False assert _decode_single_type_value(None, "yes", bool) is True assert _decode_single_type_value(None, "no", bool) is False assert _decode_single_type_value(None, "1", bool) is True assert _decode_single_type_value(None, "0", bool) is False with pytest.raises(InvalidConfigValueError): _decode_single_type_value(None, "invalid", bool) with pytest.raises(InvalidConfigValueError): _decode_single_type_value(None, "x", bool) with pytest.raises(InvalidConfigValueError): _decode_single_type_value(None, "True", bool) def test_decode_value_str() -> None: assert decode_value("repo.branch", "main") == "main" assert _decode_single_type_value(None, "main", str) == "main" def test_decode_value_datetime() -> None: tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) assert ( decode_value("telemetry.upload_consent", "2024-12-01T12:00:00Z") == tz_aware_dt ) assert ( _decode_single_type_value(None, "2024-12-01T12:00:00Z", datetime.datetime) == tz_aware_dt ) assert ( _decode_single_type_value(None, "2024-12-01T12:00:00+00:00", datetime.datetime) == tz_aware_dt ) # naive datetimes are decoded using the implicit local timezone _decode_single_type_value(None, "2024-12-01T12:00:00", datetime.datetime) def test_encode_value_none() -> None: with pytest.raises(NoneValue): encode_value(None) def test_encode_value_bool() -> None: assert encode_value(True) == "true" assert encode_value(False) == "false" def test_encode_value_int() -> None: assert encode_value(123) == "123" def test_encode_value_str() -> None: assert encode_value("") == "" assert encode_value("main") == "main" def test_encode_value_datetime() -> None: tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) assert encode_value(tz_aware_dt) == "2024-12-01T12:00:00Z" # specifically check that naive datetimes are rejected tz_naive_dt = datetime.datetime(2024, 12, 1, 12, 0, 0) with pytest.raises( ValueError, match="only timezone-aware datetimes are supported for safety" ): encode_value(tz_naive_dt) ruyisdk-ruyi-1f00e2e/tests/conftest.py000066400000000000000000000000421520522431500201320ustar00rootroot00000000000000pytest_plugins = "tests.fixtures" ruyisdk-ruyi-1f00e2e/tests/fixtures/000077500000000000000000000000001520522431500176105ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/__init__.py000066400000000000000000000233351520522431500217270ustar00rootroot00000000000000from contextlib import contextmanager, redirect_stderr, redirect_stdout from importlib import resources from dataclasses import dataclass import io import os import pathlib import sys from typing import Generator, cast from pygit2 import Repository import pytest from ruyi.cli.completion import ArgumentParser from ruyi.cli.main import main as ruyi_main from ruyi.config import GlobalConfig from ruyi.log import RuyiConsoleLogger, RuyiLogger from ruyi.ruyipkg.repo import MetadataRepo from ruyi.utils.global_mode import EnvGlobalModeProvider, GlobalModeProvider class RuyiFileFixtureFactory: def __init__(self, module: resources.Package | None = None) -> None: self._fixtures_dir: pathlib.Path | None = None if sys.version_info < (3, 12): assert module is not None # Figure out the fixtures path in a compatible way if module is None: # Python 3.12+ fallback - get the directory of this file self._fixtures_dir = pathlib.Path(__file__).parent elif isinstance(module, str): # Import the module and get its path import importlib mod = importlib.import_module(module) if mod.__file__ is not None: self._fixtures_dir = pathlib.Path(mod.__file__).parent else: self._fixtures_dir = pathlib.Path(__file__).parent else: self.module = module @contextmanager def path(self, *frags: str) -> Generator[pathlib.Path, None, None]: if self._fixtures_dir is not None: # directly derive the file path for better compatibility result_path = self._fixtures_dir for frag in frags: result_path = result_path / frag yield result_path return # fallback to importlib.resources try: path = resources.files(self.module) for frag in frags: path = path.joinpath(frag) with resources.as_file(path) as p: yield p return except (TypeError, FileNotFoundError): pass # final fallback - use the directory of this file result_path = pathlib.Path(__file__).parent for frag in frags: result_path = result_path / frag yield result_path @contextmanager def plugin_suite(self, suite_name: str) -> Generator[pathlib.Path, None, None]: if self._fixtures_dir is not None: # directly derive the file path for better compatibility result_path = self._fixtures_dir / "plugins_suites" / suite_name yield result_path return # fallback to importlib.resources try: path = resources.files(self.module) path = path.joinpath("plugins_suites").joinpath(suite_name) with resources.as_file(path) as p: yield p return except (TypeError, FileNotFoundError): pass # final fallback - use the directory of this file result_path = pathlib.Path(__file__).parent / "plugins_suites" / suite_name yield result_path class MockGlobalModeProvider(GlobalModeProvider): def __init__( self, is_debug: bool = False, is_experimental: bool = False, is_porcelain: bool = False, is_telemetry_optout: bool = False, is_cli_autocomplete: bool = False, venv_root: str | None = None, ) -> None: self._is_debug = is_debug self._is_experimental = is_experimental self._is_porcelain = is_porcelain self._is_telemetry_optout = is_telemetry_optout self._is_cli_autocomplete = is_cli_autocomplete self._venv_root = venv_root @property def argv0(self) -> str: return "ruyi" @property def main_file(self) -> str: return "ruyi/__main__.py" @property def self_exe(self) -> str: return "ruyi" @property def is_debug(self) -> bool: return self._is_debug @property def is_experimental(self) -> bool: return self._is_experimental @property def is_packaged(self) -> bool: return False @property def is_porcelain(self) -> bool: return self._is_porcelain @is_porcelain.setter def is_porcelain(self, v: bool) -> None: self._is_porcelain = v @property def is_telemetry_optout(self) -> bool: return self._is_telemetry_optout @property def is_cli_autocomplete(self) -> bool: return self._is_cli_autocomplete @property def venv_root(self) -> str | None: return self._venv_root @pytest.fixture def ruyi_file() -> RuyiFileFixtureFactory: return RuyiFileFixtureFactory(None if sys.version_info >= (3, 12) else __name__) @pytest.fixture def mock_gm() -> MockGlobalModeProvider: return MockGlobalModeProvider() @pytest.fixture def ruyi_logger(mock_gm: GlobalModeProvider) -> RuyiLogger: """Fixture for creating a RuyiLogger instance.""" return RuyiConsoleLogger(mock_gm) @dataclass class CLIRunResult: exit_code: int stdout: str stderr: str @dataclass class CLICommandContext: argv: list[str] gm: EnvGlobalModeProvider gc: GlobalConfig stdout: io.StringIO stderr: io.StringIO @property def fatal_messages(self) -> list[str]: prefix = "fatal error: " return [ line[len(prefix) :] for line in self.stderr.getvalue().splitlines() if line.startswith(prefix) ] class MockRepository: def __init__(self, root: pathlib.Path) -> None: self.workdir = root self.path = root class IntegrationTestHarness: def __init__( self, env: dict[str, str], repo_root: pathlib.Path, repo_url: str, repo_branch: str, ) -> None: self._env = env self.repo_root = repo_root self.repo_url = repo_url self.repo_branch = repo_branch def __call__(self, *args: str) -> CLIRunResult: return self.run(*args) def make_parser(self) -> ArgumentParser: return ArgumentParser() def make_command_context(self, *args: str) -> CLICommandContext: argv = ["ruyi", *args] stdout_io = io.StringIO() stderr_io = io.StringIO() gm = EnvGlobalModeProvider(self._env, argv) gm.record_self_exe(argv[0], __file__, argv[0]) logger = RuyiConsoleLogger(gm, stdout=stdout_io, stderr=stderr_io) gc = GlobalConfig.load_from_config(gm, logger) gc.override_repo_dir = str(self.repo_root) gc.override_repo_url = self.repo_url gc.override_repo_branch = self.repo_branch return CLICommandContext(argv, gm, gc, stdout_io, stderr_io) def run(self, *args: str) -> CLIRunResult: ctx = self.make_command_context(*args) with redirect_stdout(ctx.stdout), redirect_stderr(ctx.stderr): exit_code = ruyi_main(ctx.gm, ctx.gc, ctx.argv) return CLIRunResult( exit_code, ctx.stdout.getvalue(), ctx.stderr.getvalue(), ) def add_package( self, category: str, name: str, version: str, manifest_toml: str, ) -> pathlib.Path: pkg_dir = self.repo_root / "packages" / category / name pkg_dir.mkdir(parents=True, exist_ok=True) manifest_path = pkg_dir / f"{version}.toml" manifest_path.write_text(manifest_toml, encoding="utf-8") return manifest_path def _populate_default_packages_index(repo_root: pathlib.Path) -> None: repo_root.mkdir(parents=True, exist_ok=True) config_text = """\ ruyi-repo = "v1" [[mirrors]] id = "ruyi-dist" urls = ["https://example.invalid/dist/"] """ (repo_root / "config.toml").write_text(config_text + "\n", encoding="utf-8") sha_stub = "0" * 64 manifest_text = f"""\ format = "v1" [metadata] desc = "Sample integration package" vendor = {{ name = "Ruyi Integration Tests", eula = "" }} [[distfiles]] name = "sample-src.tar.zst" size = 0 [distfiles.checksums] sha256 = "{sha_stub}" [source] distfiles = ["sample-src.tar.zst"] """ manifest_dir = repo_root / "packages" / "dev-tools" / "sample-cli" manifest_dir.mkdir(parents=True, exist_ok=True) (manifest_dir / "1.0.0.toml").write_text(manifest_text, encoding="utf-8") @pytest.fixture def ruyi_cli_runner( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> IntegrationTestHarness: base_dir = tmp_path / "integration-env" home_dir = base_dir / "home" cache_dir = base_dir / "cache" config_dir = base_dir / "config" data_dir = base_dir / "data" state_dir = base_dir / "state" for p in (home_dir, cache_dir, config_dir, data_dir, state_dir): p.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("HOME", str(home_dir)) monkeypatch.setenv("XDG_CACHE_HOME", str(cache_dir)) monkeypatch.setenv("XDG_CONFIG_HOME", str(config_dir)) monkeypatch.setenv("XDG_DATA_HOME", str(data_dir)) monkeypatch.setenv("XDG_STATE_HOME", str(state_dir)) monkeypatch.setenv("RUYI_TELEMETRY_OPTOUT", "1") repo_root = cache_dir / "ruyi" / "packages-index" _populate_default_packages_index(repo_root) def _ensure_git_repo_stub(self: MetadataRepo) -> Repository: if self.repo is None: repo_path = pathlib.Path(self.root) repo_path.mkdir(parents=True, exist_ok=True) self.repo = cast(Repository, MockRepository(repo_path)) return self.repo monkeypatch.setattr(MetadataRepo, "ensure_git_repo", _ensure_git_repo_stub) env = dict(os.environ) return IntegrationTestHarness( env=env, repo_root=repo_root, repo_url="https://example.invalid/packages-index.git", repo_branch="main", ) ruyisdk-ruyi-1f00e2e/tests/fixtures/cpp-for-host_14-20240120-6_riscv64.deb000066400000000000000000000021541520522431500255260ustar00rootroot00000000000000! debian-binary 1706707052 0 0 100644 4 ` 2.0 control.tar.xz 1706707052 0 0 100644 684 ` 7zXZִFP!̏'a] }J>y&_^Q FHW G2<+O37~ McJɼ'ưP8hܿΕ5uf9?VѸ49-i3 %kVN]0?.)I[t~!u'6y?12TM4MxAdKP1c0F}oJ2Eu 7:1sAhZh~W4VwbI'TWeE,>$A"eFV|o;PIb ^ WW&|q@0v6RsRge0iH;tƁoC]!CҗCTlp rԊʒ˫A|o *2Apk\}C/䌍r7ãԫjpK6kl5_+2Xr[1bM SL=FV#.ėkh/aEX^}@ߘy7$IwpOֲԦϒ@ Aq0[s[QX@,7Q?҈Nfj¹ˌk(N- =tBDy<iK@ PMF-ıgYZdata.tar.xz 1706707052 0 0 100644 256 ` 7zXZִFP!G'] }J>y&_^Q FHW G2<+Q~ú  5iKezH{DClҝ}V1ة^ޓg7{ҕyUD/&)ۆe8l:Z+iCS׈LN^(RpaOwLP,gYZruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/000077500000000000000000000000001520522431500226655ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/000077500000000000000000000000001520522431500246605ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/has_feature/000077500000000000000000000000001520522431500271465ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/has_feature/mod.star000066400000000000000000000001501520522431500306140ustar00rootroot00000000000000RUYI = ruyi_plugin_rev(1) def check_nonexistent_feature(): return RUYI.has_feature("NoNeXiStEnT") ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/i18n-v1/000077500000000000000000000000001520522431500257635ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/i18n-v1/mod.star000066400000000000000000000005361520522431500274410ustar00rootroot00000000000000RUYI = ruyi_plugin_rev(1) def test_feature(): return RUYI.has_feature("i18n-v1") def test_get_locale(): return RUYI.i18n.locale def test_messages(): return { "hello-default": RUYI.i18n.msg("hello"), "hello-en": RUYI.i18n.msg("hello", "en"), "test-format": RUYI.i18n.msg("test-format").format(num=123), } ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/test-messages.toml000066400000000000000000000002301520522431500303340ustar00rootroot00000000000000ruyi-repo-messages = "v1" [hello] en_US = "Hello world!" zh_CN = "你好世界!" [test-format] en_US = "{num} message(s)" zh_CN = "{num} 条消息" ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/with_/000077500000000000000000000000001520522431500257725ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/api_tests/with_/mod.star000066400000000000000000000004551520522431500274500ustar00rootroot00000000000000RUYI = ruyi_plugin_rev(1) def fn1(mgr): def _with_inner(obj): return obj * 2 return RUYI.with_(mgr, _with_inner) def fn2(mgr): def _with_inner(obj): return mgr.NoNeXiStEnT return RUYI.with_(mgr, _with_inner) def fn3(mgr, py_fn): return RUYI.with_(mgr, py_fn) ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/lang_tests/000077500000000000000000000000001520522431500250305ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/lang_tests/frozen_values/000077500000000000000000000000001520522431500277125ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/lang_tests/frozen_values/a.star000066400000000000000000000002611520522431500310240ustar00rootroot00000000000000# From the "Functions" section of the Starlark spec def f(x, list=[]): list.append(x) return list f(1) # [1] f(2) # [1, 2], not [2]! ruyisdk-ruyi-1f00e2e/tests/fixtures/plugins_suites/lang_tests/frozen_values/mod.star000066400000000000000000000004141520522431500313630ustar00rootroot00000000000000load("./a.star", "f") # Should result in an error because the default argument of f is expected to be # frozen at this point, but currently our unsandboxed execution backend is not # implementing proper freezing, so this is going to succeed in our case... val = f(3) ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/000077500000000000000000000000001520522431500226765ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/000077500000000000000000000000001520522431500263255ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/000077500000000000000000000000001520522431500301075ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/arch.jsonschema000066400000000000000000000016341520522431500331040ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Architecture Entity Schema", "description": "Schema for architecture entity definitions", "type": "object", "required": ["ruyi-entity", "arch"], "properties": { "ruyi-entity": { "type": "string", "description": "Version of the entity schema", "enum": ["v0"] }, "arch": { "type": "object", "required": ["id", "display_name"], "properties": { "id": { "type": "string", "description": "Unique identifier for the architecture" }, "display_name": { "type": "string", "description": "Human-readable name for the architecture" } } }, "related": { "type": "array", "description": "List of related entity references", "items": { "type": "string", "pattern": "^.+:.+" } } } } ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/cpu.jsonschema000066400000000000000000000015661520522431500327620ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "CPU Entity Schema", "description": "Schema for CPU entity definitions", "type": "object", "required": ["ruyi-entity", "cpu"], "properties": { "ruyi-entity": { "type": "string", "description": "Version of the entity schema", "enum": ["v0"] }, "cpu": { "type": "object", "required": ["id", "display_name"], "properties": { "id": { "type": "string", "description": "Unique identifier for the CPU" }, "display_name": { "type": "string", "description": "Human-readable name for the CPU" } } }, "related": { "type": "array", "description": "List of related entity references", "items": { "type": "string", "pattern": "^.+:.+" } } } } ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/device.jsonschema000066400000000000000000000027421520522431500334270ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Device Entity Schema", "description": "Schema for device entity definitions", "type": "object", "required": ["ruyi-entity", "device"], "properties": { "ruyi-entity": { "type": "string", "description": "Version of the entity schema", "enum": ["v0"] }, "device": { "type": "object", "required": ["id", "display_name"], "properties": { "id": { "type": "string", "description": "Unique identifier for the device" }, "display_name": { "type": "string", "description": "Human-readable name for the device" }, "variants": { "type": "array", "description": "List of device variants (different configurations of the same device)", "items": { "type": "object", "required": ["id", "display_name"], "properties": { "id": { "type": "string", "description": "Unique identifier for the variant" }, "display_name": { "type": "string", "description": "Human-readable name for the variant" } } } } } }, "related": { "type": "array", "description": "List of related entity references", "items": { "type": "string", "pattern": "^.+:.+" } } } } ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/uarch.jsonschema000066400000000000000000000031411520522431500332640ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "UArch Entity Schema", "description": "Schema for micro-architecture entity definitions", "type": "object", "required": ["ruyi-entity", "uarch"], "properties": { "ruyi-entity": { "type": "string", "description": "Version of the entity schema", "enum": ["v0"] }, "uarch": { "type": "object", "required": ["id", "display_name", "arch"], "properties": { "id": { "type": "string", "description": "Unique identifier for the microarchitecture" }, "display_name": { "type": "string", "description": "Human-readable name for the microarchitecture" }, "arch": { "type": "string", "description": "Architecture family identifier (e.g., riscv64)" }, "riscv": { "type": "object", "description": "RISC-V specific configuration (only present for RISC-V architectures)", "properties": { "isa": { "type": "string", "description": "RISC-V ISA specification string" } }, "required": ["isa"] } }, "allOf": [ { "if": { "properties": { "arch": { "const": "riscv64" } } }, "then": { "required": ["riscv"] } } ] }, "related": { "type": "array", "description": "List of related entity references", "items": { "type": "string", "pattern": "^.+:.+" } } } } ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/arch/000077500000000000000000000000001520522431500272425ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/arch/riscv64.toml000066400000000000000000000001111520522431500314300ustar00rootroot00000000000000ruyi-entity = "v0" [arch] id = "riscv64" display_name = "64-bit RISC-V" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/cpu/000077500000000000000000000000001520522431500271145ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/cpu/xiangshan-nanhu.toml000066400000000000000000000001671520522431500331040ustar00rootroot00000000000000ruyi-entity = "v0" related = ["uarch:xiangshan-nanhu"] [cpu] id = "xiangshan-nanhu" display_name = "Xiangshan Nanhu" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/cpu/xuantie-th1520.toml000066400000000000000000000001621520522431500324060ustar00rootroot00000000000000ruyi-entity = "v0" related = ["uarch:xuantie-c910"] [cpu] id = "xuantie-th1520" display_name = "Xuantie TH1520" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/000077500000000000000000000000001520522431500275645ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lc4a.toml000066400000000000000000000004421520522431500325530ustar00rootroot00000000000000ruyi-entity = "v0" related = ["cpu:xuantie-th1520"] [device] id = "sipeed-lc4a" display_name = "Sipeed Lichee Cluster 4A" [[device.variants]] id = "8g" display_name = "Sipeed Lichee Cluster 4A (8G RAM)" [[device.variants]] id = "16g" display_name = "Sipeed Lichee Cluster 4A (16G RAM)" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lcon4a.toml000066400000000000000000000004441520522431500331120ustar00rootroot00000000000000ruyi-entity = "v0" related = ["cpu:xuantie-th1520"] [device] id = "sipeed-lcon4a" display_name = "Sipeed Lichee Console 4A" [[device.variants]] id = "8g" display_name = "Sipeed Lichee Console 4A (8G RAM)" [[device.variants]] id = "16g" display_name = "Sipeed Lichee Console 4A (16G RAM)" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lpi4a.toml000066400000000000000000000004211520522431500327360ustar00rootroot00000000000000ruyi-entity = "v0" related = ["cpu:xuantie-th1520"] [device] id = "sipeed-lpi4a" display_name = "Sipeed LicheePi 4A" [[device.variants]] id = "8g" display_name = "Sipeed LicheePi 4A (8G RAM)" [[device.variants]] id = "16g" display_name = "Sipeed LicheePi 4A (16G RAM)" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/uarch/000077500000000000000000000000001520522431500274275ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/uarch/xiangshan-nanhu.toml000066400000000000000000000003621520522431500334140ustar00rootroot00000000000000ruyi-entity = "v0" related = ["arch:riscv64"] [uarch] id = "xiangshan-nanhu" display_name = "Xiangshan Nanhu" arch = "riscv64" [uarch.riscv] isa = "rv64imafdc_zba_zbb_zbc_zbs_zbkb_zbkc_zbkx_zknd_zkne_zknh_zksed_zksh_svinval_zicbom_zicboz" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/entities_v0_smoke/uarch/xuantie-c910.toml000066400000000000000000000002511520522431500324510ustar00rootroot00000000000000ruyi-entity = "v0" related = ["arch:riscv64"] [uarch] id = "xuantie-c910" display_name = "Xuantie C910" arch = "riscv64" [uarch.riscv] isa = "rv64imafdc_zfh_xtheadc" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/000077500000000000000000000000001520522431500260545ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/binary-with-cmds.after.toml000066400000000000000000000030401520522431500332270ustar00rootroot00000000000000format = "v1" [metadata] desc = "RuyiSDK wlink Build (Upstream snapshot, built by PLCT)" vendor = { name = "PLCT", eula = "" } [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz" size = 902085 strip_components = 2 [distfiles.checksums] sha256 = "b78c1fa08142e657349d03f9bc2a90c49afc438eac47b17bcbbed1b2c190d13e" sha512 = "8f9e65effc56cfd12c67e18c7e431388f01b23b09cc6f33f815bc6a03457ea143ec463501f53fe8b1b72d0528261bbe5566ae43b111fc802c7c2142c0c5dbace" [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz" size = 941960 strip_components = 2 [distfiles.checksums] sha256 = "d798618d4f947584443e5d7e1a5066458274cc80648ffdd66b176c2bd51c45e7" sha512 = "40b7913e551c952fa252c4b0cda4e071ab20f2b3325850460c2f45fa2c623ced33f9ec696556e3eb5594c4b60227d45b60c0dce1196d391e3935ba11d92b2683" [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz" size = 916779 strip_components = 2 [distfiles.checksums] sha256 = "87ee5c301cf9b5df9b3b759fe77fc06d8225f3cb4a56d66f4c70753b800d8db1" sha512 = "110fa7816fb6318c3b10952f3f3c9efa8555018134029cdb98e9a2a25f7e76860d70fa0f7cc6dc93a32989909b750470e0de7f39d68af50431ba88b9a237fe51" [[binary]] host = "aarch64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz"] [binary.commands] wlink = "bin/wlink" [[binary]] host = "riscv64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz"] [[binary]] host = "x86_64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz"] [binary.commands] bar = "bin/bar" foo = "bin/foo" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/binary-with-cmds.before.toml000066400000000000000000000030341520522431500333730ustar00rootroot00000000000000format = "v1" [metadata] desc = "RuyiSDK wlink Build (Upstream snapshot, built by PLCT)" vendor = { name = "PLCT", eula = "" } [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz" size = 902085 strip_components = 2 [distfiles.checksums] sha256 = "b78c1fa08142e657349d03f9bc2a90c49afc438eac47b17bcbbed1b2c190d13e" sha512 = "8f9e65effc56cfd12c67e18c7e431388f01b23b09cc6f33f815bc6a03457ea143ec463501f53fe8b1b72d0528261bbe5566ae43b111fc802c7c2142c0c5dbace" [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz" size = 941960 strip_components = 2 [distfiles.checksums] sha256 = "d798618d4f947584443e5d7e1a5066458274cc80648ffdd66b176c2bd51c45e7" sha512 = "40b7913e551c952fa252c4b0cda4e071ab20f2b3325850460c2f45fa2c623ced33f9ec696556e3eb5594c4b60227d45b60c0dce1196d391e3935ba11d92b2683" [[distfiles]] name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz" size = 916779 strip_components = 2 [distfiles.checksums] sha256 = "87ee5c301cf9b5df9b3b759fe77fc06d8225f3cb4a56d66f4c70753b800d8db1" sha512 = "110fa7816fb6318c3b10952f3f3c9efa8555018134029cdb98e9a2a25f7e76860d70fa0f7cc6dc93a32989909b750470e0de7f39d68af50431ba88b9a237fe51" [[binary]] host = "aarch64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz"] commands = { wlink = "bin/wlink" } [[binary]] host = "riscv64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz"] [[binary]] host = "x86_64" distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz"] [binary.commands] foo = "bin/foo" bar = "bin/bar" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/distfile-restrict.after.toml000066400000000000000000000016101520522431500335070ustar00rootroot00000000000000format = "v1" [metadata] desc = "Official WCH MounRiver Studio GNU Toolchain (bare-metal, GCC 12.x, prebuilt by WCH)" vendor = { name = "WCH", eula = "" } [[distfiles]] name = "MRS_Toolchain_Linux_x64_V210.tar.xz" size = 548823504 prefixes_to_unpack = ["RISC-V Embedded GCC12"] urls = [ "http://file-oss.mounriver.com/tools/MRS_Toolchain_Linux_x64_V210.tar.xz", ] restrict = ["mirror"] [distfiles.checksums] sha256 = "5431c040cb67cf619fd18d003ed9497a1995f59329b7f51d985dcc8013eff236" sha512 = "9aa07d4b5e173ec5f661d851f60ec88085a458188afdf21e265e47f2ef9df5ff623d0158173a9fb1620a72945c7f3dfc6dfadfc3661dee5184ba9392a8ee90a4" [[binary]] host = "x86_64" distfiles = ["MRS_Toolchain_Linux_x64_V210.tar.xz"] [toolchain] target = "riscv32-wch-elf" quirks = ["wch"] components = [ { name = "binutils", version = "2.38" }, { name = "gcc", version = "12.2.0" }, { name = "gdb", version = "12.1" }, ] ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/distfile-restrict.before.toml000066400000000000000000000016071520522431500336560ustar00rootroot00000000000000format = "v1" [metadata] desc = "Official WCH MounRiver Studio GNU Toolchain (bare-metal, GCC 12.x, prebuilt by WCH)" vendor = { name = "WCH", eula = "" } [[distfiles]] name = "MRS_Toolchain_Linux_x64_V210.tar.xz" size = 548823504 urls = [ "http://file-oss.mounriver.com/tools/MRS_Toolchain_Linux_x64_V210.tar.xz", ] restrict = "mirror" prefixes_to_unpack = ["RISC-V Embedded GCC12"] [distfiles.checksums] sha256 = "5431c040cb67cf619fd18d003ed9497a1995f59329b7f51d985dcc8013eff236" sha512 = "9aa07d4b5e173ec5f661d851f60ec88085a458188afdf21e265e47f2ef9df5ff623d0158173a9fb1620a72945c7f3dfc6dfadfc3661dee5184ba9392a8ee90a4" [[binary]] host = "x86_64" distfiles = ["MRS_Toolchain_Linux_x64_V210.tar.xz"] [toolchain] target = "riscv32-wch-elf" flavors = ["wch"] components = [ { name = "binutils", version = "2.38" }, { name = "gcc", version = "12.2.0" }, { name = "gdb", version = "12.1" }, ] ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/example-board-image.after.toml000066400000000000000000000020071520522431500336500ustar00rootroot00000000000000# SPDX-License-Identifier: Apache-2.0 format = "v1" [metadata] desc = "U-Boot image for Milk-V Meles (16G RAM) and RevyOS 20250323" vendor = { name = "Milk-V", eula = "" } upstream_version = "20250323" [[metadata.service_level]] level = "good" [[distfiles]] name = "u-boot-with-spl-meles-16g.bin" size = 780496 urls = [ "https://mirror.iscas.ac.cn/revyos/extra/images/meles/20250323/u-boot-with-spl-meles-16g.bin", ] restrict = ["mirror"] [distfiles.checksums] sha256 = "1551ae525700dc6352f42b7d5280f9bd9a383f2d2f9dae8b129388c784eed5e0" sha512 = "90d7413459d232c3321c9661b8b9d3201e6efc0ed9b681bb51ddf433addc3b784704bfa01f9c8e8f8b38cd4e031ebe7899d6d6c3c091e28d3998907ae2b19256" [blob] distfiles = [ "u-boot-with-spl-meles-16g.bin", ] [provisionable] strategy = "fastboot-v1" [provisionable.partition_map] uboot = "u-boot-with-spl-meles-16g.bin" # This file is created by program Sync Package Index inside support-matrix # Run ID: 14662502620 # Run URL: https://github.com/wychlw/support-matrix/actions/runs/14662502620 ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/example-board-image.before.toml000066400000000000000000000020051520522431500340070ustar00rootroot00000000000000# SPDX-License-Identifier: Apache-2.0 format = "v1" [[distfiles]] name = "u-boot-with-spl-meles-16g.bin" size = 780496 urls = [ "https://mirror.iscas.ac.cn/revyos/extra/images/meles/20250323/u-boot-with-spl-meles-16g.bin",] restrict = [ "mirror",] [distfiles.checksums] sha256 = "1551ae525700dc6352f42b7d5280f9bd9a383f2d2f9dae8b129388c784eed5e0" sha512 = "90d7413459d232c3321c9661b8b9d3201e6efc0ed9b681bb51ddf433addc3b784704bfa01f9c8e8f8b38cd4e031ebe7899d6d6c3c091e28d3998907ae2b19256" [metadata] desc = "U-Boot image for Milk-V Meles (16G RAM) and RevyOS 20250323" upstream_version = "20250323" [[metadata.service_level]] level = "good" [blob] distfiles = [ "u-boot-with-spl-meles-16g.bin",] [provisionable] strategy = "fastboot-v1" [metadata.vendor] name = "Milk-V" eula = "" [provisionable.partition_map] uboot = "u-boot-with-spl-meles-16g.bin" # This file is created by program Sync Package Index inside support-matrix # Run ID: 14662502620 # Run URL: https://github.com/wychlw/support-matrix/actions/runs/14662502620 prefer-quirks-to-flavors.after.toml000066400000000000000000000040171520522431500346650ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifestformat = "v1" [metadata] desc = "RuyiSDK RISC-V Linux GNU Toolchain 20240222 (T-Head 2.8.0 sources, built by PLCT)" vendor = { name = "PLCT", eula = "" } [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" size = 303887180 [distfiles.checksums] sha256 = "ad98f0a337fc79faa0e28c9d65c667192c787a7a12a34e326b4fc46dcfefc82e" sha512 = "fcb7e7e071ee421626189da67f9e4bbd0da16aed0f8f12646eac20583454689aa239277156118d484a89eb9e68f266dbb98885e6fb851fb934b6ab2a17ab57a5" [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" size = 309153492 [distfiles.checksums] sha256 = "81cfe107bf0121c94fe25db53ea9a7205ebeda686ae7ff60136d42637ccfa3ed" sha512 = "133a8dc2169549c18bfc98606fb39968eb437bb51724a2611950dcd4051942475d45df4a8b945e1846569b543d94a153337f5c48b1fd2d78c6bb9778c121a730" [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz" size = 323299980 [distfiles.checksums] sha256 = "66af0f05f9f71849c909cbf071412501068e44a99cfcceb3fb07e686b2e8c898" sha512 = "7f20aa294ffb000cb52331bf8acab6086995ca2bbd8dd5ce569c7a85ef9b3516a8443080d54f21ae23ffa107456e9d22e7510daf3d64b9a81b75cdd1b578eb5d" [[binary]] host = "aarch64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] [[binary]] host = "riscv64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] [[binary]] host = "x86_64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz"] [toolchain] target = "riscv64-plctxthead-linux-gnu" quirks = ["xthead"] components = [ { name = "binutils", version = "2.35" }, { name = "gcc", version = "10.2.0" }, { name = "gdb", version = "10.0" }, { name = "glibc", version = "2.33" }, { name = "linux-headers", version = "6.4" }, ] included_sysroot = "riscv64-plctxthead-linux-gnu/sysroot" prefer-quirks-to-flavors.before.toml000066400000000000000000000040201520522431500350200ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifestformat = "v1" [metadata] desc = "RuyiSDK RISC-V Linux GNU Toolchain 20240222 (T-Head 2.8.0 sources, built by PLCT)" vendor = { name = "PLCT", eula = "" } [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" size = 303887180 [distfiles.checksums] sha256 = "ad98f0a337fc79faa0e28c9d65c667192c787a7a12a34e326b4fc46dcfefc82e" sha512 = "fcb7e7e071ee421626189da67f9e4bbd0da16aed0f8f12646eac20583454689aa239277156118d484a89eb9e68f266dbb98885e6fb851fb934b6ab2a17ab57a5" [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" size = 309153492 [distfiles.checksums] sha256 = "81cfe107bf0121c94fe25db53ea9a7205ebeda686ae7ff60136d42637ccfa3ed" sha512 = "133a8dc2169549c18bfc98606fb39968eb437bb51724a2611950dcd4051942475d45df4a8b945e1846569b543d94a153337f5c48b1fd2d78c6bb9778c121a730" [[distfiles]] name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz" size = 323299980 [distfiles.checksums] sha256 = "66af0f05f9f71849c909cbf071412501068e44a99cfcceb3fb07e686b2e8c898" sha512 = "7f20aa294ffb000cb52331bf8acab6086995ca2bbd8dd5ce569c7a85ef9b3516a8443080d54f21ae23ffa107456e9d22e7510daf3d64b9a81b75cdd1b578eb5d" [[binary]] host = "aarch64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] [[binary]] host = "riscv64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] [[binary]] host = "x86_64" distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz"] [toolchain] target = "riscv64-plctxthead-linux-gnu" flavors = ["xthead"] components = [ { name = "binutils", version = "2.35" }, { name = "gcc", version = "10.2.0" }, { name = "gdb", version = "10.0" }, { name = "glibc", version = "2.33" }, { name = "linux-headers", version = "6.4" }, ] included_sysroot = "riscv64-plctxthead-linux-gnu/sysroot" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/strip-components-zero.after.toml000066400000000000000000000014511520522431500343530ustar00rootroot00000000000000format = "v1" [metadata] desc = "Buildroot SDK & FreeRTOS image for Sipeed LicheeRV Nano, 20260114" vendor = { name = "Sipeed", eula = "" } upstream_version = "20260114" [[distfiles]] name = "2026-01-14-16-03-d4003f.tar.xz" size = 171913924 strip_components = 0 urls = [ "https://github.com/sipeed/LicheeRV-Nano-Build/releases/download/20260114/2026-01-14-16-03-d4003f.tar.xz", ] restrict = ["mirror"] [distfiles.checksums] sha256 = "d6478170e923615ca28c97592a2c68a67971e6d07fcb967371b58791938698dd" sha512 = "63b2ba457c227f1f171af669d80663d2b92a7de1b23cc7975cba0a2b3924d50608b71cf6978735a981b666da709d5b30103124ab9a37fe49e6080ca826c5e475" [blob] distfiles = [ "2026-01-14-16-03-d4003f.tar.xz", ] [provisionable] strategy = "dd-v1" [provisionable.partition_map] disk = "2026-01-14-16-03-d4003f.img" ruyisdk-ruyi-1f00e2e/tests/fixtures/ruyipkg_suites/format_manifest/strip-components-zero.before.toml000066400000000000000000000014441520522431500345160ustar00rootroot00000000000000format = "v1" [metadata] desc = "Buildroot SDK & FreeRTOS image for Sipeed LicheeRV Nano, 20260114" vendor = { name = "Sipeed", eula = "" } upstream_version = "20260114" [[distfiles]] name = "2026-01-14-16-03-d4003f.tar.xz" size = 171913924 urls = [ "https://github.com/sipeed/LicheeRV-Nano-Build/releases/download/20260114/2026-01-14-16-03-d4003f.tar.xz", ] restrict = ["mirror"] strip_components = 0 [distfiles.checksums] sha256 = "d6478170e923615ca28c97592a2c68a67971e6d07fcb967371b58791938698dd" sha512 = "63b2ba457c227f1f171af669d80663d2b92a7de1b23cc7975cba0a2b3924d50608b71cf6978735a981b666da709d5b30103124ab9a37fe49e6080ca826c5e475" [blob] distfiles = ["2026-01-14-16-03-d4003f.tar.xz"] [provisionable] strategy = "dd-v1" [provisionable.partition_map] disk = "2026-01-14-16-03-d4003f.img" ruyisdk-ruyi-1f00e2e/tests/integration/000077500000000000000000000000001520522431500202625ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/integration/test_admin_build_package.py000066400000000000000000000061351520522431500256220ustar00rootroot00000000000000"""CLI integration tests for `ruyi admin build-package` (B8).""" from __future__ import annotations import pathlib from tests.fixtures import IntegrationTestHarness def _setup_project(tmp_path: pathlib.Path, recipe_body: str) -> pathlib.Path: proj = tmp_path / "recipes-proj" proj.mkdir() (proj / "ruyi-build-recipes.toml").write_text( 'format = "v1"\n[project]\nname = "integ"\n' ) (proj / "out").mkdir() recipe = proj / "pkg.star" recipe.write_text(recipe_body) return recipe def test_admin_build_package_dry_run( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: recipe = _setup_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/true'])\n" "RUYI.build.schedule_build(build_it)\n", ) result = ruyi_cli_runner("admin", "build-package", "--dry-run", str(recipe)) assert result.exit_code == 0, result.stderr assert "build_it" in result.stdout def test_admin_build_package_executes( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: recipe = _setup_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/true'])\n" "RUYI.build.schedule_build(build_it)\n", ) result = ruyi_cli_runner("admin", "build-package", str(recipe)) assert result.exit_code == 0, result.stderr def test_admin_build_package_var_flag( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: recipe = _setup_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/echo', ctx.var('arch')])\n" "RUYI.build.schedule_build(build_it)\n", ) result = ruyi_cli_runner( "admin", "build-package", "--dry-run", "-v", "arch=riscv64", str(recipe), ) assert result.exit_code == 0, result.stderr assert "riscv64" in result.stdout def test_admin_build_package_invalid_var( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: recipe = _setup_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/true'])\n" "RUYI.build.schedule_build(build_it)\n", ) result = ruyi_cli_runner( "admin", "build-package", "--dry-run", "-v", "no_equals_here", str(recipe), ) assert result.exit_code == 1 def test_admin_build_package_build_failure_exits_nonzero( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: recipe = _setup_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/false'])\n" "RUYI.build.schedule_build(build_it)\n", ) result = ruyi_cli_runner("admin", "build-package", str(recipe)) assert result.exit_code != 0 ruyisdk-ruyi-1f00e2e/tests/integration/test_admin_check.py000066400000000000000000000126601520522431500241250ustar00rootroot00000000000000from contextlib import redirect_stderr, redirect_stdout import json import pathlib import pytest from ruyi.cli.main import main as ruyi_main from tests.fixtures import IntegrationTestHarness SHA_STUB = "0" * 64 CANONICAL_MANIFEST = f"""format = "v1" [metadata] desc = "Test package" vendor = {{ name = "Test Vendor", eula = "" }} [[distfiles]] name = "src.tar.zst" size = 0 [distfiles.checksums] sha256 = "{SHA_STUB}" [source] distfiles = ["src.tar.zst"] """ NON_CANONICAL_MANIFEST = f"""format = "v1" [[distfiles]] size = 0 name = "src.tar.zst" [distfiles.checksums] sha256 = "{SHA_STUB}" [metadata] vendor = {{ eula = "", name = "Test Vendor" }} desc = "Test package" [source] distfiles = ["src.tar.zst"] """ def test_admin_check_file_exits_zero_for_good_manifest( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(CANONICAL_MANIFEST, encoding="utf-8") result = ruyi_cli_runner("admin", "check", "-f", str(manifest_path)) assert result.exit_code == 0 assert "0 error(s), 0 warning(s)" in result.stdout def test_admin_check_accepts_multiple_file_flags( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: first_path = tmp_path / "1.0.0.toml" second_path = tmp_path / "2.0.0.toml" first_path.write_text(CANONICAL_MANIFEST, encoding="utf-8") second_path.write_text(CANONICAL_MANIFEST, encoding="utf-8") result = ruyi_cli_runner( "admin", "check", "-f", str(first_path), "--file", str(second_path), ) assert result.exit_code == 0 assert "0 error(s), 0 warning(s)" in result.stdout def test_admin_check_file_reports_diagnostics( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(NON_CANONICAL_MANIFEST, encoding="utf-8") result = ruyi_cli_runner("admin", "check", "-f", str(manifest_path)) assert result.exit_code == 1 assert "RYC0001" in result.stdout assert str(manifest_path) in result.stdout def test_admin_check_repo_exits_zero_for_default_fixture_repo( ruyi_cli_runner: IntegrationTestHarness, ) -> None: result = ruyi_cli_runner( "admin", "check", "--repo", str(ruyi_cli_runner.repo_root), ) assert result.exit_code == 0 assert "0 error(s), 0 warning(s)" in result.stdout def test_admin_check_repo_reports_multiple_diagnostics( ruyi_cli_runner: IntegrationTestHarness, ) -> None: repo_root = ruyi_cli_runner.repo_root ruyi_cli_runner.add_package("source", "bad-toml", "1.0.0", 'format = "v1"\n[') ruyi_cli_runner.add_package( "source", "bad-version", "not-semver", CANONICAL_MANIFEST, ) result = ruyi_cli_runner("admin", "check", "--repo", str(repo_root)) assert result.exit_code == 1 assert "RYC0002" in result.stdout assert "RYC0004" in result.stdout def test_admin_check_file_and_repo_fail_argument_validation( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(CANONICAL_MANIFEST, encoding="utf-8") with pytest.raises(SystemExit): ruyi_cli_runner( "admin", "check", "-f", str(manifest_path), "--repo", str(ruyi_cli_runner.repo_root), ) def test_admin_check_help_is_registered( ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("admin", "check", "--help") with ( pytest.raises(SystemExit) as exc_info, redirect_stdout(ctx.stdout), redirect_stderr(ctx.stderr), ): ruyi_main(ctx.gm, ctx.gc, ctx.argv) assert exc_info.value.code == 0 assert "usage: ruyi admin check" in ctx.stdout.getvalue() assert "--repo" in ctx.stdout.getvalue() assert "--only-packages" in ctx.stdout.getvalue() def test_admin_check_only_packages_uses_list_style_selectors( ruyi_cli_runner: IntegrationTestHarness, ) -> None: source_path = ruyi_cli_runner.add_package( "source", "ignored-source", "1.0.0", 'format = "v1"\n[', ) board_path = ruyi_cli_runner.add_package( "board-image", "selected-board", "1.0.0", NON_CANONICAL_MANIFEST, ) result = ruyi_cli_runner( "admin", "check", "--repo", str(ruyi_cli_runner.repo_root), "--only-packages", "--category-is", "board-image", ) assert result.exit_code == 1 assert "RYC0001" in result.stdout assert str(board_path) in result.stdout assert str(source_path) not in result.stdout assert "RYC0002" not in result.stdout def test_admin_check_porcelain_emits_jsonl_diagnostics( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(NON_CANONICAL_MANIFEST, encoding="utf-8") result = ruyi_cli_runner( "--porcelain", "admin", "check", "-f", str(manifest_path), ) assert result.exit_code == 1 entities = [json.loads(line) for line in result.stdout.splitlines()] assert [entity["ty"] for entity in entities] == ["checkdiagnostic-v1"] assert entities[0]["code"] == "RYC0001" ruyisdk-ruyi-1f00e2e/tests/integration/test_cli_basic.py000066400000000000000000000131601520522431500236040ustar00rootroot00000000000000import io import pathlib import sys from tests.fixtures import IntegrationTestHarness import pytest from ruyi.cli.main import is_version_query, main as ruyi_main from ruyi.log import RuyiConsoleLogger from ruyi.ruyipkg.distfile import Distfile SHA_STUB = "0" * 64 class _TTYStringIO(io.StringIO): def isatty(self) -> bool: return True def _fail_on_repo_access(self: object) -> None: raise AssertionError("completion setup must not access the package repo") def test_cli_version_query_detection() -> None: assert is_version_query(["ruyi", "--version"]) assert is_version_query(["ruyi", "-V"]) assert is_version_query(["ruyi", "--porcelain", "--version"]) assert is_version_query(["ruyi", "--config", "foo", "--version"]) assert is_version_query(["ruyi", "version"]) assert is_version_query(["ruyi", "--porcelain", "version"]) assert not is_version_query(["ruyi"]) assert not is_version_query(["ruyi", "list"]) assert not is_version_query(["ruyi", "list", "version"]) def test_cli_version(ruyi_cli_runner: IntegrationTestHarness) -> None: for argv in [ ["--version"], ["version"], ]: result = ruyi_cli_runner(*argv) assert result.exit_code == 0 assert "Ruyi" in result.stdout assert "fatal error" not in result.stderr.lower() def test_output_completion_script_does_not_access_repo_or_telemetry( ruyi_cli_runner: IntegrationTestHarness, monkeypatch: pytest.MonkeyPatch, ) -> None: from ruyi.ruyipkg.repo import MetadataRepo monkeypatch.setattr(MetadataRepo, "ensure_git_repo", _fail_on_repo_access) result = ruyi_cli_runner("--output-completion-script=bash") assert result.exit_code == 0 assert result.stdout.startswith("#compdef ruyi\n") assert "package repository" not in result.stderr telemetry_root = pathlib.Path(ruyi_cli_runner._env["XDG_STATE_HOME"]) / "ruyi" assert not (telemetry_root / "telemetry" / "installation.json").exists() assert not (telemetry_root / "telemetry" / "minimal-installation-marker").exists() def test_autocomplete_parser_build_does_not_access_package_repo( ruyi_cli_runner: IntegrationTestHarness, monkeypatch: pytest.MonkeyPatch, ) -> None: from ruyi.cli import builtin_commands from ruyi.cli.cmd import RootCommand from ruyi.ruyipkg.repo import MetadataRepo del builtin_commands ctx = ruyi_cli_runner.make_command_context() ctx.gm._is_cli_autocomplete = True # pylint: disable=protected-access monkeypatch.setattr(MetadataRepo, "ensure_git_repo", _fail_on_repo_access) RootCommand.build_argparse(ctx.gc) def test_cli_version_skips_first_run_oobe( ruyi_cli_runner: IntegrationTestHarness, monkeypatch: "pytest.MonkeyPatch", ) -> None: for argv in [ ("--version",), ("version",), ]: ctx = ruyi_cli_runner.make_command_context(*argv) stdout = _TTYStringIO() stderr = _TTYStringIO() ctx.gc.logger = RuyiConsoleLogger(ctx.gm, stdout=stdout, stderr=stderr) monkeypatch.setattr(sys, "stdin", _TTYStringIO()) monkeypatch.setattr(sys, "stdout", stdout) monkeypatch.setattr(sys, "stderr", stderr) exit_code = ruyi_main(ctx.gm, ctx.gc, ctx.argv) assert exit_code == 0 assert "Ruyi" in stdout.getvalue() assert "Welcome to RuyiSDK" not in stderr.getvalue() telemetry_root = pathlib.Path(ctx.gc.telemetry_root) assert not (telemetry_root / "installation.json").exists() assert not (telemetry_root / "minimal-installation-marker").exists() def test_cli_list_with_mock_repo(ruyi_cli_runner: IntegrationTestHarness) -> None: result = ruyi_cli_runner("list", "--name-contains", "sample-cli") assert result.exit_code == 0 assert "dev-tools/sample-cli" in result.stdout def test_cli_list_with_custom_package(ruyi_cli_runner: IntegrationTestHarness) -> None: manifest = ( 'format = "v1"\n' 'kind = ["source"]\n\n' "[metadata]\n" 'desc = "Custom integration package"\n' 'vendor = { name = "Integration Tests", eula = "" }\n\n' "[[distfiles]]\n" 'name = "custom-src.tar.zst"\n' "size = 0\n\n" "[distfiles.checksums]\n" f'sha256 = "{SHA_STUB}"\n' ) ruyi_cli_runner.add_package("examples", "custom-cli", "0.1.0", manifest) result = ruyi_cli_runner("list", "--category-is", "examples") assert result.exit_code == 0 assert "examples/custom-cli" in result.stdout def test_cli_extract_without_subdir_reports_current_directory( ruyi_cli_runner: IntegrationTestHarness, monkeypatch: pytest.MonkeyPatch, ) -> None: manifest = f"""\ format = "v1" kind = ["source"] [metadata] desc = "Extract message package" vendor = {{ name = "Integration Tests", eula = "" }} [[distfiles]] name = "extract-src.raw" size = 0 unpack = "raw" [distfiles.checksums] sha256 = "{SHA_STUB}" [source] distfiles = ["extract-src.raw"] """ ruyi_cli_runner.add_package("examples", "extract-message", "1.0.0", manifest) unpack_roots: list[object] = [] def fake_ensure(self: object, logger: object) -> None: pass def fake_unpack(self: object, root: object, logger: object) -> None: unpack_roots.append(root) monkeypatch.setattr(Distfile, "ensure", fake_ensure) monkeypatch.setattr(Distfile, "unpack", fake_unpack) result = ruyi_cli_runner( "extract", "--extract-without-subdir", "examples/extract-message", ) assert result.exit_code == 0 assert unpack_roots == [None] assert "has been extracted to ." in result.stderr assert "has been extracted to None" not in result.stderr ruyisdk-ruyi-1f00e2e/tests/integration/test_multi_repo.py000066400000000000000000000302141520522431500240520ustar00rootroot00000000000000"""Integration tests for multi-repo workflows. Tests full round-trips through the CLI: repo add/remove/enable/disable, list with priority shadowing, and package listing across repos. """ import pathlib import pygit2 from tests.fixtures import IntegrationTestHarness SHA_STUB = "0" * 64 def _write_user_config(harness: IntegrationTestHarness, toml: str) -> None: """Write a user config file into the harness's XDG config dir.""" config_dir = pathlib.Path(harness._env["XDG_CONFIG_HOME"]) / "ruyi" config_dir.mkdir(parents=True, exist_ok=True) (config_dir / "config.toml").write_text(toml, encoding="utf-8") def _make_repo_dir( harness: IntegrationTestHarness, repo_id: str, *, config_toml_id: str | None = None, ) -> pathlib.Path: """Create a minimal repo dir under the harness cache with config.toml.""" cache_dir = pathlib.Path(harness._env["XDG_CACHE_HOME"]) repo_root = cache_dir / "ruyi" / "repos" / repo_id repo_root.mkdir(parents=True, exist_ok=True) pygit2.init_repository(str(repo_root)) on_disk_id = config_toml_id if config_toml_id is not None else repo_id config_text = f"""\ ruyi-repo = "v1" [repo] id = "{on_disk_id}" [[mirrors]] id = "ruyi-dist" urls = ["https://example.invalid/dist/"] """ (repo_root / "config.toml").write_text(config_text, encoding="utf-8") return repo_root def _add_manifest( repo_root: pathlib.Path, category: str, name: str, version: str, desc: str = "test package", ) -> None: """Write a minimal package manifest into a repo directory.""" pkg_dir = repo_root / "packages" / category / name pkg_dir.mkdir(parents=True, exist_ok=True) manifest = f"""\ format = "v1" kind = ["source"] [metadata] desc = "{desc}" vendor = {{ name = "Test Vendor", eula = "" }} [[distfiles]] name = "{name}-{version}.tar.zst" size = 0 [distfiles.checksums] sha256 = "{SHA_STUB}" """ (pkg_dir / f"{version}.toml").write_text(manifest, encoding="utf-8") class TestRepoListMultiRepo: """repo list shows multiple configured repos with correct markers.""" def test_list_shows_default_only( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner("repo", "list") assert result.exit_code == 0 assert "ruyisdk" in result.stdout assert "(default)" in result.stdout def test_list_shows_additional_repo( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: _make_repo_dir(ruyi_cli_runner, "custom-repo") _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "custom-repo" remote = "https://example.invalid/custom.git" priority = 50 active = true """, ) result = ruyi_cli_runner("repo", "list") assert result.exit_code == 0 assert "ruyisdk" in result.stdout assert "custom-repo" in result.stdout assert "priority=50" in result.stdout def test_list_inactive_repo_no_star( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "disabled-repo" remote = "https://example.invalid/disabled.git" active = false """, ) result = ruyi_cli_runner("repo", "list") assert result.exit_code == 0 assert "disabled-repo" in result.stdout # The default repo should have an asterisk but the disabled one should not lines = result.stdout.strip().splitlines() for line in lines: if "disabled-repo" in line: # Should not start with * stripped = line.lstrip() assert not stripped.startswith( "*" ), f"disabled repo should not have *: {line}" class TestRepoAddRemoveRoundTrip: """Test adding and removing repos via CLI.""" def test_add_repo_creates_config_entry( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner( "repo", "add", "my-overlay", "https://example.invalid/overlay.git", "--priority", "10", ) assert result.exit_code == 0 assert "my-overlay" in result.stderr # Verify it shows up in repo list result = ruyi_cli_runner("repo", "list") assert result.exit_code == 0 assert "my-overlay" in result.stdout assert "priority=10" in result.stdout def test_add_duplicate_fails(self, ruyi_cli_runner: IntegrationTestHarness) -> None: result = ruyi_cli_runner( "repo", "add", "dup-repo", "https://example.invalid/dup.git", ) assert result.exit_code == 0 result = ruyi_cli_runner( "repo", "add", "dup-repo", "https://example.invalid/dup2.git", ) assert result.exit_code == 1 assert "already exists" in result.stderr def test_add_reserved_id_fails( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner( "repo", "add", "ruyisdk", "https://example.invalid/ruyisdk.git", ) assert result.exit_code == 1 assert "reserved" in result.stderr def test_add_invalid_id_fails( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner( "repo", "add", "INVALID_ID!", "https://example.invalid/bad.git", ) assert result.exit_code == 1 assert "invalid" in result.stderr def test_add_no_url_no_local_fails( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner("repo", "add", "orphan-repo") assert result.exit_code == 1 def test_remove_repo(self, ruyi_cli_runner: IntegrationTestHarness) -> None: # Add then remove result = ruyi_cli_runner( "repo", "add", "to-remove", "https://example.invalid/to-remove.git", ) assert result.exit_code == 0 result = ruyi_cli_runner("repo", "remove", "to-remove") assert result.exit_code == 0 assert "removed" in result.stderr # Verify gone from list result = ruyi_cli_runner("repo", "list") assert "to-remove" not in result.stdout def test_remove_default_fails( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner("repo", "remove", "ruyisdk") assert result.exit_code == 1 assert "cannot remove" in result.stderr def test_remove_nonexistent_fails( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: result = ruyi_cli_runner("repo", "remove", "no-such-repo") assert result.exit_code == 1 def test_remove_with_purge(self, ruyi_cli_runner: IntegrationTestHarness) -> None: repo_root = _make_repo_dir(ruyi_cli_runner, "purge-me") result = ruyi_cli_runner( "repo", "add", "purge-me", "https://example.invalid/purge-me.git", ) assert result.exit_code == 0 assert repo_root.exists() result = ruyi_cli_runner("repo", "remove", "purge-me", "--purge") assert result.exit_code == 0 assert not repo_root.exists() class TestRepoEnableDisable: """Enable and disable repos via CLI.""" def test_disable_and_enable(self, ruyi_cli_runner: IntegrationTestHarness) -> None: result = ruyi_cli_runner( "repo", "add", "toggle-repo", "https://example.invalid/toggle.git", ) assert result.exit_code == 0 # Disable result = ruyi_cli_runner("repo", "disable", "toggle-repo") assert result.exit_code == 0 result = ruyi_cli_runner("repo", "list") lines = result.stdout.strip().splitlines() for line in lines: if "toggle-repo" in line: stripped = line.lstrip() assert not stripped.startswith("*") # Enable result = ruyi_cli_runner("repo", "enable", "toggle-repo") assert result.exit_code == 0 result = ruyi_cli_runner("repo", "list") lines = result.stdout.strip().splitlines() for line in lines: if "toggle-repo" in line: stripped = line.lstrip() assert stripped.startswith("*") class TestRepoSetPriority: """Set priority on repos via CLI.""" def test_set_priority(self, ruyi_cli_runner: IntegrationTestHarness) -> None: result = ruyi_cli_runner( "repo", "add", "pri-repo", "https://example.invalid/pri.git", "--priority", "5", ) assert result.exit_code == 0 result = ruyi_cli_runner("repo", "set-priority", "pri-repo", "99") assert result.exit_code == 0 result = ruyi_cli_runner("repo", "list") assert "priority=99" in result.stdout class TestMultiRepoPackageListing: """Package list shows packages from multiple repos with priority.""" def test_list_packages_across_repos( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: """Packages from both default and overlay repos appear in list.""" overlay_root = _make_repo_dir(ruyi_cli_runner, "overlay") _add_manifest(overlay_root, "toolchain", "overlay-gcc", "1.0.0", "Overlay GCC") _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "overlay" remote = "https://example.invalid/overlay.git" priority = 100 active = true """, ) result = ruyi_cli_runner("list", "--all") assert result.exit_code == 0 # Default repo has sample-cli, overlay has overlay-gcc assert "sample-cli" in result.stdout assert "overlay-gcc" in result.stdout def test_priority_shadowing_in_list( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: """Higher-priority repo shadows lower for same package version.""" overlay_root = _make_repo_dir(ruyi_cli_runner, "overlay") # Same package as default repo but different desc _add_manifest( overlay_root, "dev-tools", "sample-cli", "1.0.0", "Overlay Sample CLI", ) _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "overlay" remote = "https://example.invalid/overlay.git" priority = 100 active = true """, ) result = ruyi_cli_runner("list", "--name-contains", "sample-cli") assert result.exit_code == 0 assert "sample-cli" in result.stdout def test_disabled_repo_packages_excluded( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: """Disabled repo's packages do not appear in list.""" disabled_root = _make_repo_dir(ruyi_cli_runner, "disabled-repo") _add_manifest( disabled_root, "toolchain", "hidden-pkg", "1.0.0", "Should Not Appear" ) _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "disabled-repo" remote = "https://example.invalid/disabled.git" active = false """, ) result = ruyi_cli_runner("list", "--all") assert result.exit_code == 0 assert "hidden-pkg" not in result.stdout # Default packages still visible assert "sample-cli" in result.stdout def test_multi_repo_tag_shown( self, ruyi_cli_runner: IntegrationTestHarness ) -> None: """When multiple repos are configured, list shows [repo-id] tags.""" overlay_root = _make_repo_dir(ruyi_cli_runner, "overlay") _add_manifest(overlay_root, "source", "extra-lib", "0.1.0") _write_user_config( ruyi_cli_runner, """\ [[repos]] id = "overlay" remote = "https://example.invalid/overlay.git" priority = 50 active = true """, ) result = ruyi_cli_runner("list", "--all") assert result.exit_code == 0 # With multiple repos, repo ID tags should appear assert "[ruyisdk]" in result.stdout or "[overlay]" in result.stdout ruyisdk-ruyi-1f00e2e/tests/integration/test_venv_cli.py000066400000000000000000000176331520522431500235120ustar00rootroot00000000000000import argparse import os import pathlib import shutil import pytest from ruyi.cli.completion import ArgumentParser from ruyi.mux.venv.maker import ( SysrootProvisionMode, VenvProvisionError, provision_sysroot, ) from ruyi.mux.venv.venv_cli import VenvCommand from tests.fixtures import IntegrationTestHarness def make_parser(ruyi_cli_runner: IntegrationTestHarness) -> ArgumentParser: ctx = ruyi_cli_runner.make_command_context("venv") p = ruyi_cli_runner.make_parser() VenvCommand.configure_args(ctx.gc, p) return p def test_parse_new_sysroot_source_flags( ruyi_cli_runner: IntegrationTestHarness, ) -> None: p = make_parser(ruyi_cli_runner) args = p.parse_args( [ "default", "./venv", "--copy-sysroot-from-dir", "/tmp/sysroot", ] ) assert args.copy_sysroot_from_dir == "/tmp/sysroot" assert args.symlink_sysroot_from_dir is None assert args.copy_sysroot_from_pkg is None assert args.project_sysroot_from_rootfs is None args = p.parse_args( [ "default", "./venv", "--symlink-sysroot-from-dir", "/tmp/sysroot", ] ) assert args.symlink_sysroot_from_dir == "/tmp/sysroot" args = p.parse_args( [ "default", "./venv", "--project-sysroot-from-rootfs", "/tmp/rootfs", ] ) assert args.project_sysroot_from_rootfs == "/tmp/rootfs" def test_old_sysroot_from_alias_still_parses( ruyi_cli_runner: IntegrationTestHarness, ) -> None: p = make_parser(ruyi_cli_runner) args = p.parse_args( [ "default", "./venv", "--sysroot-from", "gnu-plct", ] ) assert args.copy_sysroot_from_pkg == "gnu-plct" def test_help_documents_sysroot_provisioning( ruyi_cli_runner: IntegrationTestHarness, ) -> None: p = make_parser(ruyi_cli_runner) help_text = p.format_help() assert "Sysroot provisioning:" in help_text assert "--copy-sysroot-from-dir only for a complete sysroot directory" in help_text assert ( "--project-sysroot-from-rootfs for distro rootfs or chroot trees" in help_text ) assert ( "Ruyi never elevates privileges when creating virtual environments" in help_text ) def test_sysroot_source_options_are_mutually_exclusive_at_main( ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") args = argparse.Namespace( profile="default", dest="./venv", with_sysroot=True, name=None, toolchain=["gnu-plct"], emulator=None, copy_sysroot_from_pkg="foo", copy_sysroot_from_dir="/tmp/sysroot", symlink_sysroot_from_dir=None, project_sysroot_from_rootfs=None, extra_commands_from=None, ) rc = VenvCommand.main(ctx.gc, args) assert rc == 1 assert ctx.fatal_messages == [ "at most one of --copy-sysroot-from-pkg, --copy-sysroot-from-dir, --symlink-sysroot-from-dir, and --project-sysroot-from-rootfs may be specified" ] def test_without_sysroot_conflicts_with_explicit_source( ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") args = argparse.Namespace( profile="default", dest="./venv", with_sysroot=False, name=None, toolchain=["gnu-plct"], emulator=None, copy_sysroot_from_pkg=None, copy_sysroot_from_dir="/tmp/sysroot", symlink_sysroot_from_dir=None, project_sysroot_from_rootfs=None, extra_commands_from=None, ) rc = VenvCommand.main(ctx.gc, args) assert rc == 1 assert ctx.fatal_messages == [ "--without-sysroot cannot be combined with a sysroot source option" ] def test_copy_sysroot_failure_reports_clean_diagnostic( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") src = tmp_path / "sysroot" dest = tmp_path / "venv" / "sysroot.riscv64-test-linux-gnu" src.mkdir() def fake_copytree(*args: object, **kwargs: object) -> None: raise shutil.Error( [ ( str(src / "etc" / "shadow"), str(dest / "etc" / "shadow"), "permission denied", ), ] ) monkeypatch.setattr(shutil, "copytree", fake_copytree) with pytest.raises(VenvProvisionError): provision_sysroot( ctx.gc.logger, src, dest, SysrootProvisionMode.COPY_TREE, "riscv64-test-linux-gnu", ) assert ctx.fatal_messages == [ f"cannot copy sysroot from {src}: one entry could not be copied" ] assert ( "Ruyi does not elevate privileges when creating virtual environments" in ctx.stderr.getvalue() ) def test_project_sysroot_from_rootfs_copies_common_roots( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") src = tmp_path / "rootfs" dest = tmp_path / "venv" / "sysroot.riscv64-test-linux-gnu" (src / "usr" / "include").mkdir(parents=True) (src / "usr" / "include" / "game.h").write_text("#pragma once\n", encoding="utf-8") (src / "usr" / "lib").mkdir(parents=True) (src / "usr" / "lib" / "libgame.so").write_bytes(b"fake so") (src / "usr" / "lib" / "ld-linux-riscv64-lp64d.so.1").write_bytes(b"fake ld") (src / "lib64").mkdir() os.symlink( "/usr/lib/ld-linux-riscv64-lp64d.so.1", src / "lib64" / "ld-linux-riscv64-lp64d.so.1", ) (src / "etc").mkdir() (src / "etc" / "shadow").write_text("should not be copied\n", encoding="utf-8") provision_sysroot( ctx.gc.logger, src, dest, SysrootProvisionMode.PROJECT_ROOTFS, "riscv64-test-linux-gnu", ) assert (dest / "usr" / "include" / "game.h").read_text( encoding="utf-8" ) == "#pragma once\n" assert (dest / "usr" / "lib" / "libgame.so").read_bytes() == b"fake so" assert not (dest / "etc" / "shadow").exists() assert os.readlink(dest / "lib64" / "ld-linux-riscv64-lp64d.so.1") == ( "../usr/lib/ld-linux-riscv64-lp64d.so.1" ) def test_project_sysroot_from_rootfs_skips_unsupported_entries( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") src = tmp_path / "rootfs" dest = tmp_path / "venv" / "sysroot.riscv64-test-linux-gnu" (src / "usr" / "lib").mkdir(parents=True) (src / "usr" / "lib" / "libgame.so").write_bytes(b"fake so") os.mkfifo(src / "usr" / "lib" / "unsupported-fifo") provision_sysroot( ctx.gc.logger, src, dest, SysrootProvisionMode.PROJECT_ROOTFS, "riscv64-test-linux-gnu", ) assert (dest / "usr" / "lib" / "libgame.so").read_bytes() == b"fake so" assert not (dest / "usr" / "lib" / "unsupported-fifo").exists() assert "some unreadable or unsupported files were skipped" in ctx.stderr.getvalue() def test_project_sysroot_from_rootfs_fails_without_supported_roots( tmp_path: pathlib.Path, ruyi_cli_runner: IntegrationTestHarness, ) -> None: ctx = ruyi_cli_runner.make_command_context("venv") src = tmp_path / "rootfs" dest = tmp_path / "venv" / "sysroot.riscv64-test-linux-gnu" (src / "etc").mkdir(parents=True) with pytest.raises(VenvProvisionError): provision_sysroot( ctx.gc.logger, src, dest, SysrootProvisionMode.PROJECT_ROOTFS, "riscv64-test-linux-gnu", ) assert ctx.fatal_messages == [ f"cannot project sysroot from {src}: no supported sysroot directories were found" ] ruyisdk-ruyi-1f00e2e/tests/pluginhost/000077500000000000000000000000001520522431500201335ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/pluginhost/__init__.py000066400000000000000000000000001520522431500222320ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_api.py000066400000000000000000000115271520522431500223230ustar00rootroot00000000000000from contextlib import AbstractContextManager import tomllib from types import TracebackType import pytest from ruyi.log import RuyiLogger from ruyi.pluginhost.ctx import PluginHostContext from ruyi.ruyipkg.msg import RepoMessageStore from ..fixtures import RuyiFileFixtureFactory def test_api_has_feature( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: with ruyi_file.plugin_suite("api_tests") as plugin_root: phctx = PluginHostContext.new(ruyi_logger, plugin_root) ev = phctx.make_evaluator() nonexistent = phctx.get_from_plugin("has_feature", "check_nonexistent_feature") assert nonexistent is not None assert not ev.eval_function(nonexistent) def test_api_feature_i18n_v1_dynamic_exposure( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: with ruyi_file.plugin_suite("api_tests") as plugin_root: phctx1 = PluginHostContext.new( ruyi_logger, plugin_root, # no locale or message store factory ) ev1 = phctx1.make_evaluator() feature1 = phctx1.get_from_plugin("i18n-v1", "test_feature") assert not ev1.eval_function(feature1) phctx2 = PluginHostContext.new( ruyi_logger, plugin_root, locale="en_US", # no message store factory ) ev2 = phctx2.make_evaluator() feature2 = phctx2.get_from_plugin("i18n-v1", "test_feature") assert not ev2.eval_function(feature2) with open(plugin_root / "test-messages.toml", "rb") as f: msgs = tomllib.load(f) phctx3 = PluginHostContext.new( ruyi_logger, plugin_root, # no locale message_store_factory=lambda: RepoMessageStore.from_object(msgs), ) ev3 = phctx3.make_evaluator() feature3 = phctx3.get_from_plugin("i18n-v1", "test_feature") assert ev3.eval_function(feature3) def test_api_feature_i18n_v1( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: with ruyi_file.plugin_suite("api_tests") as plugin_root: with open(plugin_root / "test-messages.toml", "rb") as f: msgs = tomllib.load(f) rm = RepoMessageStore.from_object(msgs) phctx = PluginHostContext.new( ruyi_logger, plugin_root, locale="zh_CN", message_store_factory=lambda: rm, ) ev = phctx.make_evaluator() test_feature = phctx.get_from_plugin("i18n-v1", "test_feature") assert test_feature is not None assert ev.eval_function(test_feature) get_locale = phctx.get_from_plugin("i18n-v1", "test_get_locale") assert get_locale is not None locale = ev.eval_function(get_locale) assert locale == "zh_CN" test_messages = phctx.get_from_plugin("i18n-v1", "test_messages") assert test_messages is not None msgs_result = ev.eval_function(test_messages) assert msgs_result == { "hello-default": "你好世界!", "hello-en": "Hello world!", "test-format": "123 条消息", } def test_api_with_( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: class MockContextManager(AbstractContextManager[int]): def __init__(self) -> None: self.entered = 0 self.exited = 0 def __enter__(self) -> int: self.entered += 1 return 233 def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> bool | None: self.exited += 1 return None with ruyi_file.plugin_suite("api_tests") as plugin_root: phctx = PluginHostContext.new(ruyi_logger, plugin_root) ev = phctx.make_evaluator() fn1 = phctx.get_from_plugin("with_", "fn1") assert fn1 is not None cm1 = MockContextManager() ret1 = ev.eval_function(fn1, cm1) assert cm1.entered == 1 assert cm1.exited == 1 assert ret1 == 466 # even when the plugin side panics, the context manager semantics # shall remain enforced fn2 = phctx.get_from_plugin("with_", "fn2") assert fn2 is not None cm2 = MockContextManager() with pytest.raises((RuntimeError, AttributeError)): ev.eval_function(fn2, cm2) assert cm2.entered == 1 assert cm2.exited == 1 def inner_fn3(x: int) -> int: return x - 233 fn3 = phctx.get_from_plugin("with_", "fn3") assert fn3 is not None cm3 = MockContextManager() ret3 = ev.eval_function(fn3, cm3, inner_fn3) assert cm3.entered == 1 assert cm3.exited == 1 assert ret3 == 0 ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_build_recipe_api.py000066400000000000000000000132161520522431500250260ustar00rootroot00000000000000"""Tests for RuyiBuildRecipeAPI (B5).""" import pathlib from typing import Any import pytest from ruyi.log import RuyiLogger from ruyi.pluginhost.api import RuyiHostAPI from ruyi.pluginhost.build_api import RuyiBuildRecipeAPI, ScheduledBuild from ruyi.pluginhost.ctx import PluginHostContext def _make_recipe_phctx( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger, ) -> PluginHostContext[Any, Any]: plugin_root = tmp_path / "plugins" plugin_root.mkdir() recipe_root = tmp_path / "recipes_proj" recipe_root.mkdir() return PluginHostContext.new( ruyi_logger, plugin_root, recipe_project_root=recipe_root, ) def _make_plain_phctx( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger, ) -> PluginHostContext[Any, Any]: plugin_root = tmp_path / "plugins" plugin_root.mkdir() return PluginHostContext.new(ruyi_logger, plugin_root) def test_schedule_build_registers_callable( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) def build_one(ctx: object) -> None: pass api.schedule_build(build_one) registry = phctx.scheduled_builds_for(recipe_file) assert len(registry) == 1 sb = registry[0] assert isinstance(sb, ScheduledBuild) assert sb.name == "build_one" assert sb.fn is build_one assert sb.recipe_file == recipe_file def test_schedule_build_explicit_name( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) api.schedule_build(lambda ctx: None, name="custom") registry = phctx.scheduled_builds_for(recipe_file) assert [sb.name for sb in registry] == ["custom"] def test_schedule_build_rejects_non_callable( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) with pytest.raises(RuntimeError, match="expected a callable"): api.schedule_build("not a function") def test_schedule_build_accepts_lambda( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) api.schedule_build(lambda ctx: None) assert phctx.scheduled_builds_for(recipe_file)[0].name == "" def test_schedule_build_rejects_callable_without_derivable_name( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: class CallableWithoutName: def __call__(self, ctx: Any) -> None: pass phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) with pytest.raises(RuntimeError, match="could not derive a name"): api.schedule_build(CallableWithoutName()) def test_schedule_build_rejects_duplicate_name( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" api = RuyiBuildRecipeAPI(phctx, recipe_file) api.schedule_build(lambda ctx: None, name="dup") with pytest.raises(RuntimeError, match="duplicate build name"): api.schedule_build(lambda ctx: None, name="dup") def test_recipe_phctx_has_build_recipe_capability( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) assert "build-recipe-v1" in phctx.capabilities # Recipes must go through ctx.subprocess; raw subprocess is denied. assert "call-subprocess-v1" not in phctx.capabilities def test_plain_phctx_does_not_have_build_recipe_capability( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_plain_phctx(tmp_path, ruyi_logger) assert "build-recipe-v1" not in phctx.capabilities assert "call-subprocess-v1" in phctx.capabilities def test_build_namespace_gated_on_capability( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_plain_phctx(tmp_path, ruyi_logger) host_api = RuyiHostAPI( phctx, this_file=tmp_path / "unused.star", this_plugin_dir=tmp_path, allow_host_fs_access=False, ) with pytest.raises( RuntimeError, match="only available when loading a build recipe" ): _ = host_api.build def test_build_namespace_available_in_recipe_context( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" host_api = RuyiHostAPI( phctx, this_file=recipe_file, this_plugin_dir=tmp_path / "recipes_proj", allow_host_fs_access=False, ) build = host_api.build assert isinstance(build, RuyiBuildRecipeAPI) def test_call_subprocess_rejected_in_recipe_context( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: phctx = _make_recipe_phctx(tmp_path, ruyi_logger) recipe_file = tmp_path / "recipes_proj" / "pkg.star" host_api = RuyiHostAPI( phctx, this_file=recipe_file, this_plugin_dir=tmp_path / "recipes_proj", allow_host_fs_access=False, ) with pytest.raises(RuntimeError, match="call_subprocess_argv is not available"): host_api.call_subprocess_argv(["/bin/true"]) ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_lang.py000066400000000000000000000012531520522431500224660ustar00rootroot00000000000000import pytest from ruyi.log import RuyiLogger from ruyi.pluginhost.ctx import PluginHostContext from ..fixtures import RuyiFileFixtureFactory @pytest.mark.xfail( reason="unsandboxed backend does not support freezing yet", strict=True, ) def test_lang_frozen_values( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: with ruyi_file.plugin_suite("lang_tests") as plugin_root: phctx = PluginHostContext.new(ruyi_logger, plugin_root) # Should fail because in the test plugin we're trying to append to a # frozen list. with pytest.raises(RuntimeError): phctx.get_from_plugin("frozen_values", "val") ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_lint_module.py000066400000000000000000000111111520522431500240520ustar00rootroot00000000000000import ast import pytest from ruyi.pluginhost.unsandboxed import lint_module def _lint(src: str) -> None: lint_module(ast.parse(src)) def test_plain_module_passes() -> None: _lint( "x = 1\n" "def f(a, b):\n" " return a + b\n" "y = [f(i, 1) for i in range(10)]\n" ) def test_slice_in_value_position_passes() -> None: # Slicing on the RHS of an assignment, and as a subexpression on the # LHS but not as the outermost Subscript's slice, is legal Starlark. _lint("a = xs[1:3]\n") _lint("xs[ys[1:3]] = 0\n") def test_starred_in_call_and_rhs_passes() -> None: # ``*args`` / ``**kwargs`` in call arguments and starred elements on # the RHS of an assignment are valid Starlark and must not be gated. _lint("f(*args, **kwargs)\n") _lint("xs = [*a, *b]\n") @pytest.mark.parametrize( ("src", "expected_feature"), [ ("y = (x := 1)\n", "walrus"), ("raise ValueError('x')\n", "raise"), ("assert True\n", "assert"), ("import os\n", "import"), ("from os import path\n", "from"), ("try:\n pass\nexcept Exception:\n pass\n", "try"), ("with open('x') as f:\n pass\n", "with"), ( "match x:\n case 1:\n pass\n case _:\n pass\n", "match", ), ("def g():\n yield 1\n", "yield"), ("def g():\n yield from [1]\n", "yield from"), ("def g():\n global x\n", "global"), ( "def outer():\n" " x = 1\n" " def inner():\n" " nonlocal x\n" " return inner\n", "nonlocal", ), ("class C:\n pass\n", "class"), ("async def g():\n pass\n", "async def"), ("@staticmethod\ndef f():\n pass\n", "decorator"), ("x: int = 1\n", "variable type annotation"), ("def f() -> int:\n return 1\n", "return type annotation"), ("def f(x: int):\n return x\n", "parameter type annotation"), ("x = f'hello {name}'\n", "f-string"), ("x = {1, 2, 3}\n", "set display"), ("x = {i for i in range(3)}\n", "set comprehension"), ("x = sum(i for i in range(3))\n", "generator expression"), ("x = [1]\ndel x\n", "del"), ("x = a @ b\n", "matrix-multiplication operator"), ("a @= b\n", "matrix-multiplication assignment"), ("ok = 0 <= i < n\n", "chained comparison"), ("ok = a is b\n", "`is` operator"), ("ok = a is not b\n", "`is not` operator"), ("def f(a, /, b):\n return a + b\n", "positional-only parameter"), ("def f():\n while True:\n pass\n", "while"), ("xs[1:3] = [0]\n", "slice as assignment target"), ("xs[1:3] += [0]\n", "slice as assignment target"), ("(xs[1:3], y[0]) = (0, 1)\n", "slice as assignment target"), ("[xs[1:3], y[0]] = [0, 1]\n", "slice as assignment target"), ("a, *rest = xs\n", "starred assignment target"), ("for *a, b in xs:\n pass\n", "starred loop variable"), ("y = [x for *a, b in xs]\n", "starred loop variable"), ("y = {k: v for *a, (k, v) in xs}\n", "starred loop variable"), # `await`, `async for`, `async with` can only occur syntactically # inside an `async def`, so they are shadowed by the `async def` # rejection above; they have their own ``visit_*`` overrides anyway # for defence in depth. ], ) def test_gated_feature_is_rejected(src: str, expected_feature: str) -> None: with pytest.raises(RuntimeError) as excinfo: _lint(src) msg = str(excinfo.value) assert "is not allowed in plugin code" in msg assert expected_feature in msg assert "line " in msg @pytest.mark.parametrize( ("src", "expected_line", "expected_feature"), [ # Walrus on the third line of the module. ("x = 1\ny = 2\nz = (w := 3)\n", 3, "walrus"), # ``del`` on line 2. ("x = [1]\ndel x\n", 2, "del"), # Decorator declared on line 1, its function header on line 2; # the gate reports the decorator's own line. ("@staticmethod\ndef f():\n return 1\n", 1, "decorator"), # Nested gated construct: the inner ``while`` is on line 3. ("def f():\n x = 1\n while x:\n x = 0\n", 3, "while"), ], ) def test_gated_feature_reports_correct_line( src: str, expected_line: int, expected_feature: str ) -> None: with pytest.raises(RuntimeError) as excinfo: _lint(src) msg = str(excinfo.value) assert f"line {expected_line}:" in msg assert expected_feature in msg ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_paths.py000066400000000000000000000207671520522431500226770ustar00rootroot00000000000000import pathlib import pytest from ruyi.pluginhost.paths import resolve_ruyi_load_path @pytest.fixture def plugin_root(tmp_path: pathlib.Path) -> pathlib.Path: root = tmp_path / "plugins" (root / "alpha").mkdir(parents=True) (root / "alpha" / "mod.star").write_text("") (root / "alpha" / "sub.star").write_text("") (root / "alpha" / "data").mkdir() (root / "alpha" / "data" / "foo.toml").write_text("") (root / "beta").mkdir() (root / "beta" / "mod.star").write_text("") (root / "beta" / "data").mkdir() (root / "beta" / "data" / "bar.toml").write_text("") return root def test_plain_relative_within_plugin(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" resolved = resolve_ruyi_load_path( "sub.star", plugin_root, False, originating, False ) assert resolved == (plugin_root / "alpha" / "sub.star").resolve() def test_plain_absolute_resolves_against_plugin_root( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" resolved = resolve_ruyi_load_path( "/sub.star", plugin_root, False, originating, False ) assert resolved == plugin_root / "alpha" / "sub.star" def test_plain_cross_plugin_boundary_rejected(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(ValueError, match="cross plugin boundary"): resolve_ruyi_load_path( "../beta/mod.star", plugin_root, False, originating, False ) def test_ruyi_plugin_scheme_resolves_entrypoint(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" resolved = resolve_ruyi_load_path( "ruyi-plugin://beta", plugin_root, False, originating, False ) assert resolved == plugin_root / "beta" / "mod.star" def test_ruyi_plugin_scheme_rejects_data_context(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="ruyi-plugin protocol"): resolve_ruyi_load_path( "ruyi-plugin://beta", plugin_root, True, originating, False ) def test_ruyi_plugin_scheme_rejects_path_segment( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="non-empty path segment"): resolve_ruyi_load_path( "ruyi-plugin://beta/extra", plugin_root, False, originating, False ) def test_ruyi_plugin_scheme_rejects_empty_netloc( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="empty location"): resolve_ruyi_load_path("ruyi-plugin://", plugin_root, False, originating, False) def test_ruyi_plugin_data_scheme_resolves_under_data( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" resolved = resolve_ruyi_load_path( "ruyi-plugin-data://beta/bar.toml", plugin_root, True, originating, False, ) assert resolved == plugin_root / "beta" / "data" / "bar.toml" def test_ruyi_plugin_data_scheme_rejects_non_data_context( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="ruyi-plugin-data protocol"): resolve_ruyi_load_path( "ruyi-plugin-data://beta/bar.toml", plugin_root, False, originating, False, ) def test_host_scheme_requires_allow_host_fs_access( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="host protocol"): resolve_ruyi_load_path( "host:///etc/hostname", plugin_root, False, originating, False ) def test_host_scheme_returns_absolute_path_when_allowed( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" resolved = resolve_ruyi_load_path( "host:///etc/hostname", plugin_root, False, originating, True ) assert resolved == pathlib.Path("/etc/hostname") def test_fancy_uri_features_rejected(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="fancy URI features"): resolve_ruyi_load_path( "ruyi-plugin://beta?x=1", plugin_root, False, originating, False ) def test_unknown_scheme_rejected(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="unsupported Ruyi Starlark load path"): resolve_ruyi_load_path( "gopher://whatever", plugin_root, False, originating, False ) def test_double_slash_prefix_rejected(plugin_root: pathlib.Path) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="'//' is not allowed"): resolve_ruyi_load_path("//evil", plugin_root, False, originating, False) # --- ruyi-build:// / ruyi-build-data:// --------------------------------- @pytest.fixture def recipe_project(tmp_path: pathlib.Path) -> pathlib.Path: root = tmp_path / "recipes_proj" (root / "lib").mkdir(parents=True) (root / "lib" / "docker.star").write_text("") (root / "cfg.toml").write_text("") (root / "recipes").mkdir() (root / "recipes" / "pkg.star").write_text("") return root def test_ruyi_build_resolves_against_project_root( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" resolved = resolve_ruyi_load_path( "ruyi-build://lib/docker.star", plugin_root, False, originating, False, recipe_project_root=recipe_project, ) assert resolved == (recipe_project / "lib" / "docker.star").resolve() def test_ruyi_build_rejected_outside_recipe_context( plugin_root: pathlib.Path, ) -> None: originating = plugin_root / "alpha" / "mod.star" with pytest.raises(RuntimeError, match="only available when loading"): resolve_ruyi_load_path( "ruyi-build://lib/docker.star", plugin_root, False, originating, False, ) def test_ruyi_build_rejects_traversal( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" with pytest.raises(RuntimeError, match="escapes recipe project root"): resolve_ruyi_load_path( "ruyi-build://../../etc/passwd", plugin_root, False, originating, False, recipe_project_root=recipe_project, ) def test_ruyi_build_rejects_data_context( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" with pytest.raises(RuntimeError, match="ruyi-build protocol"): resolve_ruyi_load_path( "ruyi-build://cfg.toml", plugin_root, True, originating, False, recipe_project_root=recipe_project, ) def test_ruyi_build_data_resolves( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" resolved = resolve_ruyi_load_path( "ruyi-build-data://cfg.toml", plugin_root, True, originating, False, recipe_project_root=recipe_project, ) assert resolved == (recipe_project / "cfg.toml").resolve() def test_ruyi_build_data_rejects_non_data_context( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" with pytest.raises(RuntimeError, match="ruyi-build-data protocol"): resolve_ruyi_load_path( "ruyi-build-data://cfg.toml", plugin_root, False, originating, False, recipe_project_root=recipe_project, ) def test_ruyi_build_rejects_empty_path( plugin_root: pathlib.Path, recipe_project: pathlib.Path ) -> None: originating = recipe_project / "recipes" / "pkg.star" with pytest.raises(RuntimeError, match="empty path"): resolve_ruyi_load_path( "ruyi-build://", plugin_root, False, originating, False, recipe_project_root=recipe_project, ) ruyisdk-ruyi-1f00e2e/tests/pluginhost/test_recipe_build_ctx.py000066400000000000000000000130351520522431500250520ustar00rootroot00000000000000"""Tests for RecipeBuildCtx and plan records (B6).""" import pathlib import pytest from ruyi.pluginhost.build_api import ( Artifact, Invocation, RecipeBuildCtx, ) from ruyi.ruyipkg.recipe_project import RecipeProject def _make_project(tmp_path: pathlib.Path) -> RecipeProject: root = tmp_path.resolve() (root / "out").mkdir(exist_ok=True) return RecipeProject( root=root, name="test", output_dir=(root / "out").resolve(), extra_artifact_roots=((tmp_path / "scratch").resolve(),), ) def _make_ctx( tmp_path: pathlib.Path, user_vars: dict[str, str] | None = None, ) -> RecipeBuildCtx: (tmp_path / "scratch").mkdir(exist_ok=True) project = _make_project(tmp_path) recipe = project.root / "recipes" / "pkg.star" recipe.parent.mkdir(parents=True, exist_ok=True) recipe.write_text("") return RecipeBuildCtx( project=project, name="b1", recipe_file=recipe, user_vars=user_vars or {}, ) def test_ctx_identity_fields(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) assert ctx.name == "b1" assert ctx.recipe_file.endswith("pkg.star") assert ctx.repo_root == str(tmp_path.resolve()) def test_ctx_repo_path_safe_join(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) (tmp_path / "src").mkdir() assert ctx.repo_path("src") == str((tmp_path / "src").resolve()) def test_ctx_repo_path_traversal_rejected(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="escapes recipe project root"): ctx.repo_path("../evil") def test_ctx_var_returns_provided(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path, user_vars={"arch": "riscv64"}) assert ctx.var("arch") == "riscv64" def test_ctx_var_default(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) assert ctx.var("arch", default="amd64") == "amd64" def test_ctx_var_missing_no_default_errors(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="no value provided"): ctx.var("arch") def test_ctx_var_non_string_default_rejected(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="must be a string"): ctx.var("arch", default=42) def test_ctx_subprocess_returns_plan(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) inv = ctx.subprocess( argv=["/bin/true", "hello"], env={"FOO": "bar"}, ) assert isinstance(inv, Invocation) assert inv.argv == ("/bin/true", "hello") assert inv.cwd == tmp_path.resolve() assert inv.env == {"FOO": "bar"} assert inv.produces == () def test_ctx_subprocess_rejects_empty_argv(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="argv must be non-empty"): ctx.subprocess(argv=[]) def test_ctx_subprocess_rejects_non_string_argv(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="argv entries must be strings"): ctx.subprocess(argv=["/bin/true", 1]) # type: ignore[list-item] def test_ctx_subprocess_rejects_non_artifact_produces(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="produces entries must be Artifact"): ctx.subprocess(argv=["/bin/true"], produces=["foo.tar"]) # type: ignore[list-item] def test_ctx_subprocess_carries_produces(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) a = ctx.artifact("*.tar.zst") inv = ctx.subprocess(argv=["/bin/true"], produces=[a]) assert inv.produces == (a,) def test_ctx_artifact_defaults_to_output_dir(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) a = ctx.artifact("*.tar.zst") assert isinstance(a, Artifact) assert a.glob == "*.tar.zst" assert a.root == (tmp_path / "out").resolve() def test_ctx_artifact_relative_root_resolved_under_project( tmp_path: pathlib.Path, ) -> None: ctx = _make_ctx(tmp_path) (tmp_path / "dist").mkdir() a = ctx.artifact("*.tar.zst", root="dist") assert a.root == (tmp_path / "dist").resolve() def test_ctx_artifact_absolute_root_must_be_in_allowlist( tmp_path: pathlib.Path, ) -> None: ctx = _make_ctx(tmp_path) a = ctx.artifact("*.tar.zst", root=str(tmp_path / "scratch")) assert a.root == (tmp_path / "scratch").resolve() def test_ctx_artifact_absolute_root_outside_allowlist_rejected( tmp_path: pathlib.Path, ) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="not in extra_artifact_roots"): ctx.artifact("*.tar.zst", root="/etc") def test_ctx_artifact_absolute_root_subdir_of_allowlist_entry_accepted( tmp_path: pathlib.Path, ) -> None: # extra_artifact_roots acts as a subtree allow-list per the design # doc: subdirectories of an allowed root should be accepted. ctx = _make_ctx(tmp_path) sub = tmp_path / "scratch" / "sub" sub.mkdir(parents=True) a = ctx.artifact("*.tar.zst", root=str(sub)) assert a.root == sub.resolve() def test_ctx_artifact_relative_traversal_rejected(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="escapes recipe project root"): ctx.artifact("*.tar.zst", root="../evil") def test_ctx_artifact_empty_glob_rejected(tmp_path: pathlib.Path) -> None: ctx = _make_ctx(tmp_path) with pytest.raises(RuntimeError, match="glob must be a non-empty"): ctx.artifact("") ruyisdk-ruyi-1f00e2e/tests/rit-suites/000077500000000000000000000000001520522431500200475ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/rit-suites/ruyi-gha.yaml000066400000000000000000000025531520522431500224650ustar00rootroot00000000000000# NOTE: This file is adapted from ruyi-litester/suites/ruyi.yaml, to account # for the unique needs of GitHub Actions and PR checks. # # Main change points: # # * The default (first) test suite name is renamed to "ruyi-gha" from "ruyi". # * Removed `ruyi/ruyi-bin-{install,remove}` pre- and post-actions because # we are the upstream. # * Removed the `ruyi-bin` variant, same reason as above. # # Please keep the changes in sync when you bump the ruyi-litester submodule. ruyi-gha: # originally "ruyi" cases: # testcases list - ruyi-help - ruyi-basic - ruyi-advance - ruyi-mugen pre: # each pre script should have a corresponding post script # or set it to _ - ["ruyi/ruyi-src-install", ] # "ruyi/ruyi-bin-install" removed post: - ["ruyi/ruyi-src-remove", ] # "ruyi/ruyi-bin-remove" removed ruyi-local: cases: - ruyi-help - ruyi-basic - ruyi-advance - ruyi-mugen pre: - ["_", ] post: - ["_", ] ruyi-src: cases: - ruyi-help - ruyi-basic - ruyi-advance - ruyi-mugen pre: - ["ruyi/ruyi-src-install", ] post: - ["ruyi/ruyi-src-remove", ] # ruyi-bin: removed for upstream ruyi-i18n: cases: - ruyi-i18n pre: - ["i18n/setup-zh-locale", "i18n/setup-en-locale",] - ["ruyi/ruyi-src-install", ] post: - ["i18n/setup-en-locale", _] - ["ruyi/ruyi-src-remove", ] ruyisdk-ruyi-1f00e2e/tests/ruyi-litester/000077500000000000000000000000001520522431500205605ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/ruyipkg/000077500000000000000000000000001520522431500174315ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/ruyipkg/__init__.py000066400000000000000000000000001520522431500215300ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_build_runner.py000066400000000000000000000227231520522431500235400ustar00rootroot00000000000000"""Tests for the recipe build runner (B7).""" from __future__ import annotations import pathlib import pytest from ruyi.log import RuyiLogger from ruyi.ruyipkg.build_runner import ( BuildFailure, format_build_report, run_recipe, ) def _make_project(tmp_path: pathlib.Path, recipe_body: str) -> pathlib.Path: (tmp_path / "ruyi-build-recipes.toml").write_text( 'format = "v1"\n[project]\nname = "testproj"\n' ) (tmp_path / "out").mkdir(exist_ok=True) recipe_dir = tmp_path / "recipes" recipe_dir.mkdir(exist_ok=True) recipe = recipe_dir / "pkg.star" recipe.write_text(recipe_body) return recipe def test_run_recipe_dry_run(tmp_path: pathlib.Path, ruyi_logger: RuyiLogger) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/true', 'hi'])\n" "\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe(ruyi_logger, recipe, dry_run=True) assert len(reports) == 1 r = reports[0] assert r.build_name == "build_it" assert r.invocations[0].argv == ("/bin/true", "hi") assert r.exit_code == 0 assert r.artifacts == () def test_run_recipe_no_scheduled_builds_errors( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project(tmp_path, "RUYI = ruyi_plugin_rev(1)\n") with pytest.raises(RuntimeError, match="scheduled no builds"): run_recipe(ruyi_logger, recipe, dry_run=True) def test_run_recipe_name_filter_selects( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_a(ctx):\n" " return ctx.subprocess(argv = ['/bin/true', 'a'])\n" "def build_b(ctx):\n" " return ctx.subprocess(argv = ['/bin/true', 'b'])\n" "\n" "RUYI.build.schedule_build(build_a)\n" "RUYI.build.schedule_build(build_b)\n", ) reports = run_recipe(ruyi_logger, recipe, dry_run=True, selected_names=["build_b"]) assert [r.build_name for r in reports] == ["build_b"] def test_run_recipe_name_filter_missing_errors( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_a(ctx):\n" " return ctx.subprocess(argv = ['/bin/true'])\n" "RUYI.build.schedule_build(build_a)\n", ) with pytest.raises(RuntimeError, match="does not define the requested"): run_recipe(ruyi_logger, recipe, dry_run=True, selected_names=["nope"]) def test_run_recipe_user_vars(tmp_path: pathlib.Path, ruyi_logger: RuyiLogger) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " arch = ctx.var('arch')\n" " return ctx.subprocess(argv = ['/bin/echo', arch])\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe( ruyi_logger, recipe, dry_run=True, user_vars={"arch": "riscv64"}, ) assert reports[0].invocations[0].argv == ("/bin/echo", "riscv64") def test_run_recipe_executes_and_collects_artifacts( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: out = tmp_path / "out" out.mkdir(exist_ok=True) artifact = out / "pkg-1.0.tar.zst" artifact.write_bytes(b"dummy") recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(\n" " argv = ['/bin/true'],\n" " produces = [ctx.artifact(glob = 'pkg-*.tar.zst')],\n" " )\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe(ruyi_logger, recipe) assert len(reports) == 1 r = reports[0] assert r.exit_code == 0 assert len(r.artifacts) == 1 ar = r.artifacts[0] assert ar.path == artifact assert ar.size == 5 assert len(ar.checksums["sha256"]) == 64 assert len(ar.checksums["sha512"]) == 128 def test_run_recipe_missing_artifact_fails( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(\n" " argv = ['/bin/true'],\n" " produces = [ctx.artifact(glob = 'nope-*.tar')],\n" " )\n" "RUYI.build.schedule_build(build_it)\n", ) with pytest.raises(RuntimeError, match="matched no files"): run_recipe(ruyi_logger, recipe) def test_run_recipe_non_zero_exit_raises_build_failure( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/false'])\n" "RUYI.build.schedule_build(build_it)\n", ) with pytest.raises(BuildFailure) as excinfo: run_recipe(ruyi_logger, recipe) assert excinfo.value.exit_code != 0 assert excinfo.value.build_name == "build_it" def test_run_recipe_supports_ruyi_build_load( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: (tmp_path / "ruyi-build-recipes.toml").write_text('format = "v1"\n') (tmp_path / "out").mkdir(exist_ok=True) lib = tmp_path / "lib" lib.mkdir() (lib / "common.star").write_text("GREETING = 'hi from lib'\n") recipe = tmp_path / "recipes" / "pkg.star" recipe.parent.mkdir() recipe.write_text( "RUYI = ruyi_plugin_rev(1)\n" "load('ruyi-build://lib/common.star', 'GREETING')\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/echo', GREETING])\n" "RUYI.build.schedule_build(build_it)\n" ) reports = run_recipe(ruyi_logger, recipe, dry_run=True) assert reports[0].invocations[0].argv == ("/bin/echo", "hi from lib") def test_format_build_report_is_reasonable( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(argv = ['/bin/true'])\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe(ruyi_logger, recipe, dry_run=True) text = format_build_report(reports[0]) assert 'build_name = "build_it"' in text assert "[[invocations]]" in text def test_format_build_report_includes_artifacts( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: out = tmp_path / "out" out.mkdir(exist_ok=True) (out / "pkg-1.0.tar.zst").write_bytes(b"dummy") recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(\n" " argv = ['/bin/true'],\n" " produces = [ctx.artifact(glob = 'pkg-*.tar.zst')],\n" " )\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe(ruyi_logger, recipe) text = format_build_report(reports[0]) ar = reports[0].artifacts[0] assert "[[artifacts]]" in text assert f'path = "{ar.path}"' in text assert f"size = {ar.size}" in text assert f'sha256 = "{ar.checksums["sha256"]}"' in text assert f'sha512 = "{ar.checksums["sha512"]}"' in text def test_run_recipe_rejects_non_invocation_return( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return 'not-an-invocation'\n" "RUYI.build.schedule_build(build_it)\n", ) with pytest.raises(RuntimeError, match="expected Invocation"): run_recipe(ruyi_logger, recipe, dry_run=True) def test_run_recipe_rejects_list_with_non_invocation( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return [ctx.subprocess(argv = ['/bin/true']), 42]\n" "RUYI.build.schedule_build(build_it)\n", ) with pytest.raises(RuntimeError, match="expected Invocation"): run_recipe(ruyi_logger, recipe, dry_run=True) def test_run_recipe_rejects_empty_list( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return []\n" "RUYI.build.schedule_build(build_it)\n", ) with pytest.raises(RuntimeError, match="empty list"): run_recipe(ruyi_logger, recipe, dry_run=True) def test_run_recipe_output_dir_override( tmp_path: pathlib.Path, ruyi_logger: RuyiLogger ) -> None: override_out = tmp_path / "override_out" override_out.mkdir() (override_out / "overridden.tar.zst").write_bytes(b"ok") recipe = _make_project( tmp_path, "RUYI = ruyi_plugin_rev(1)\n" "def build_it(ctx):\n" " return ctx.subprocess(\n" " argv = ['/bin/true'],\n" " produces = [ctx.artifact(glob = '*.tar.zst')],\n" " )\n" "RUYI.build.schedule_build(build_it)\n", ) reports = run_recipe(ruyi_logger, recipe, output_dir_override=override_out) assert len(reports) == 1 assert len(reports[0].artifacts) == 1 assert ( reports[0].artifacts[0].path == (override_out / "overridden.tar.zst").resolve() ) ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_check.py000066400000000000000000000132001520522431500221130ustar00rootroot00000000000000import pathlib from ruyi.ruyipkg.check import ( check_manifest_file, check_repo, infer_manifest_repo_context, parse_package_selector_args, ) SHA_STUB = "0" * 64 CANONICAL_MANIFEST = f"""format = "v1" [metadata] desc = "Test package" vendor = {{ name = "Test Vendor", eula = "" }} [[distfiles]] name = "src.tar.zst" size = 0 [distfiles.checksums] sha256 = "{SHA_STUB}" [source] distfiles = ["src.tar.zst"] """ NON_CANONICAL_MANIFEST = f"""format = "v1" [[distfiles]] size = 0 name = "src.tar.zst" [distfiles.checksums] sha256 = "{SHA_STUB}" [metadata] vendor = {{ eula = "", name = "Test Vendor" }} desc = "Test package" [source] distfiles = ["src.tar.zst"] """ VALID_REPO_CONFIG = """ruyi-repo = "v1" [[mirrors]] id = "ruyi-dist" urls = ["https://example.invalid/dist/"] """ def _write_manifest( root: pathlib.Path, category: str, name: str, version: str, text: str = CANONICAL_MANIFEST, ) -> pathlib.Path: manifest_dir = root / "packages" / category / name manifest_dir.mkdir(parents=True, exist_ok=True) manifest_path = manifest_dir / f"{version}.toml" manifest_path.write_text(text, encoding="utf-8") return manifest_path def _write_repo_config(root: pathlib.Path, text: str = VALID_REPO_CONFIG) -> None: root.mkdir(parents=True, exist_ok=True) (root / "config.toml").write_text(text, encoding="utf-8") def test_clean_canonical_manifest_has_no_diagnostics( tmp_path: pathlib.Path, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(CANONICAL_MANIFEST, encoding="utf-8") assert check_manifest_file(manifest_path) == [] def test_non_canonical_manifest_reports_format_diagnostic( tmp_path: pathlib.Path, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text(NON_CANONICAL_MANIFEST, encoding="utf-8") diagnostics = check_manifest_file(manifest_path) assert [diag.code for diag in diagnostics] == ["RYC0001"] assert diagnostics[0].hint == f"run: ruyi admin format-manifest {manifest_path}" def test_malformed_toml_reports_parse_position(tmp_path: pathlib.Path) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text('format = "v1"\n[metadata\n', encoding="utf-8") diagnostics = check_manifest_file(manifest_path) assert [diag.code for diag in diagnostics] == ["RYC0002"] assert diagnostics[0].line == 2 assert diagnostics[0].column is not None def test_unknown_manifest_format_reports_invalid_manifest( tmp_path: pathlib.Path, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text( CANONICAL_MANIFEST.replace('format = "v1"', 'format = "v2"'), encoding="utf-8", ) diagnostics = check_manifest_file(manifest_path) assert [diag.code for diag in diagnostics] == ["RYC0003"] def test_missing_required_fields_are_reported_without_traceback( tmp_path: pathlib.Path, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text( f"""format = "v1" [[distfiles]] name = "src.tar.zst" size = 0 [distfiles.checksums] sha256 = "{SHA_STUB}" """, encoding="utf-8", ) diagnostics = check_manifest_file(manifest_path) assert [diag.code for diag in diagnostics] == ["RYC0003"] assert "metadata" in diagnostics[0].message def test_accessor_triggered_malformed_data_is_reported( tmp_path: pathlib.Path, ) -> None: manifest_path = tmp_path / "1.0.0.toml" manifest_path.write_text( f"""format = "v1" [metadata] desc = "Test package" vendor = {{ name = "Test Vendor", eula = "" }} [[distfiles]] size = 0 [distfiles.checksums] sha256 = "{SHA_STUB}" """, encoding="utf-8", ) diagnostics = check_manifest_file(manifest_path) assert [diag.code for diag in diagnostics] == ["RYC0003"] assert "name" in diagnostics[0].message def test_repo_mode_reports_invalid_semver_filenames( tmp_path: pathlib.Path, ) -> None: _write_repo_config(tmp_path) _write_manifest(tmp_path, "source", "sample", "not-semver") diagnostics = check_repo(tmp_path) assert [diag.code for diag in diagnostics] == ["RYC0004"] def test_repo_mode_continues_after_bad_files(tmp_path: pathlib.Path) -> None: _write_repo_config(tmp_path) _write_manifest(tmp_path, "source", "bad-toml", "1.0.0", 'format = "v1"\n[') _write_manifest(tmp_path, "source", "bad-version", "not-semver") diagnostics = check_repo(tmp_path) assert sorted(diag.code for diag in diagnostics) == ["RYC0002", "RYC0004"] def test_file_mode_infers_repo_context(tmp_path: pathlib.Path) -> None: manifest_path = _write_manifest( tmp_path, "board-image", "example-board", "1.2.3", ) context = infer_manifest_repo_context(manifest_path) assert context is not None assert context.repo_root == tmp_path assert context.manifest_root_name == "packages" assert context.category == "board-image" assert context.name == "example-board" assert context.version == "1.2.3" def test_only_packages_limits_package_diagnostics_but_checks_repo_config( tmp_path: pathlib.Path, ) -> None: _write_repo_config(tmp_path, 'ruyi-repo = "v1"\n') _write_manifest(tmp_path, "source", "ignored", "1.0.0", 'format = "v1"\n[') board_manifest = _write_manifest( tmp_path, "board-image", "selected", "1.0.0", NON_CANONICAL_MANIFEST, ) selector = parse_package_selector_args(["--category-is", "board-image"]) diagnostics = check_repo(tmp_path, package_selector=selector) assert [diag.code for diag in diagnostics] == ["RYC0005", "RYC0001"] assert diagnostics[1].path == board_manifest ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_checksum.py000066400000000000000000000030341520522431500226440ustar00rootroot00000000000000import io import hashlib import pytest from ruyi.ruyipkg.checksum import get_hash_instance, Checksummer def test_get_hash_instance_supported() -> None: # these should not raise any exception get_hash_instance("sha256") get_hash_instance("sha512") def test_get_hash_instance_unsupported() -> None: with pytest.raises(ValueError, match="checksum algorithm md5 not supported"): get_hash_instance("md5") def test_checksummer_compute() -> None: file_content = b"test content" expected_sha256 = hashlib.sha256(file_content).hexdigest() expected_sha512 = hashlib.sha512(file_content).hexdigest() file = io.BytesIO(file_content) checksums = {"sha256": expected_sha256, "sha512": expected_sha512} checksummer = Checksummer(file, checksums) computed_checksums = checksummer.compute() assert computed_checksums["sha256"] == expected_sha256 def test_checksummer_check() -> None: file_content = b"test content" expected_sha256 = hashlib.sha256(file_content).hexdigest() expected_sha512 = hashlib.sha512(file_content).hexdigest() file = io.BytesIO(file_content) checksums = {"sha256": expected_sha256, "sha512": expected_sha512} checksummer = Checksummer(file, checksums) # This should not raise any exception checksummer.check() # Modify the file content to cause a checksum mismatch file = io.BytesIO(b"modified content") checksummer = Checksummer(file, checksums) with pytest.raises(ValueError, match="wrong sha256 checksum"): checksummer.check() ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_composite_repo.py000066400000000000000000000367311520522431500241030ustar00rootroot00000000000000import pathlib from typing import TYPE_CHECKING import pygit2 from ruyi.ruyipkg.composite_repo import CompositeRepo from ruyi.ruyipkg.repo import RepoEntry if TYPE_CHECKING: from tests.fixtures import MockGlobalModeProvider from ruyi.log import RuyiLogger def _make_entry( repo_id: str = "test-repo", priority: int = 0, active: bool = True, local_path: str | None = None, ) -> RepoEntry: return RepoEntry( id=repo_id, name=f"Test Repo {repo_id}", remote="https://example.invalid/repo.git", branch="main", local_path=local_path, priority=priority, active=active, ) def _init_repo_dir(root: pathlib.Path) -> None: """Initialize a bare-minimum git repo at root for MetadataRepo.""" root.mkdir(parents=True, exist_ok=True) pygit2.init_repository(str(root)) def _write_manifest( root: pathlib.Path, category: str, name: str, ver: str, *, slug: str | None = None, desc: str = "test package", ) -> None: """Write a minimal package manifest TOML file into a repo tree.""" pkg_dir = root / "packages" / category / name pkg_dir.mkdir(parents=True, exist_ok=True) lines = [ 'format = "v1"', 'kind = ["source"]', "distfiles = []", "", "[metadata]", f'desc = "{desc}"', ] if slug is not None: lines.append(f'slug = "{slug}"') lines.extend( [ "", "[metadata.vendor]", 'name = "test-vendor"', ] ) (pkg_dir / f"{ver}.toml").write_text("\n".join(lines) + "\n") def _write_profile_plugin( root: pathlib.Path, arch: str, profile_ids: list[str], *, quirks_by_profile: dict[str, list[str]] | None = None, ) -> None: plugin_dir = root / "plugins" / f"ruyi-profile-{arch}" plugin_dir.mkdir(parents=True, exist_ok=True) plugin_text = "\n".join( [ "def list_all_profile_ids_v1():", f" return {profile_ids!r}", "", "def list_needed_quirks_v1(profile_id):", f" return {quirks_by_profile or {}!r}.get(profile_id, [])", "", ] ) (plugin_dir / "mod.star").write_text(plugin_text, encoding="utf-8") class TestCompositeRepoSingleEntry: def test_iter_repos_single( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) entry = _make_entry(local_path=str(tmp_path)) composite = CompositeRepo([entry], gc) repos = list(composite.iter_repos()) assert len(repos) == 1 assert repos[0].repo_id == "test-repo" def test_inactive_entries_excluded( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) active_entry = _make_entry(repo_id="active", local_path=str(tmp_path)) inactive_entry = _make_entry( repo_id="inactive", active=False, local_path=str(tmp_path / "other") ) composite = CompositeRepo([active_entry, inactive_entry], gc) repos = list(composite.iter_repos()) assert len(repos) == 1 assert repos[0].repo_id == "active" def test_repo_id_returns_highest_priority( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) entry = _make_entry(repo_id="my-repo", priority=50, local_path=str(tmp_path)) composite = CompositeRepo([entry], gc) assert composite.repo_id == "my-repo" def test_entries_sorted_by_priority( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) low = _make_entry(repo_id="low", priority=0, local_path=str(tmp_path / "low")) high = _make_entry( repo_id="high", priority=100, local_path=str(tmp_path / "high") ) composite = CompositeRepo([high, low], gc) repos = list(composite.iter_repos()) assert len(repos) == 2 # Should be sorted ascending by priority assert repos[0].repo_id == "low" assert repos[1].repo_id == "high" class TestCompositeRepoMultiEntryMerge: """Tests for multi-repo merge, deduplication, and priority shadowing.""" def _make_composite( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> CompositeRepo: """Set up two repos with overlapping and unique packages. base (priority=0): toolchain/gcc 13.1.0 (slug=gcc-13) toolchain/gcc 13.2.0 toolchain/llvm 17.0.0 overlay (priority=100): toolchain/gcc 13.2.0 (shadows base, different desc) toolchain/gcc 14.0.0 (unique to overlay) source/some-lib 1.0.0 (unique category) """ from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) base_root = tmp_path / "base" _init_repo_dir(base_root) _write_manifest(base_root, "toolchain", "gcc", "13.1.0", slug="gcc-13") _write_manifest(base_root, "toolchain", "gcc", "13.2.0", desc="base gcc 13.2") _write_manifest(base_root, "toolchain", "llvm", "17.0.0") overlay_root = tmp_path / "overlay" _init_repo_dir(overlay_root) _write_manifest( overlay_root, "toolchain", "gcc", "13.2.0", desc="overlay gcc 13.2" ) _write_manifest(overlay_root, "toolchain", "gcc", "14.0.0") _write_manifest(overlay_root, "source", "some-lib", "1.0.0", slug="some-lib") base_entry = _make_entry(repo_id="base", priority=0, local_path=str(base_root)) overlay_entry = _make_entry( repo_id="overlay", priority=100, local_path=str(overlay_root) ) return CompositeRepo([overlay_entry, base_entry], gc) def test_iter_pkgs_merges_versions( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) pkgs = {(cat, name): vers for cat, name, vers in composite.iter_pkgs()} # gcc should have 3 versions merged assert ("toolchain", "gcc") in pkgs gcc_vers = pkgs[("toolchain", "gcc")] assert set(gcc_vers.keys()) == {"13.1.0", "13.2.0", "14.0.0"} # llvm from base only assert ("toolchain", "llvm") in pkgs assert set(pkgs[("toolchain", "llvm")].keys()) == {"17.0.0"} # some-lib from overlay only assert ("source", "some-lib") in pkgs def test_priority_shadowing_same_version( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: """When base and overlay both have gcc 13.2.0, overlay wins.""" composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) pm = composite.get_pkg("gcc", "toolchain", "13.2.0") assert pm is not None # The overlay version should shadow the base. assert pm.repo_id == "overlay" assert pm.desc == "overlay gcc 13.2" def test_get_pkg_from_base_only( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) pm = composite.get_pkg("gcc", "toolchain", "13.1.0") assert pm is not None assert pm.repo_id == "base" def test_get_pkg_nonexistent( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) assert composite.get_pkg("nonexistent", "toolchain", "1.0.0") is None def test_get_pkg_latest_ver( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) latest = composite.get_pkg_latest_ver("gcc", "toolchain") assert latest.ver == "14.0.0" assert latest.repo_id == "overlay" def test_get_pkg_latest_ver_across_repos( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: """get_pkg_latest_ver without category uses merged by-name index.""" composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) latest = composite.get_pkg_latest_ver("gcc") assert latest.ver == "14.0.0" def test_get_pkg_by_slug( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) # Slug from base repo pm = composite.get_pkg_by_slug("gcc-13") assert pm is not None assert pm.name == "gcc" # Slug from overlay repo pm = composite.get_pkg_by_slug("some-lib") assert pm is not None assert pm.name == "some-lib" assert composite.get_pkg_by_slug("nonexistent") is None def test_iter_pkg_vers_by_category( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) vers = list(composite.iter_pkg_vers("gcc", "toolchain")) ver_strs = {pm.ver for pm in vers} assert ver_strs == {"13.1.0", "13.2.0", "14.0.0"} def test_iter_pkg_vers_by_name_only( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) vers = list(composite.iter_pkg_vers("llvm")) assert len(vers) == 1 assert vers[0].ver == "17.0.0" def test_iter_pkg_manifests_deduplicates( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: """iter_pkg_manifests should yield deduplicated manifests.""" composite = self._make_composite(tmp_path, mock_gm, ruyi_logger) manifests = list(composite.iter_pkg_manifests()) # Total unique (category, name, ver) tuples: # gcc: 13.1.0, 13.2.0, 14.0.0 = 3 # llvm: 17.0.0 = 1 # some-lib: 1.0.0 = 1 # Total = 5 keys = [(pm.category, pm.name, pm.ver) for pm in manifests] assert len(keys) == 5 assert len(set(keys)) == 5 # all unique def test_slug_shadowing( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: """When two repos define the same slug, higher priority wins.""" from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) base_root = tmp_path / "slug-base" _init_repo_dir(base_root) _write_manifest( base_root, "toolchain", "pkg-a", "1.0.0", slug="shared-slug", desc="from base", ) overlay_root = tmp_path / "slug-overlay" _init_repo_dir(overlay_root) _write_manifest( overlay_root, "toolchain", "pkg-b", "2.0.0", slug="shared-slug", desc="from overlay", ) base_entry = _make_entry(repo_id="base", priority=0, local_path=str(base_root)) overlay_entry = _make_entry( repo_id="overlay", priority=100, local_path=str(overlay_root) ) composite = CompositeRepo([base_entry, overlay_entry], gc) pm = composite.get_pkg_by_slug("shared-slug") assert pm is not None assert pm.desc == "from overlay" assert pm.repo_id == "overlay" def test_profile_queries_skip_broken_repo( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) base_root = tmp_path / "profile-base" _init_repo_dir(base_root) _write_profile_plugin( base_root, "riscv64", ["milkv-duo"], quirks_by_profile={"milkv-duo": ["xthead"]}, ) broken_root = tmp_path / "profile-broken" _init_repo_dir(broken_root) (broken_root / "plugins" / "ruyi-profile-riscv64").mkdir( parents=True, exist_ok=True, ) base_entry = _make_entry(repo_id="base", priority=0, local_path=str(base_root)) broken_entry = _make_entry( repo_id="broken", priority=100, local_path=str(broken_root), ) composite = CompositeRepo([broken_entry, base_entry], gc) assert composite.get_supported_arches() == ["riscv64"] profiles = list(composite.iter_profiles_for_arch("riscv64")) assert [p.id for p in profiles] == ["milkv-duo"] assert profiles[0].need_quirks == {"xthead"} profile = composite.get_profile("milkv-duo") assert profile is not None assert profile.id == "milkv-duo" arch_profile = composite.get_profile_for_arch("riscv64", "milkv-duo") assert arch_profile is not None assert arch_profile.id == "milkv-duo" def test_profile_plugin_first_valid_repo_wins_per_arch( self, tmp_path: pathlib.Path, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) low_root = tmp_path / "profile-low" _init_repo_dir(low_root) _write_profile_plugin( low_root, "riscv64", ["low-profile"], ) high_root = tmp_path / "profile-high" _init_repo_dir(high_root) _write_profile_plugin( high_root, "riscv64", ["high-profile"], quirks_by_profile={"high-profile": ["xthead"]}, ) low_entry = _make_entry(repo_id="low", priority=0, local_path=str(low_root)) high_entry = _make_entry( repo_id="high", priority=100, local_path=str(high_root), ) composite = CompositeRepo([high_entry, low_entry], gc) assert composite.get_supported_arches() == ["riscv64"] profiles = list(composite.iter_profiles_for_arch("riscv64")) assert [p.id for p in profiles] == ["high-profile"] assert profiles[0].need_quirks == {"xthead"} profile = composite.get_profile("high-profile") assert profile is not None assert profile.id == "high-profile" assert composite.get_profile("low-profile") is None assert composite.get_profile_for_arch("riscv64", "low-profile") is None ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_entity.py000066400000000000000000000307441520522431500223660ustar00rootroot00000000000000import pytest from ruyi.log import RuyiLogger from ruyi.ruyipkg.entity import EntityStore from ruyi.ruyipkg.entity_provider import FSEntityProvider from ..fixtures import RuyiFileFixtureFactory def test_entity_store_discovery( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test that EntityStore correctly discovers entity types.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) entity_types = set(store.get_entity_types()) assert "arch" in entity_types assert "cpu" in entity_types assert "device" in entity_types assert "uarch" in entity_types assert len(entity_types) == 4 def test_entity_store_get_entity( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test retrieving entities by type and ID.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Test valid entity retrieval cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None assert cpu.entity_type == "cpu" assert cpu.id == "xiangshan-nanhu" assert cpu.display_name is not None # Test non-existent entity nonexistent = store.get_entity("cpu", "nonexistent") assert nonexistent is None def test_entity_store_iter_entities( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test iterating over entities.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Test iterating over a specific type cpus = list(store.iter_entities("cpu")) assert len(cpus) >= 2 # At least xiangshan-nanhu and xuantie-th1520 # Test iterating over all entities all_entities = list(store.iter_entities(None)) assert len(all_entities) >= 6 # Total number of entities in the fixture def test_entity_store_get_entity_by_ref( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test retrieving entities by reference string.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Test valid reference cpu = store.get_entity_by_ref("cpu:xiangshan-nanhu") assert cpu is not None assert cpu.entity_type == "cpu" assert cpu.id == "xiangshan-nanhu" # Test invalid reference format with pytest.raises(ValueError): store.get_entity_by_ref("invalid_reference") def test_entity_validation( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test entity validation against schemas.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Force validation by explicitly loading store.load_all(validate=True) # All entities in the fixture should be valid and loaded successfully cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None device = store.get_entity("device", "sipeed-lpi4a") assert device is not None def test_entity_related_refs( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test retrieving related entity references from an entity.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Test entity with related entities cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None assert isinstance(cpu.related_refs, list) assert "uarch:xiangshan-nanhu" in cpu.related_refs # Test device with related entities device = store.get_entity("device", "sipeed-lpi4a") assert device is not None assert isinstance(device.related_refs, list) assert "cpu:xuantie-th1520" in device.related_refs def test_get_related_entities( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test retrieving related entities from an entity.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Test CPU entity with a related uarch entity cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None related_entities = store.list_related_entities(cpu) assert len(related_entities) == 1 assert related_entities[0].entity_type == "uarch" assert related_entities[0].id == "xiangshan-nanhu" # Test device entity with a related CPU entity device = store.get_entity("device", "sipeed-lpi4a") assert device is not None related_entities = store.list_related_entities(device) assert len(related_entities) == 1 assert related_entities[0].entity_type == "cpu" assert related_entities[0].id == "xuantie-th1520" def test_traverse_related_entities_direct( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test traversing directly related entities.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Start from a device entity device = store.get_entity("device", "sipeed-lpi4a") assert device is not None # Get direct related entities (transitive=False) related = list(store.traverse_related_entities(device, transitive=False)) # Should only include the directly related CPU entity assert len(related) == 1 assert related[0].entity_type == "cpu" assert related[0].id == "xuantie-th1520" def test_traverse_related_entities_transitive( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test traversing the transitive closure of related entities.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Start from a device entity device = store.get_entity("device", "sipeed-lpi4a") assert device is not None # Get transitive related entities (transitive=True) related = list(store.traverse_related_entities(device, transitive=True)) # Should include: # 1. The directly related CPU entity # 2. Any entities related to that CPU entity assert len(related) >= 1 # Check that the CPU is included cpu_entities = [e for e in related if e.entity_type == "cpu"] assert len(cpu_entities) >= 1 assert any(e.id == "xuantie-th1520" for e in cpu_entities) # If the CPU has related entities, they should also be included # Get the CPU entity to check its relationships cpu = store.get_entity("cpu", "xuantie-th1520") if cpu and cpu.related_refs: for ref in cpu.related_refs: entity_type, entity_id = ref.split(":", 1) assert any( e.entity_type == entity_type and e.id == entity_id for e in related ) def test_traverse_related_entities_with_type_filter( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test traversing related entities with filtering by entity type.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Start from a device entity reference ref = "device:sipeed-lpi4a" # Only get entities of type "cpu" cpu_entities = list( store.traverse_related_entities( ref, transitive=True, entity_types=["cpu"], ) ) # Should only include CPU entities assert all(e.entity_type == "cpu" for e in cpu_entities) assert any(e.id == "xuantie-th1520" for e in cpu_entities) # Only get entities of type "uarch" uarch_entities = list( store.traverse_related_entities( ref, transitive=True, entity_types=["uarch"], ) ) # Should only include uarch entities assert all(e.entity_type == "uarch" for e in uarch_entities) # Test with multiple entity types mixed_entities = list( store.traverse_related_entities( ref, transitive=True, entity_types=["cpu", "uarch"], ) ) # Should only include entities of the specified types assert all(e.entity_type in ["cpu", "uarch"] for e in mixed_entities) assert not any(e.entity_type == "device" for e in mixed_entities) def test_entity_store_is_entity_related_to( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test the ``is_related_to`` method of ``EntityStore``.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) assert store.is_entity_related_to( "cpu:xiangshan-nanhu", "uarch:xiangshan-nanhu", ) assert store.is_entity_related_to( "uarch:xiangshan-nanhu", "cpu:xiangshan-nanhu", ) assert not store.is_entity_related_to( "cpu:xiangshan-nanhu", "uarch:xuantie-c910", ) assert not store.is_entity_related_to( "uarch:xuantie-c910", "cpu:xiangshan-nanhu", ) assert not store.is_entity_related_to( "cpu:xiangshan-nanhu", "uarch:nonexistent", ) assert not store.is_entity_related_to( "uarch:nonexistent", "cpu:xiangshan-nanhu", ) assert not store.is_entity_related_to("device:sipeed-lpi4a", "arch:riscv64") assert store.is_entity_related_to( "device:sipeed-lpi4a", "arch:riscv64", transitive=True, ) assert store.is_entity_related_to( "arch:riscv64", "device:sipeed-lpi4a", transitive=True, ) assert not store.is_entity_related_to( "uarch:xiangshan-nanhu", "device:sipeed-lpi4a", transitive=True, ) assert not store.is_entity_related_to( "device:sipeed-lpi4a", "uarch:xiangshan-nanhu", transitive=True, ) assert store.is_entity_related_to( "device:sipeed-lpi4a", "uarch:xiangshan-nanhu", unidirectional=False, transitive=True, ) assert store.is_entity_related_to( "uarch:xiangshan-nanhu", "device:sipeed-lpi4a", unidirectional=False, transitive=True, ) def test_entity_porcelain_output( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test that entity porcelain output works correctly.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: store = EntityStore(ruyi_logger, FSEntityProvider(ruyi_logger, entities_path)) # Get a test entity cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None # Test porcelain output porcelain_output = cpu.to_porcelain() # Verify the structure assert porcelain_output["ty"] == "entitylistoutput-v1" assert porcelain_output["entity_type"] == "cpu" assert porcelain_output["entity_id"] == "xiangshan-nanhu" assert porcelain_output["display_name"] is not None assert "data" in porcelain_output assert "related_refs" in porcelain_output assert "reverse_refs" in porcelain_output # Check that related_refs contains expected references assert isinstance(porcelain_output["related_refs"], list) assert "uarch:xiangshan-nanhu" in porcelain_output["related_refs"] ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_entity_providers.py000066400000000000000000000050401520522431500244520ustar00rootroot00000000000000from typing import Any, Mapping, Sequence from ruyi.log import RuyiLogger from ruyi.ruyipkg.entity_provider import BaseEntityProvider, FSEntityProvider from ruyi.ruyipkg.entity import EntityStore from ..fixtures import RuyiFileFixtureFactory class MockEntityProvider(BaseEntityProvider): """A mock entity provider for testing.""" def discover_schemas(self) -> dict[str, object]: """Return a mock schema.""" return { "os": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "os": { "type": "object", "properties": { "display_name": {"type": "string"}, "version": {"type": "string"}, }, "required": ["display_name"], } }, } } def load_entities( self, entity_types: Sequence[str], ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: """Return mock entity data if 'os' is in entity_types.""" if "os" not in entity_types: return {} return { "os": { "linux": {"os": {"display_name": "Linux", "version": "6.6.0"}}, "freebsd": {"os": {"display_name": "FreeBSD", "version": "14.0"}}, } } def test_entity_store_with_custom_provider( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, ) -> None: """Test using EntityStore with a custom provider.""" with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: # Create store with both filesystem and mock providers fs_provider = FSEntityProvider(ruyi_logger, entities_path) mock_provider = MockEntityProvider() store = EntityStore(ruyi_logger, fs_provider, mock_provider) # Verify entity types from both providers are available entity_types = set(store.get_entity_types()) assert "cpu" in entity_types # from filesystem assert "os" in entity_types # from mock provider # Verify we can get entities from both providers cpu = store.get_entity("cpu", "xiangshan-nanhu") assert cpu is not None assert cpu.entity_type == "cpu" os = store.get_entity("os", "linux") assert os is not None assert os.entity_type == "os" assert os.display_name == "Linux" assert os.data.get("version") == "6.6.0" ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_format_manifest.py000066400000000000000000000025701520522431500242240ustar00rootroot00000000000000import tomlkit from ruyi.ruyipkg.canonical_dump import dumps_canonical_package_manifest_toml from ruyi.ruyipkg.pkg_manifest import PackageManifest from ..fixtures import RuyiFileFixtureFactory def test_format_manifest(ruyi_file: RuyiFileFixtureFactory) -> None: with ruyi_file.path("ruyipkg_suites", "format_manifest") as fixtures_dir: # Find pairs of before/after files files = list(fixtures_dir.glob("*.toml")) cases = [f.name[:-12] for f in files if f.name.endswith(".before.toml")] for case_name in cases: # Determine the expected output file name before_file = fixtures_dir / f"{case_name}.before.toml" after_file = fixtures_dir / f"{case_name}.after.toml" assert after_file.exists(), f"Expected file {after_file} does not exist" with open(before_file, "rb") as f: data = PackageManifest(tomlkit.load(f)) # Process with the formatter result = dumps_canonical_package_manifest_toml(data) # Read the expected output with open(after_file, "r", encoding="utf-8") as g: expected = g.read() assert result == expected, ( f"Formatted output for {before_file.name} doesn't match expected output. " f"Check {after_file.name} for the expected formatting result." ) ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_host.py000066400000000000000000000021721520522431500220210ustar00rootroot00000000000000from ruyi.ruyipkg import host def test_canonicalize_arch() -> None: testcases = [ ("AMD64", "x86_64"), ("amd64", "x86_64"), ("EM64T", "x86_64"), ("ARM64", "aarch64"), ("arm64", "aarch64"), ("x86_64", "x86_64"), ("aarch64", "aarch64"), ("riscv64", "riscv64"), ] for input, expected in testcases: assert host.canonicalize_arch_str(input) == expected def test_canonicalize_host() -> None: assert host.canonicalize_host_str("arm64") == "linux/aarch64" assert host.canonicalize_host_str("aarch64") == "linux/aarch64" assert host.canonicalize_host_str("darwin/arm64") == "darwin/aarch64" assert host.canonicalize_host_str("linux/riscv64") == "linux/riscv64" assert host.canonicalize_host_str("riscv64") == "linux/riscv64" assert host.canonicalize_host_str("win32/AMD64") == "windows/x86_64" assert host.canonicalize_host_str("win32/ARM64") == "windows/aarch64" assert host.canonicalize_host_str("x86_64") == "linux/x86_64" assert ( host.canonicalize_host_str(host.RuyiHost("win32", "AMD64")) == "windows/x86_64" ) ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_migration.py000066400000000000000000000051161520522431500230360ustar00rootroot00000000000000import os import pathlib import pytest from ruyi.log import RuyiConsoleLogger from ruyi.ruyipkg.migration import migrate_repo_dir from tests.fixtures import MockGlobalModeProvider @pytest.fixture def mock_logger() -> RuyiConsoleLogger: gm = MockGlobalModeProvider() return RuyiConsoleLogger(gm) class TestMigrateRepoDir: def test_migrates_legacy_to_new( self, tmp_path: pathlib.Path, mock_logger: RuyiConsoleLogger ) -> None: cache_root = tmp_path / "cache" legacy = cache_root / "packages-index" legacy.mkdir(parents=True) (legacy / "config.toml").write_text("test") migrate_repo_dir(str(cache_root), mock_logger) new_path = cache_root / "repos" / "ruyisdk" assert new_path.is_dir() assert (new_path / "config.toml").read_text() == "test" # Legacy path should be a symlink to new assert legacy.is_symlink() assert os.readlink(str(legacy)) == str(new_path) def test_noop_if_legacy_missing( self, tmp_path: pathlib.Path, mock_logger: RuyiConsoleLogger ) -> None: cache_root = tmp_path / "cache" cache_root.mkdir(parents=True) migrate_repo_dir(str(cache_root), mock_logger) assert not (cache_root / "repos" / "ruyisdk").exists() def test_noop_if_already_migrated( self, tmp_path: pathlib.Path, mock_logger: RuyiConsoleLogger ) -> None: cache_root = tmp_path / "cache" new_path = cache_root / "repos" / "ruyisdk" new_path.mkdir(parents=True) (new_path / "config.toml").write_text("new") # Legacy exists as a regular dir too legacy = cache_root / "packages-index" legacy.mkdir(parents=True) (legacy / "config.toml").write_text("old") migrate_repo_dir(str(cache_root), mock_logger) # Neither should be changed assert (new_path / "config.toml").read_text() == "new" assert not legacy.is_symlink() assert (legacy / "config.toml").read_text() == "old" def test_noop_if_legacy_is_symlink( self, tmp_path: pathlib.Path, mock_logger: RuyiConsoleLogger ) -> None: cache_root = tmp_path / "cache" new_path = cache_root / "repos" / "ruyisdk" new_path.mkdir(parents=True) (new_path / "config.toml").write_text("data") legacy = cache_root / "packages-index" os.symlink(str(new_path), str(legacy)) migrate_repo_dir(str(cache_root), mock_logger) # Symlink still points correctly assert legacy.is_symlink() assert (legacy / "config.toml").read_text() == "data" ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_recipe_project.py000066400000000000000000000157021520522431500240440ustar00rootroot00000000000000import os import pathlib import pytest from ruyi.ruyipkg.recipe_project import ( MARKER_FILENAME, RecipeProject, RecipeProjectError, discover_recipe_project, safe_join, ) def _write_marker(root: pathlib.Path, body: str) -> None: (root / MARKER_FILENAME).write_text(body) def test_discover_from_immediate_parent(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\n[project]\nname = "demo"\n') recipe = tmp_path / "foo.star" recipe.write_text("") rp = discover_recipe_project(recipe) assert rp.root == tmp_path.resolve() assert rp.name == "demo" assert rp.output_dir == (tmp_path / "out").resolve() assert rp.extra_artifact_roots == () def test_discover_walks_up_parents(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\n') nested = tmp_path / "a" / "b" / "c" nested.mkdir(parents=True) recipe = nested / "x.star" recipe.write_text("") rp = discover_recipe_project(recipe) assert rp.root == tmp_path.resolve() # Default name falls back to root dir name. assert rp.name == tmp_path.name def test_discover_missing_marker(tmp_path: pathlib.Path) -> None: recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="no ruyi-build-recipes.toml"): discover_recipe_project(recipe) def test_discover_missing_recipe_file(tmp_path: pathlib.Path) -> None: with pytest.raises(RecipeProjectError, match="recipe file not found"): discover_recipe_project(tmp_path / "nope.star") def test_discover_malformed_toml(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, "not = valid = toml") recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="malformed"): discover_recipe_project(recipe) def test_discover_unsupported_format(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v2"\n') recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="unsupported or missing 'format'"): discover_recipe_project(recipe) def test_discover_custom_output_dir_and_extras(tmp_path: pathlib.Path) -> None: _write_marker( tmp_path, 'format = "v1"\n' "[project]\n" 'name = "demo"\n' 'output_dir = "dist"\n' f'extra_artifact_roots = ["{tmp_path.as_posix()}/scratch"]\n', ) (tmp_path / "scratch").mkdir() recipe = tmp_path / "x.star" recipe.write_text("") rp = discover_recipe_project(recipe) assert rp.output_dir == (tmp_path / "dist").resolve() assert rp.extra_artifact_roots == ((tmp_path / "scratch").resolve(),) def test_discover_absolute_output_dir_rejected(tmp_path: pathlib.Path) -> None: _write_marker( tmp_path, 'format = "v1"\n[project]\noutput_dir = "/abs"\n', ) recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises( RecipeProjectError, match="output_dir must be a project-relative" ): discover_recipe_project(recipe) def test_discover_traversing_output_dir_rejected(tmp_path: pathlib.Path) -> None: _write_marker( tmp_path, 'format = "v1"\n[project]\noutput_dir = "../out"\n', ) recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="escapes the project root"): discover_recipe_project(recipe) def test_discover_relative_extra_artifact_root_rejected(tmp_path: pathlib.Path) -> None: _write_marker( tmp_path, 'format = "v1"\n[project]\nextra_artifact_roots = ["scratch"]\n', ) recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="must be absolute"): discover_recipe_project(recipe) @pytest.mark.skipif(os.name != "posix", reason="symlinks are POSIX-flavored here") def test_discover_symlink_escape_rejected(tmp_path: pathlib.Path) -> None: project = tmp_path / "proj" project.mkdir() _write_marker(project, 'format = "v1"\n') outside = tmp_path / "outside" outside.mkdir() real_recipe = outside / "x.star" real_recipe.write_text("") link = project / "x.star" link.symlink_to(real_recipe) with pytest.raises(RecipeProjectError, match="escapes its project root"): discover_recipe_project(link) def test_safe_join_ok(tmp_path: pathlib.Path) -> None: (tmp_path / "lib").mkdir() target = tmp_path / "lib" / "docker.star" target.write_text("") assert safe_join(tmp_path, "lib/docker.star") == target.resolve() def test_safe_join_rejects_traversal(tmp_path: pathlib.Path) -> None: with pytest.raises(RecipeProjectError, match="escapes recipe project root"): safe_join(tmp_path, "../evil") def test_safe_join_rejects_absolute(tmp_path: pathlib.Path) -> None: with pytest.raises(RecipeProjectError, match="absolute paths are not allowed"): safe_join(tmp_path, "/etc/passwd") def test_recipe_project_marker_path(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\n') recipe = tmp_path / "x.star" recipe.write_text("") rp = discover_recipe_project(recipe) assert isinstance(rp, RecipeProject) assert rp.marker_path == tmp_path.resolve() / MARKER_FILENAME def test_discover_invalid_project_not_table(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\nproject = 1\n') recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match=r"\[project\] must be a table"): discover_recipe_project(recipe) def test_discover_invalid_project_name_empty(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\n[project]\nname = ""\n') recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="project.name"): discover_recipe_project(recipe) def test_discover_invalid_project_name_non_string(tmp_path: pathlib.Path) -> None: _write_marker(tmp_path, 'format = "v1"\n[project]\nname = 123\n') recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="project.name"): discover_recipe_project(recipe) def test_discover_invalid_extra_artifact_roots_non_list( tmp_path: pathlib.Path, ) -> None: _write_marker( tmp_path, 'format = "v1"\n[project]\nextra_artifact_roots = "scratch"\n', ) recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="extra_artifact_roots"): discover_recipe_project(recipe) def test_discover_invalid_extra_artifact_roots_non_string_item( tmp_path: pathlib.Path, ) -> None: _write_marker( tmp_path, 'format = "v1"\n[project]\nextra_artifact_roots = ["/ok", 1]\n', ) recipe = tmp_path / "x.star" recipe.write_text("") with pytest.raises(RecipeProjectError, match="extra_artifact_roots"): discover_recipe_project(recipe) ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_repo_entry.py000066400000000000000000000063711520522431500232370ustar00rootroot00000000000000from typing import TYPE_CHECKING import pytest from ruyi.ruyipkg.repo import ( DEFAULT_REPO_ID, DEFAULT_REPO_NAME, DEFAULT_REPO_PRIORITY, RepoEntry, ) if TYPE_CHECKING: from tests.fixtures import MockGlobalModeProvider from ruyi.log import RuyiLogger class TestRepoEntry: def test_construction(self) -> None: entry = RepoEntry( id="my-vendor", name="My Vendor Overlay", remote="https://example.com/overlay.git", branch="main", local_path=None, priority=50, active=True, ) assert entry.id == "my-vendor" assert entry.name == "My Vendor Overlay" assert entry.remote == "https://example.com/overlay.git" assert entry.branch == "main" assert entry.local_path is None assert entry.priority == 50 assert entry.active is True def test_resolve_root_without_local_path(self) -> None: entry = RepoEntry( id="my-vendor", name="My Vendor Overlay", remote="https://example.com/overlay.git", branch="main", local_path=None, priority=50, active=True, ) root = entry.resolve_root("/home/user/.cache/ruyi") assert root == "/home/user/.cache/ruyi/repos/my-vendor" def test_resolve_root_with_local_path(self) -> None: entry = RepoEntry( id="local-testing", name="Local Testing", remote=None, branch="main", local_path="/home/user/my-overlay", priority=100, active=True, ) root = entry.resolve_root("/home/user/.cache/ruyi") assert root == "/home/user/my-overlay" def test_from_legacy_config( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) entry = RepoEntry.from_legacy_config(gc) assert entry.id == DEFAULT_REPO_ID assert entry.name == DEFAULT_REPO_NAME assert entry.priority == DEFAULT_REPO_PRIORITY assert entry.active is True assert entry.remote is not None assert entry.branch == "main" assert entry.local_path is None def test_from_legacy_config_with_overrides( self, mock_gm: "MockGlobalModeProvider", ruyi_logger: "RuyiLogger", ) -> None: from ruyi.config import GlobalConfig gc = GlobalConfig(mock_gm, ruyi_logger) gc.override_repo_url = "https://custom.example.com/repo.git" gc.override_repo_branch = "dev" gc.override_repo_dir = "/custom/path" entry = RepoEntry.from_legacy_config(gc) assert entry.remote == "https://custom.example.com/repo.git" assert entry.branch == "dev" assert entry.local_path == "/custom/path" def test_frozen(self) -> None: entry = RepoEntry( id="test", name="Test", remote=None, branch="main", local_path=None, priority=0, active=True, ) with pytest.raises(AttributeError): entry.id = "changed" # type: ignore[misc] ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_state.py000066400000000000000000000113461520522431500221670ustar00rootroot00000000000000import datetime from typing import Iterable, TYPE_CHECKING from unittest.mock import Mock try: from semver.version import Version # type: ignore[import-untyped,unused-ignore] except ModuleNotFoundError: # semver 2.x from semver import VersionInfo as Version # type: ignore[import-untyped,unused-ignore] from ruyi.ruyipkg.state import BoundInstallationStateStore, PackageInstallationInfo if TYPE_CHECKING: from ruyi.ruyipkg.pkg_manifest import BoundPackageManifest def test_bound_installation_state_store_empty() -> None: """Test BoundInstallationStateStore with no installed packages.""" # Create a mock RuyipkgGlobalStateStore that returns no installed packages mock_rgs = Mock() mock_rgs.list_installed_packages.return_value = [] # Create a mock MetadataRepo mock_mr = Mock() # Create the BoundInstallationStateStore store = BoundInstallationStateStore(mock_rgs, mock_mr) # Test that it returns no packages assert not list(store.iter_pkg_manifests()) assert not list(store.iter_pkgs()) assert not list(store.iter_pkg_vers("nonexistent")) assert store.get_pkg_by_slug("nonexistent") is None def test_bound_installation_state_store_with_installed_packages() -> None: """Test BoundInstallationStateStore with some installed packages.""" # Create mock installed packages install_info1 = PackageInstallationInfo( repo_id="test-repo", category="toolchain", name="gcc", version="13.1.0", host="x86_64-linux-gnu", install_path="/test/path1", install_time=datetime.datetime.now(), ) install_info2 = PackageInstallationInfo( repo_id="test-repo", category="toolchain", name="gcc", version="13.2.0", host="x86_64-linux-gnu", install_path="/test/path2", install_time=datetime.datetime.now(), ) # Create mock manifests mock_manifest1 = Mock() mock_manifest1.category = "toolchain" mock_manifest1.name = "gcc" mock_manifest1.ver = "13.1.0" mock_manifest1.slug = "gcc-13-1-0" mock_manifest1.semver = Version.parse("13.1.0") mock_manifest2 = Mock() mock_manifest2.category = "toolchain" mock_manifest2.name = "gcc" mock_manifest2.ver = "13.2.0" mock_manifest2.slug = "gcc-13-2-0" mock_manifest2.semver = Version.parse("13.2.0") # Create a mock RuyipkgGlobalStateStore mock_rgs = Mock() mock_rgs.list_installed_packages.return_value = [install_info1, install_info2] # Create a mock MetadataRepo mock_mr = Mock() def mock_get_pkg( name: str, category: str, ver: str ) -> "BoundPackageManifest | None": if name == "gcc" and category == "toolchain": if ver == "13.1.0": return mock_manifest1 elif ver == "13.2.0": return mock_manifest2 return None def mock_iter_pkg_vers( name: str, category: str | None = None ) -> "Iterable[BoundPackageManifest]": if name == "gcc" and category == "toolchain": return [mock_manifest1, mock_manifest2] return [] mock_mr.get_pkg.side_effect = mock_get_pkg mock_mr.iter_pkg_vers.side_effect = mock_iter_pkg_vers # Create the BoundInstallationStateStore store = BoundInstallationStateStore(mock_rgs, mock_mr) # Test iter_pkg_manifests manifests = list(store.iter_pkg_manifests()) assert len(manifests) == 2 assert mock_manifest1 in manifests assert mock_manifest2 in manifests # Test iter_pkgs pkgs = list(store.iter_pkgs()) assert len(pkgs) == 1 category, name, versions = pkgs[0] assert category == "toolchain" assert name == "gcc" assert len(versions) == 2 assert "13.1.0" in versions assert "13.2.0" in versions # Test iter_pkg_vers pkg_versions = list(store.iter_pkg_vers("gcc", "toolchain")) assert len(pkg_versions) == 2 assert mock_manifest1 in pkg_versions assert mock_manifest2 in pkg_versions # Test get_pkg_latest_ver latest = store.get_pkg_latest_ver("gcc", "toolchain") # Should return the one with the higher version assert latest == mock_manifest2 # Test get_pkg_by_slug result = store.get_pkg_by_slug("gcc-13-1-0") assert result == mock_manifest1 # Test get_pkg method pkg = store.get_pkg("gcc", "toolchain", "13.1.0") assert pkg == mock_manifest1 pkg = store.get_pkg("gcc", "toolchain", "13.2.0") assert pkg == mock_manifest2 # Test get_pkg with non-existent package result = store.get_pkg("nonexistent", "toolchain", "1.0.0") assert result is None result = store.get_pkg("gcc", "nonexistent", "13.1.0") assert result is None result = store.get_pkg("gcc", "toolchain", "99.0.0") assert result is None ruyisdk-ruyi-1f00e2e/tests/ruyipkg/test_unpack.py000066400000000000000000000024041520522431500223230ustar00rootroot00000000000000import pathlib import sys from ruyi.log import RuyiLogger from ruyi.ruyipkg import unpack from ruyi.ruyipkg.unpack_method import UnpackMethod, determine_unpack_method from ..fixtures import RuyiFileFixtureFactory def test_unpack_deb( ruyi_file: RuyiFileFixtureFactory, ruyi_logger: RuyiLogger, tmp_path: pathlib.Path, ) -> None: with ruyi_file.path("cpp-for-host_14-20240120-6_riscv64.deb") as p: assert determine_unpack_method(str(p)) == UnpackMethod.DEB unpack.do_unpack( ruyi_logger, str(p), str(tmp_path), 0, UnpackMethod.DEB, None, ) check = tmp_path / "usr" / "share" / "doc" / "cpp-for-host" if sys.version_info >= (3, 12): assert check.exists(follow_symlinks=False) else: # Python 3.11 lacks pathlib.Path.exists(follow_symlinks) # # we know that this path is going to be a symlink so simply # ensuring it's existent is enough; asserting that it is dangling # risks breaking CI on systems where the target actually exists assert check.lstat() is not None assert check.is_symlink() assert str(check.readlink()) == "cpp-riscv64-linux-gnu" ruyisdk-ruyi-1f00e2e/tests/utils/000077500000000000000000000000001520522431500170775ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/utils/__init__.py000066400000000000000000000000001520522431500211760ustar00rootroot00000000000000ruyisdk-ruyi-1f00e2e/tests/utils/test_global_mode.py000066400000000000000000000010661520522431500227570ustar00rootroot00000000000000from ruyi.utils.global_mode import is_cli_completion_script_requested def test_is_cli_completion_script_requested_matches_only_exact_option() -> None: assert is_cli_completion_script_requested(["ruyi", "--output-completion-script"]) assert is_cli_completion_script_requested( ["ruyi", "--output-completion-script=bash"] ) assert not is_cli_completion_script_requested( ["ruyi", "--output-completion-script-foo"] ) assert not is_cli_completion_script_requested( ["ruyi", "--output-completion-script-foo=bash"] ) ruyisdk-ruyi-1f00e2e/tests/utils/test_l10n.py000066400000000000000000000044421520522431500212660ustar00rootroot00000000000000from ruyi.utils.l10n import lang_code_to_lang_region, LangAndRegion, match_lang_code def test_lang_code_to_lang_region() -> None: assert lang_code_to_lang_region("en", False) == LangAndRegion("en", "en", None) assert lang_code_to_lang_region("en", True) == LangAndRegion("en", "en", "US") assert lang_code_to_lang_region("en_SG", False) == LangAndRegion( "en_SG", "en", "SG" ) assert lang_code_to_lang_region("zh", False) == LangAndRegion("zh", "zh", None) assert lang_code_to_lang_region("zh", True) == LangAndRegion("zh", "zh", "CN") assert lang_code_to_lang_region("zh_HK", False) == LangAndRegion( "zh_HK", "zh", "HK" ) assert lang_code_to_lang_region("cmn", False) == LangAndRegion("cmn", "cmn", None) assert lang_code_to_lang_region("cmn", True) == LangAndRegion("cmn", "cmn", None) def test_match_lang_code() -> None: assert match_lang_code("zh", ["en"]) == "en" assert match_lang_code("en_US", ["en"]) == "en" assert match_lang_code("en", ["en_US"]) == "en_US" assert match_lang_code("en_US", ["en_US"]) == "en_US" assert match_lang_code("zh", ["en", "zh"]) == "zh" assert match_lang_code("zh", ["en", "zh_CN"]) == "zh_CN" assert match_lang_code("zh", ["en", "zh_HK"]) == "zh_HK" assert match_lang_code("zh_HK", ["en", "zh_CN"]) == "zh_CN" assert match_lang_code("zh_CN", ["en", "zh_HK"]) == "zh_HK" # match according to region assert match_lang_code("ga", ["en", "en_IE", "zh_CN"]) == "en_IE" # match according to language assert match_lang_code("pt", ["pt_BR", "en", "zh"]) == "pt_BR" # fallback in the order of en_US, en_*, zh_CN, zh_* assert ( match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE", "en", "en_US"]) == "en_US" ) assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE", "en"]) == "en" assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE"]) == "en_IE" assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN"]) == "zh_CN" assert match_lang_code("pt", ["ga", "zh_HK", "zh"]) == "zh" assert match_lang_code("pt", ["ga", "zh_HK"]) == "zh_HK" # fallback to the lexicographically first one assert match_lang_code("ru", ["ga", "es_ES"]) == "es_ES" assert match_lang_code("ru", ["es_ES", "ga"]) == "es_ES" ruyisdk-ruyi-1f00e2e/tests/utils/test_mounts.py000066400000000000000000000016701520522431500220410ustar00rootroot00000000000000from ruyi.utils import mounts def test_parse_mounts() -> None: sample = r""" /dev/mapper/foo / btrfs rw,noatime,ssd,discard=async,space_cache=v2,autodefrag,subvolid=5,subvol=/ 0 0 devtmpfs /dev devtmpfs rw,nosuid,size=3896808k,nr_inodes=974202,mode=755,inode64 0 0 tmpfs /dev/shm tmpfs rw,nosuid,nodev,inode64 0 0 tmpfs /tmp/x\040b tmpfs rw,relatime,inode64 0 0 """ parsed = mounts.parse_mounts(sample) assert len(parsed) == 4 assert parsed[0].source == "/dev/mapper/foo" assert parsed[0].target == "/" assert parsed[0].fstype == "btrfs" assert parsed[0].options == [ "rw", "noatime", "ssd", "discard=async", "space_cache=v2", "autodefrag", "subvolid=5", "subvol=/", ] assert parsed[3].source == "tmpfs" assert parsed[3].target == "/tmp/x b" assert parsed[3].fstype == "tmpfs" assert parsed[3].options == ["rw", "relatime", "inode64"] ruyisdk-ruyi-1f00e2e/tests/utils/test_toml.py000066400000000000000000000020101520522431500214540ustar00rootroot00000000000000import tomlkit from ruyi.utils.toml import ( extract_footer_comments, extract_header_comments, ) def test_extract_header_comments() -> None: a1 = """[foo] a = 2 """ assert extract_header_comments(tomlkit.parse(a1)) == [] a2 = """ # foo # bar a = 2 # baz """ assert extract_header_comments(tomlkit.parse(a2)) == [ "# foo\n", "\n", "# bar\n", "\n", ] a3 = """ # baz # quux [a] b = 2 """ assert extract_header_comments(tomlkit.parse(a3)) == ["# baz\n", "# quux\n", "\n"] def test_extract_footer_comments() -> None: a1 = """[foo] a = 2 """ assert extract_footer_comments(tomlkit.parse(a1)) == [] a2 = """ a = 2 # foo # bar """ assert extract_footer_comments(tomlkit.parse(a2)) == ["# foo\n", "# bar\n"] a3 = """# foo # bar [a] foo = 2 # baz # baz2 # quux """ assert extract_footer_comments(tomlkit.parse(a3)) == [ "\n", "# baz\n", "# baz2\n", "\n", "# quux\n", ]