├── ruyi ├── py.typed ├── device │ ├── __init__.py │ └── provision_cli.py ├── mux │ ├── __init__.py │ ├── venv │ │ ├── __init__.py │ │ ├── emulator_cfg.py │ │ └── venv_cli.py │ └── .gitignore ├── utils │ ├── __init__.py │ ├── url.py │ ├── templating.py │ ├── frontmatter.py │ ├── nuitka.py │ ├── mounts.py │ ├── porcelain.py │ ├── prereqs.py │ ├── ar.py │ ├── l10n.py │ ├── markdown.py │ ├── xdg_basedir.py │ ├── ci.py │ └── toml.py ├── pluginhost │ ├── __init__.py │ └── plugin_cli.py ├── ruyipkg │ ├── __init__.py │ ├── profile_cli.py │ ├── cli_completion.py │ ├── checksum.py │ ├── host.py │ ├── update_cli.py │ ├── protocols.py │ ├── unpack_method.py │ ├── list_cli.py │ ├── admin_checksum.py │ ├── news_cli.py │ ├── admin_cli.py │ └── msg.py ├── telemetry │ ├── __init__.py │ ├── event.py │ ├── scope.py │ └── aggregate.py ├── cli │ ├── __init__.py │ ├── builtin_commands.py │ ├── completion.py │ ├── version_cli.py │ ├── completer.py │ └── oobe.py ├── __init__.py ├── resource_bundle │ ├── __init__.py │ └── __main__.py ├── version.py ├── config │ ├── news.py │ └── errors.py └── __main__.py ├── tests ├── __init__.py ├── utils │ ├── __init__.py │ ├── test_mounts.py │ ├── test_toml.py │ └── test_l10n.py ├── config │ ├── __init__.py │ ├── test_schema.py │ └── test_editor.py ├── pluginhost │ ├── __init__.py │ └── test_api.py ├── ruyipkg │ ├── __init__.py │ ├── test_host.py │ ├── test_unpack.py │ ├── test_format_manifest.py │ ├── test_checksum.py │ └── test_entity_providers.py ├── conftest.py ├── fixtures │ ├── cpp-for-host_14-20240120-6_riscv64.deb │ ├── ruyipkg_suites │ │ ├── entities_v0_smoke │ │ │ ├── arch │ │ │ │ └── riscv64.toml │ │ │ ├── cpu │ │ │ │ ├── xuantie-th1520.toml │ │ │ │ └── xiangshan-nanhu.toml │ │ │ ├── uarch │ │ │ │ ├── xuantie-c910.toml │ │ │ │ └── xiangshan-nanhu.toml │ │ │ ├── device │ │ │ │ ├── sipeed-lpi4a.toml │ │ │ │ ├── sipeed-lc4a.toml │ │ │ │ └── sipeed-lcon4a.toml │ │ │ └── _schemas │ │ │ │ ├── cpu.jsonschema │ │ │ │ ├── arch.jsonschema │ │ │ │ ├── device.jsonschema │ │ │ │ └── uarch.jsonschema │ │ └── format_manifest │ │ │ ├── distfile-restrict.after.toml │ │ │ ├── distfile-restrict.before.toml │ │ │ ├── example-board-image.before.toml │ │ │ ├── example-board-image.after.toml │ │ │ ├── binary-with-cmds.before.toml │ │ │ ├── binary-with-cmds.after.toml │ │ │ ├── prefer-quirks-to-flavors.after.toml │ │ │ └── prefer-quirks-to-flavors.before.toml │ └── plugins_suites │ │ └── with_ │ │ └── foo │ │ └── mod.star ├── integration │ └── test_cli_basic.py └── rit-suites │ └── ruyi-gha.yaml ├── stubs ├── fastjsonschema │ ├── version.pyi │ ├── exceptions.pyi │ └── __init__.pyi └── arpy.pyi ├── contrib ├── shell-completions │ ├── bash │ │ └── ruyi │ └── zsh │ │ └── _ruyi └── poetry-1.x │ ├── README.md │ └── pyproject.toml ├── resources ├── ruyi.ico ├── ruyi-logo-256.png ├── bundled │ ├── prompt.venv-created.txt.jinja │ ├── meson-cross.ini.jinja │ ├── toolchain.cmake.jinja │ ├── binfmt.conf.jinja │ ├── ruyi-cache.toml.jinja │ ├── ruyi-venv.toml.jinja │ ├── ruyi-activate.bash.jinja │ └── _ruyi_completion ├── make-ruyi-ico.sh └── release-notes-header-template.md ├── .gitmodules ├── scripts ├── lint-shell-scripts.sh ├── dist.ps1 ├── build-and-push-dist-image.sh ├── lint-bundled-resources.sh ├── dist-image │ ├── build-python.sh │ ├── prepare-poetry.sh │ └── Dockerfile ├── dist-gha.sh ├── install-baseline-deps.sh ├── patches │ └── nuitka │ │ └── 0001-workaround-libatomic-linkage-for-static-libpython-on.patch ├── make-reproducible-source-tarball.sh ├── set-gha-env.py ├── _image_tag_base.sh ├── lint-cli-startup-flow.py ├── lint-version-metadata.py ├── organize-release-artifacts.py └── make-release-tag.py ├── .markdownlint.yaml ├── .editorconfig ├── .github └── workflows │ ├── ruyi-package-ci.yml │ └── auto-tag.yml ├── docs ├── README.md ├── ci-release-automation.md ├── ci-self-hosted-runner.md ├── repo-pkg-versioning-convention.md ├── naming-of-devices-and-images.md ├── programmatic-usage.md ├── building.md └── dep-baseline.md ├── CONTRIBUTING.zh.md └── pyproject.toml /ruyi/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/device/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/mux/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/mux/venv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/pluginhost/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/telemetry/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pluginhost/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ruyipkg/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruyi/mux/.gitignore: -------------------------------------------------------------------------------- 1 | !venv 2 | -------------------------------------------------------------------------------- /stubs/fastjsonschema/version.pyi: -------------------------------------------------------------------------------- 1 | VERSION: str 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = "tests.fixtures" 2 | -------------------------------------------------------------------------------- /contrib/shell-completions/bash/ruyi: -------------------------------------------------------------------------------- 1 | eval "$(ruyi --output-completion-script=bash)" 2 | -------------------------------------------------------------------------------- /resources/ruyi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruyisdk/ruyi/HEAD/resources/ruyi.ico -------------------------------------------------------------------------------- /contrib/shell-completions/zsh/_ruyi: -------------------------------------------------------------------------------- 1 | #compdef ruyi 2 | eval "$(ruyi --output-completion-script=zsh)" 3 | -------------------------------------------------------------------------------- /resources/ruyi-logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruyisdk/ruyi/HEAD/resources/ruyi-logo-256.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/ruyi-litester"] 2 | path = tests/ruyi-litester 3 | url = https://github.com/weilinfox/ruyi-litester.git 4 | -------------------------------------------------------------------------------- /ruyi/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | 4 | # Should be all-lower for is_called_as_ruyi to work 5 | RUYI_ENTRYPOINT_NAME: Final = "ruyi" 6 | -------------------------------------------------------------------------------- /tests/fixtures/cpp-for-host_14-20240120-6_riscv64.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruyisdk/ruyi/HEAD/tests/fixtures/cpp-for-host_14-20240120-6_riscv64.deb -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/arch/riscv64.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | [arch] 4 | id = "riscv64" 5 | display_name = "64-bit RISC-V" 6 | -------------------------------------------------------------------------------- /scripts/lint-shell-scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "${BASH_SOURCE[0]}")"/.. 6 | find resources scripts -name '*.sh' -print0 | xargs -0 shellcheck -P . -P scripts 7 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/cpu/xuantie-th1520.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["uarch:xuantie-c910"] 4 | 5 | [cpu] 6 | id = "xuantie-th1520" 7 | display_name = "Xuantie TH1520" 8 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/cpu/xiangshan-nanhu.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["uarch:xiangshan-nanhu"] 4 | 5 | [cpu] 6 | id = "xiangshan-nanhu" 7 | display_name = "Xiangshan Nanhu" 8 | -------------------------------------------------------------------------------- /ruyi/utils/url.py: -------------------------------------------------------------------------------- 1 | from urllib import parse 2 | 3 | 4 | def urljoin_for_sure(base: str, url: str) -> str: 5 | if base.endswith("/"): 6 | return parse.urljoin(base, url) 7 | return parse.urljoin(base + "/", url) 8 | -------------------------------------------------------------------------------- /scripts/dist.ps1: -------------------------------------------------------------------------------- 1 | # Activate the Poetry venv 2 | & ((poetry env info --path) + "\Scripts\activate.ps1") 3 | 4 | $Env:CLCACHE_DIR = "\clcache" 5 | $Env:RUYI_DIST_BUILD_DIR = "\build" 6 | $Env:RUYI_DIST_CACHE_DIR = "\ruyi-dist-cache" 7 | 8 | python "scripts\dist-inner.py" 9 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/uarch/xuantie-c910.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["arch:riscv64"] 4 | 5 | [uarch] 6 | id = "xuantie-c910" 7 | display_name = "Xuantie C910" 8 | arch = "riscv64" 9 | 10 | [uarch.riscv] 11 | isa = "rv64imafdc_zfh_xtheadc" 12 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # It's fine to switch to different ul style for better readability 2 | MD004: false 3 | 4 | # Use 4 spaces for nested list items for (1) better visual separation and 5 | # (2) consistency with Python 6 | MD007: 7 | indent: 4 8 | 9 | # No impact on readability 10 | MD034: false 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | insert_final_newline = true 10 | 11 | [*.{md,py,sh}] 12 | indent_size = 4 13 | 14 | [Dockerfile*] 15 | indent_size = 4 16 | 17 | [*.{toml,yml,yaml}] 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/uarch/xiangshan-nanhu.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["arch:riscv64"] 4 | 5 | [uarch] 6 | id = "xiangshan-nanhu" 7 | display_name = "Xiangshan Nanhu" 8 | arch = "riscv64" 9 | 10 | [uarch.riscv] 11 | isa = "rv64imafdc_zba_zbb_zbc_zbs_zbkb_zbkc_zbkx_zknd_zkne_zknh_zksed_zksh_svinval_zicbom_zicboz" 12 | -------------------------------------------------------------------------------- /scripts/build-and-push-dist-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | MY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | 7 | source "$MY_DIR/_image_tag_base.sh" 8 | 9 | cd "$MY_DIR/dist-image" 10 | exec docker buildx build --rm \ 11 | --platform "linux/amd64,linux/arm64,linux/riscv64" \ 12 | -t "$(image_tag_base amd64)" \ 13 | --push \ 14 | . 15 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lpi4a.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["cpu:xuantie-th1520"] 4 | 5 | [device] 6 | id = "sipeed-lpi4a" 7 | display_name = "Sipeed LicheePi 4A" 8 | 9 | [[device.variants]] 10 | id = "8g" 11 | display_name = "Sipeed LicheePi 4A (8G RAM)" 12 | 13 | [[device.variants]] 14 | id = "16g" 15 | display_name = "Sipeed LicheePi 4A (16G RAM)" 16 | -------------------------------------------------------------------------------- /tests/fixtures/plugins_suites/with_/foo/mod.star: -------------------------------------------------------------------------------- 1 | RUYI = ruyi_plugin_rev(1) 2 | 3 | 4 | def fn1(mgr): 5 | def _with_inner(obj): 6 | return obj * 2 7 | return RUYI.with_(mgr, _with_inner) 8 | 9 | 10 | def fn2(mgr): 11 | def _with_inner(obj): 12 | return mgr.NoNeXiStEnT 13 | return RUYI.with_(mgr, _with_inner) 14 | 15 | 16 | def fn3(mgr, py_fn): 17 | return RUYI.with_(mgr, py_fn) 18 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lc4a.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["cpu:xuantie-th1520"] 4 | 5 | [device] 6 | id = "sipeed-lc4a" 7 | display_name = "Sipeed Lichee Cluster 4A" 8 | 9 | [[device.variants]] 10 | id = "8g" 11 | display_name = "Sipeed Lichee Cluster 4A (8G RAM)" 12 | 13 | [[device.variants]] 14 | id = "16g" 15 | display_name = "Sipeed Lichee Cluster 4A (16G RAM)" 16 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/device/sipeed-lcon4a.toml: -------------------------------------------------------------------------------- 1 | ruyi-entity = "v0" 2 | 3 | related = ["cpu:xuantie-th1520"] 4 | 5 | [device] 6 | id = "sipeed-lcon4a" 7 | display_name = "Sipeed Lichee Console 4A" 8 | 9 | [[device.variants]] 10 | id = "8g" 11 | display_name = "Sipeed Lichee Console 4A (8G RAM)" 12 | 13 | [[device.variants]] 14 | id = "16g" 15 | display_name = "Sipeed Lichee Console 4A (16G RAM)" 16 | -------------------------------------------------------------------------------- /.github/workflows/ruyi-package-ci.yml: -------------------------------------------------------------------------------- 1 | name: Dispatch ruyi-package-ci 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | dispatch-ruyi-package-ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Dispatch ruyi-package-ci 13 | uses: benc-uk/workflow-dispatch@v1 14 | with: 15 | token: ${{ secrets.GHA_PAT_RUYI_PACKAGE_CI_RW }} 16 | repo: ruyisdk/ruyi-package-ci 17 | ref: master 18 | workflow: deb.yml 19 | inputs: '{"ruyi_deb_ref": "${{ github.ref_name }}"}' 20 | -------------------------------------------------------------------------------- /contrib/poetry-1.x/README.md: -------------------------------------------------------------------------------- 1 | # Poetry 1.x project metadata files 2 | 3 | If you are a packager packaging `ruyi` for old distros that only provide 4 | Poetry 1.x, here are project metadata files ready for use. 5 | 6 | Just drop in the files to replace the Poetry 2.x metadata: 7 | 8 | ```sh 9 | # at project root 10 | mv contrib/poetry-1.x/{pyproject.toml,poetry.lock} . 11 | ``` 12 | 13 | Then you should be able to continue building with Poetry 1.x. 14 | 15 | The metadata provided here is generated with Poetry 1.0.7, which is what 16 | Ubuntu 22.04 provides. 17 | -------------------------------------------------------------------------------- /ruyi/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | if typing.TYPE_CHECKING: 4 | 5 | class NuitkaVersion(typing.NamedTuple): 6 | major: int 7 | minor: int 8 | micro: int 9 | releaselevel: str 10 | containing_dir: str 11 | standalone: bool 12 | onefile: bool 13 | macos_bundle_mode: bool 14 | no_asserts: bool 15 | no_docstrings: bool 16 | no_annotations: bool 17 | module: bool 18 | main: str 19 | original_argv0: str | None 20 | 21 | __compiled__: NuitkaVersion 22 | -------------------------------------------------------------------------------- /ruyi/resource_bundle/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import zlib 3 | 4 | from .data import RESOURCES, TEMPLATES 5 | 6 | 7 | def _unpack_payload(x: bytes) -> str: 8 | return zlib.decompress(base64.b64decode(x)).decode("utf-8") 9 | 10 | 11 | def get_resource_str(template_name: str) -> str | None: 12 | if t := RESOURCES.get(template_name): 13 | return _unpack_payload(t) 14 | return None 15 | 16 | 17 | def get_template_str(template_name: str) -> str | None: 18 | if t := TEMPLATES.get(template_name): 19 | return _unpack_payload(t) 20 | return None 21 | -------------------------------------------------------------------------------- /scripts/lint-bundled-resources.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REGEN_SCRIPT="./ruyi/resource_bundle/__main__.py" 4 | 5 | cd "$(dirname "${BASH_SOURCE[0]}")"/.. || exit 6 | if ! "$REGEN_SCRIPT"; then 7 | echo "error: syncing of resource bundle failed" >&2 8 | exit 1 9 | fi 10 | 11 | if ! git diff --exit-code ruyi/resource_bundle > /dev/null; then 12 | echo "error: resource bundle modified but not synced to Python package" >&2 13 | echo "info: re-run $REGEN_SCRIPT to do so" >&2 14 | exit 1 15 | fi 16 | 17 | echo "info: ✅ resource bundle is properly synced" >&2 18 | exit 0 19 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # RuyiSDK 包管理器的技术文档 2 | 3 | 以下是一些面向 RuyiSDK 贡献者、打包者群体的技术文档。如果您是使用 RuyiSDK 4 | 开发自己项目的用户,请参考 [RuyiSDK 官网文档](https://ruyisdk.org/docs/intro)。 5 | 6 | ## 第三方 RuyiSDK 生态 7 | 8 | * [如何程序化地与 `ruyi` 交互](./programmatic-usage.md) 9 | 10 | ## 工程化 11 | 12 | * [`ruyi` 的构建方式](./building.md) 13 | * [CI: 自动化版本发布](./ci-release-automation.md) 14 | * [Repo CI: Self-hosted runner 管理](./ci-self-hosted-runner.md) 15 | * [`ruyi` 的依赖兼容基线](./dep-baseline.md) 16 | 17 | ## 软件源 18 | 19 | * [设备、系统镜像的命名约定](./naming-of-devices-and-images.md) 20 | * [RuyiSDK 软件源的软件包版本约定](./repo-pkg-versioning-convention.md) 21 | * [Ruyi 软件源结构定义](./repo-structure.md) 22 | -------------------------------------------------------------------------------- /resources/bundled/prompt.venv-created.txt.jinja: -------------------------------------------------------------------------------- 1 | The virtual environment is now created. 2 | 3 | You may activate it by sourcing the appropriate activation script in the 4 | [green]bin[/] directory, and deactivate by invoking `ruyi-deactivate`. 5 | {%- if sysroot %} 6 | 7 | A fresh sysroot/prefix is also provisioned in the virtual environment. 8 | It is available at the following path: 9 | 10 | [green]{{ sysroot }}[/] 11 | {%- endif %} 12 | 13 | The virtual environment also comes with ready-made CMake toolchain file 14 | and Meson cross file. Check the virtual environment root for those; 15 | comments in the files contain usage instructions. 16 | -------------------------------------------------------------------------------- /resources/bundled/meson-cross.ini.jinja: -------------------------------------------------------------------------------- 1 | # Use like: 2 | # 3 | # meson setup --cross-file {{ venv_root }}/meson-cross.ini ... 4 | # 5 | # Needs meson 0.56.0+. 6 | 7 | [binaries] 8 | c = '{{ cc }}' 9 | cpp = '{{ cxx }}' 10 | {%- for key, path in meson_additional_binaries.items() %} 11 | {{ key }} = '{{ path }}' 12 | {%- endfor %} 13 | {% if sysroot %} 14 | [built-in options] 15 | prefix = '{{ sysroot }}' 16 | {% endif %} 17 | [properties] 18 | cmake_toolchain_file = '{{ cmake_toolchain_file }}' 19 | {%- if sysroot %} 20 | sys_root = '{{ sysroot }}' 21 | {%- endif %} 22 | 23 | [host_machine] 24 | system = 'linux' 25 | cpu_family = '{{ processor }}' 26 | cpu = '{{ processor }}' 27 | endian = 'little' 28 | -------------------------------------------------------------------------------- /ruyi/version.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | RUYI_SEMVER: Final = "0.44.0-alpha.20251218" 4 | RUYI_USER_AGENT: Final = f"ruyi/{RUYI_SEMVER}" 5 | 6 | COPYRIGHT_NOTICE: Final = """\ 7 | Copyright (C) Institute of Software, Chinese Academy of Sciences (ISCAS). 8 | All rights reserved. 9 | License: Apache-2.0 10 | \ 11 | """ 12 | 13 | MPL_REDIST_NOTICE: Final = """\ 14 | This distribution of ruyi contains code licensed under the Mozilla Public 15 | License 2.0 (https://mozilla.org/MPL/2.0/). You can get the respective 16 | project's sources from the project's official website: 17 | 18 | * certifi: https://github.com/certifi/python-certifi 19 | \ 20 | """ 21 | -------------------------------------------------------------------------------- /ruyi/cli/builtin_commands.py: -------------------------------------------------------------------------------- 1 | from ..device import provision_cli as provision_cli 2 | from ..mux.venv import venv_cli as venv_cli 3 | from ..pluginhost import plugin_cli as plugin_cli 4 | from ..ruyipkg import admin_cli as admin_cli 5 | from ..ruyipkg import entity_cli as entity_cli 6 | from ..ruyipkg import install_cli as install_cli 7 | from ..ruyipkg import list_cli as list_cli 8 | from ..ruyipkg import news_cli as news_cli 9 | from ..ruyipkg import profile_cli as profile_cli 10 | from ..ruyipkg import update_cli as update_cli 11 | from ..telemetry import telemetry_cli as telemetry_cli 12 | from . import self_cli as self_cli 13 | from . import config_cli as config_cli 14 | from . import version_cli as version_cli 15 | -------------------------------------------------------------------------------- /resources/bundled/toolchain.cmake.jinja: -------------------------------------------------------------------------------- 1 | # Use like: 2 | # 3 | # cmake ... \ 4 | # -DCMAKE_TOOLCHAIN_FILE={{ venv_root }}/toolchain.cmake \ 5 | {%- if sysroot %} 6 | # -DCMAKE_INSTALL_PREFIX={{ sysroot }} \ 7 | {%- endif %} 8 | # ... 9 | 10 | set(CMAKE_SYSTEM_NAME Linux) 11 | set(CMAKE_SYSTEM_PROCESSOR {{ processor }}) 12 | set(CMAKE_C_COMPILER {{ cc }}) 13 | set(CMAKE_CXX_COMPILER {{ cxx }}) 14 | {% if sysroot -%} 15 | set(CMAKE_FIND_ROOT_PATH {{ sysroot }}) 16 | {% endif %} 17 | # search for headers and libraries in the target environment, 18 | # search for programs in the host environment 19 | set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) 20 | set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) 21 | set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) 22 | -------------------------------------------------------------------------------- /scripts/dist-image/build-python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Nuitka now requires final versions of Python, but unfortunately the python3.11 6 | # in the jammy repo is 3.11.0rc1, and there's no python3.12 in repo, so we have 7 | # to build our own Python for now. 8 | # 9 | # See: https://github.com/Nuitka/Nuitka/commit/54f2a2222abedf92d45b8f397233cfb3bef340c5 10 | 11 | PYTHON_V=3.13.9 12 | pushd /tmp 13 | wget "https://www.python.org/ftp/python/${PYTHON_V}/Python-${PYTHON_V}.tar.xz" 14 | mkdir py-src py-build 15 | tar -xf "Python-${PYTHON_V}.tar.xz" --strip-components=1 -C py-src 16 | 17 | pushd py-build 18 | ../py-src/configure --prefix=/usr/local 19 | make -j 20 | make install 21 | popd 22 | 23 | rm -rf py-src py-build Python-*.tar.xz 24 | popd 25 | -------------------------------------------------------------------------------- /resources/bundled/binfmt.conf.jinja: -------------------------------------------------------------------------------- 1 | # binfmt_misc config suitable for this Ruyi virtual environment, 2 | # in systemd-binfmt config format; see `man binfmt.d` for details. 3 | # You should register one of the following declaration(s), in a way 4 | # appropriate for your distribution / service manager / etc, or invoke 5 | # the emulator binary yourself via the `ruyi-qemu` wrapper. 6 | {% for prog in resolved_progs %} 7 | # Emulator {{ prog.display_name }} 8 | {%- if prog.env %} 9 | # 10 | # Note that you also have to provide these environment variables at runtime, 11 | # in order to achieve correct emulation semantics: 12 | # 13 | {% for k, v in prog.env.items() %}# - {{ k }}={{ v | sh }} 14 | {% endfor %} 15 | {%- endif -%} 16 | {{ prog.binfmt_misc_str }} 17 | {%- endfor %} 18 | -------------------------------------------------------------------------------- /resources/make-ruyi-ico.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # requires icotool, imagemagick & pngcrush to work 3 | 4 | set -e 5 | 6 | my_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | cd "$my_dir" 8 | 9 | sizes=( 16 32 48 64 128 ) 10 | 11 | input_file=ruyi-logo-256.png 12 | tmpdir="$(mktemp -d)" 13 | cp "$input_file" "$tmpdir/256.png" 14 | 15 | size_files=() 16 | pushd "$tmpdir" > /dev/null 17 | for size in "${sizes[@]}"; do 18 | convert 256.png -resize "${size}x${size}" "${size}.tmp.png" 19 | pngcrush "${size}.tmp.png" "${size}.png" 20 | size_files+=( "${size}.png" ) 21 | done 22 | 23 | size_files+=( 256.png ) 24 | icotool -c -o "$my_dir/ruyi.ico" "${size_files[@]}" 25 | popd > /dev/null 26 | 27 | rm "$tmpdir"/*.png 28 | rmdir "$tmpdir" 29 | -------------------------------------------------------------------------------- /stubs/fastjsonschema/exceptions.pyi: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | SPLIT_RE: re.Pattern[str] 4 | 5 | class JsonSchemaException(ValueError): ... 6 | 7 | class JsonSchemaValueException(JsonSchemaException): 8 | message: str 9 | value: object | None 10 | name: str | None 11 | definition: object | None 12 | rule: str | None 13 | def __init__( 14 | self, 15 | message: str, 16 | value: object | None = None, 17 | name: str | None = None, 18 | definition: object | None = None, 19 | rule: str | None = None, 20 | ) -> None: ... 21 | @property 22 | def path(self) -> list[str]: ... 23 | @property 24 | def rule_definition(self) -> object: ... 25 | 26 | class JsonSchemaDefinitionException(JsonSchemaException): ... 27 | -------------------------------------------------------------------------------- /resources/bundled/ruyi-cache.toml.jinja: -------------------------------------------------------------------------------- 1 | # NOTE: This file is managed by ruyi. DO NOT EDIT! 2 | 3 | [cached_v2] 4 | {% if qemu_bin %}qemu_bin = "{{ qemu_bin }}"{% endif %} 5 | {% if profile_emu_env %} 6 | [cached_v2.profile_emu_env] 7 | {% for k, v in profile_emu_env.items() %}{{ k }} = "{{ v }}" 8 | {% endfor %}{% endif %} 9 | {% for k, v in targets.items() %}[cached_v2.targets.{{ k }}] 10 | toolchain_bindir = "{{ v['toolchain_bindir'] }}" 11 | toolchain_flags = "{{ v['toolchain_flags'] }}" 12 | {% if v["toolchain_sysroot"] %}toolchain_sysroot = "{{ v['toolchain_sysroot'] }}"{% endif %} 13 | {% if v["gcc_install_dir"] %}gcc_install_dir = "{{ v['gcc_install_dir'] }}"{% endif %} 14 | {% endfor %} 15 | {% for k, v in cmd_metadata_map.items() %}[cached_v2.cmd_metadata_map."{{ k }}"] 16 | dest = "{{ v['dest'] }}" 17 | target_tuple = "{{ v['target_tuple'] }}" 18 | {% endfor %} 19 | -------------------------------------------------------------------------------- /scripts/dist-image/prepare-poetry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | MAKEFLAGS="-j$(nproc)" 6 | export MAKEFLAGS 7 | 8 | # poetry should be put into its own venv to avoid contaminating the dist build 9 | # venv; otherwise nuitka can and will see additional imports leading to bloat 10 | python3.13 -m venv /home/b/build-tools-venv 11 | /home/b/build-tools-venv/bin/pip install -U pip setuptools wheel 12 | /home/b/build-tools-venv/bin/pip install poetry 13 | /home/b/build-tools-venv/bin/pip install maturin==1.9.6 cibuildwheel~=3.1.1 auditwheel==6.4.2 14 | for tool in poetry maturin cibuildwheel auditwheel; do 15 | ln -s /home/b/build-tools-venv/bin/"$tool" /usr/local/bin/"$tool" 16 | done 17 | 18 | python3.13 -m venv /home/b/venv 19 | /home/b/venv/bin/pip install -U pip setuptools wheel 20 | chown -R "$BUILDER_UID:$BUILDER_GID" /home/b/venv 21 | 22 | # remove wheel caches in the root user 23 | rm -rf /root/.cache 24 | -------------------------------------------------------------------------------- /ruyi/cli/completion.py: -------------------------------------------------------------------------------- 1 | """ 2 | helper functions for CLI completions 3 | 4 | see https://github.com/kislyuk/argcomplete/issues/443 for why this is needed 5 | """ 6 | 7 | import argparse 8 | from typing import Any, Callable, Final, Optional, Sequence, cast 9 | 10 | SUPPORTED_SHELLS: Final[set[str]] = {"bash", "zsh"} 11 | 12 | 13 | class ArgcompleteAction(argparse.Action): 14 | completer: Optional[Callable[[str, object], list[str]]] 15 | 16 | def __call__( 17 | self, 18 | parser: argparse.ArgumentParser, 19 | namespace: argparse.Namespace, 20 | values: str | Sequence[Any] | None, 21 | option_string: str | None = None, 22 | ) -> None: 23 | raise NotImplementedError(".__call__() not defined") 24 | 25 | 26 | class ArgumentParser(argparse.ArgumentParser): 27 | def add_argument(self, *args: Any, **kwargs: Any) -> ArgcompleteAction: 28 | return cast(ArgcompleteAction, super().add_argument(*args, **kwargs)) 29 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/profile_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from .list_cli import ListCommand 5 | 6 | if TYPE_CHECKING: 7 | from ..cli.completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class ListProfilesCommand( 12 | ListCommand, 13 | cmd="profiles", 14 | help="List all available profiles", 15 | ): 16 | @classmethod 17 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 18 | pass 19 | 20 | @classmethod 21 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 22 | logger = cfg.logger 23 | mr = cfg.repo 24 | 25 | for arch in mr.get_supported_arches(): 26 | for p in mr.iter_profiles_for_arch(arch): 27 | if not p.need_quirks: 28 | logger.stdout(p.id) 29 | continue 30 | 31 | logger.stdout(f"{p.id} (needs quirks: {p.need_quirks})") 32 | 33 | return 0 34 | -------------------------------------------------------------------------------- /scripts/dist-gha.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 4 | 5 | # this has to mirror the setup in the GHA workflow and scripts/dist.sh 6 | export CCACHE_DIR=/github/workspace/build-cache/ccache 7 | export POETRY_CACHE_DIR=/github/workspace/build-cache/poetry-cache 8 | export RUYI_DIST_BUILD_DIR=/github/workspace/build 9 | export RUYI_DIST_CACHE_DIR=/github/workspace/build-cache/ruyi-dist-cache 10 | export RUYI_DIST_INNER_CONTAINERIZED=x 11 | export RUYI_DIST_INNER=x 12 | export RUYI_DIST_ADDITIONAL_INDEX_URL=https://mirror.iscas.ac.cn/ruyisdk/dist/python-wheels/simple/ 13 | "$MY_DIR"/dist.sh "$@" 14 | ret=$? 15 | 16 | # fix the cache directory's ownership if necessary 17 | cache_uid="$(stat -c '%u' /github/workspace/build-cache)" 18 | workspace_uid="$(stat -c '%u' /github/workspace)" 19 | if [[ $cache_uid -ne $workspace_uid ]]; then 20 | echo "fixing ownership of build cache directory" 21 | chown -Rv --reference=/github/workspace /github/workspace/build-cache 22 | fi 23 | 24 | exit $ret 25 | -------------------------------------------------------------------------------- /scripts/install-baseline-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | main() { 6 | local pkglist=( 7 | # Package versions provided by Ubuntu 22.04 LTS. 8 | python3-argcomplete # 3.1.4 9 | python3-arpy # 1.1.1 10 | python3-certifi # 2020.6.20 11 | python3-fastjsonschema # 2.15.1 12 | python3-jinja2 # 3.0.3 13 | python3-pygit2 # 1.6.1 14 | python3-yaml # 5.4.1 # https://github.com/yaml/pyyaml/issues/724 15 | python3-requests # 2.25.1 16 | python3-rich # 11.2.0 17 | python3-semver # 2.10.2 18 | python3-tomli # 1.2.2 19 | python3-tomlkit # 0.9.2 20 | python3-typing-extensions # 3.10.0.2 21 | 22 | # for installing ourselves 23 | python3-pip 24 | 25 | # for running the test suite with purely system deps 26 | python3-pytest # 6.2.5 27 | ) 28 | 29 | export DEBIAN_FRONTEND=noninteractive 30 | export DEBCONF_NONINTERACTIVE_SEEN=true 31 | sudo apt-get update -qqy 32 | sudo apt-get install -y "${pkglist[@]}" 33 | } 34 | 35 | main "$@" 36 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/cpu.jsonschema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "CPU Entity Schema", 4 | "description": "Schema for CPU entity definitions", 5 | "type": "object", 6 | "required": ["ruyi-entity", "cpu"], 7 | "properties": { 8 | "ruyi-entity": { 9 | "type": "string", 10 | "description": "Version of the entity schema", 11 | "enum": ["v0"] 12 | }, 13 | "cpu": { 14 | "type": "object", 15 | "required": ["id", "display_name"], 16 | "properties": { 17 | "id": { 18 | "type": "string", 19 | "description": "Unique identifier for the CPU" 20 | }, 21 | "display_name": { 22 | "type": "string", 23 | "description": "Human-readable name for the CPU" 24 | } 25 | } 26 | }, 27 | "related": { 28 | "type": "array", 29 | "description": "List of related entity references", 30 | "items": { 31 | "type": "string", 32 | "pattern": "^.+:.+" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/distfile-restrict.after.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "Official WCH MounRiver Studio GNU Toolchain (bare-metal, GCC 12.x, prebuilt by WCH)" 5 | vendor = { name = "WCH", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "MRS_Toolchain_Linux_x64_V210.tar.xz" 9 | size = 548823504 10 | prefixes_to_unpack = ["RISC-V Embedded GCC12"] 11 | urls = [ 12 | "http://file-oss.mounriver.com/tools/MRS_Toolchain_Linux_x64_V210.tar.xz", 13 | ] 14 | restrict = ["mirror"] 15 | 16 | [distfiles.checksums] 17 | sha256 = "5431c040cb67cf619fd18d003ed9497a1995f59329b7f51d985dcc8013eff236" 18 | sha512 = "9aa07d4b5e173ec5f661d851f60ec88085a458188afdf21e265e47f2ef9df5ff623d0158173a9fb1620a72945c7f3dfc6dfadfc3661dee5184ba9392a8ee90a4" 19 | 20 | [[binary]] 21 | host = "x86_64" 22 | distfiles = ["MRS_Toolchain_Linux_x64_V210.tar.xz"] 23 | 24 | [toolchain] 25 | target = "riscv32-wch-elf" 26 | quirks = ["wch"] 27 | components = [ 28 | { name = "binutils", version = "2.38" }, 29 | { name = "gcc", version = "12.2.0" }, 30 | { name = "gdb", version = "12.1" }, 31 | ] 32 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/distfile-restrict.before.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "Official WCH MounRiver Studio GNU Toolchain (bare-metal, GCC 12.x, prebuilt by WCH)" 5 | vendor = { name = "WCH", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "MRS_Toolchain_Linux_x64_V210.tar.xz" 9 | size = 548823504 10 | urls = [ 11 | "http://file-oss.mounriver.com/tools/MRS_Toolchain_Linux_x64_V210.tar.xz", 12 | ] 13 | restrict = "mirror" 14 | prefixes_to_unpack = ["RISC-V Embedded GCC12"] 15 | 16 | [distfiles.checksums] 17 | sha256 = "5431c040cb67cf619fd18d003ed9497a1995f59329b7f51d985dcc8013eff236" 18 | sha512 = "9aa07d4b5e173ec5f661d851f60ec88085a458188afdf21e265e47f2ef9df5ff623d0158173a9fb1620a72945c7f3dfc6dfadfc3661dee5184ba9392a8ee90a4" 19 | 20 | [[binary]] 21 | host = "x86_64" 22 | distfiles = ["MRS_Toolchain_Linux_x64_V210.tar.xz"] 23 | 24 | [toolchain] 25 | target = "riscv32-wch-elf" 26 | flavors = ["wch"] 27 | components = [ 28 | { name = "binutils", version = "2.38" }, 29 | { name = "gcc", version = "12.2.0" }, 30 | { name = "gdb", version = "12.1" }, 31 | ] 32 | -------------------------------------------------------------------------------- /tests/utils/test_mounts.py: -------------------------------------------------------------------------------- 1 | from ruyi.utils import mounts 2 | 3 | 4 | def test_parse_mounts() -> None: 5 | sample = r""" 6 | /dev/mapper/foo / btrfs rw,noatime,ssd,discard=async,space_cache=v2,autodefrag,subvolid=5,subvol=/ 0 0 7 | devtmpfs /dev devtmpfs rw,nosuid,size=3896808k,nr_inodes=974202,mode=755,inode64 0 0 8 | tmpfs /dev/shm tmpfs rw,nosuid,nodev,inode64 0 0 9 | tmpfs /tmp/x\040b tmpfs rw,relatime,inode64 0 0 10 | """ 11 | 12 | parsed = mounts.parse_mounts(sample) 13 | assert len(parsed) == 4 14 | 15 | assert parsed[0].source == "/dev/mapper/foo" 16 | assert parsed[0].target == "/" 17 | assert parsed[0].fstype == "btrfs" 18 | assert parsed[0].options == [ 19 | "rw", 20 | "noatime", 21 | "ssd", 22 | "discard=async", 23 | "space_cache=v2", 24 | "autodefrag", 25 | "subvolid=5", 26 | "subvol=/", 27 | ] 28 | 29 | assert parsed[3].source == "tmpfs" 30 | assert parsed[3].target == "/tmp/x b" 31 | assert parsed[3].fstype == "tmpfs" 32 | assert parsed[3].options == ["rw", "relatime", "inode64"] 33 | -------------------------------------------------------------------------------- /ruyi/utils/templating.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from typing import Any, Final, Callable, Tuple 3 | 4 | from jinja2 import BaseLoader, Environment, TemplateNotFound 5 | 6 | from ..resource_bundle import get_template_str 7 | 8 | 9 | class EmbeddedLoader(BaseLoader): 10 | def __init__(self) -> None: 11 | pass 12 | 13 | def get_source( 14 | self, 15 | environment: Environment, 16 | template: str, 17 | ) -> Tuple[str, str | None, Callable[[], bool] | None]: 18 | if payload := get_template_str(template): 19 | return payload, None, None 20 | raise TemplateNotFound(template) 21 | 22 | 23 | _JINJA_ENV: Final = Environment( 24 | loader=EmbeddedLoader(), 25 | autoescape=False, # we're not producing HTML 26 | auto_reload=False, # we're serving statically embedded assets 27 | keep_trailing_newline=True, # to make shells happy 28 | ) 29 | _JINJA_ENV.filters["sh"] = shlex.quote 30 | 31 | 32 | def render_template_str(template_name: str, data: dict[str, Any]) -> str: 33 | tmpl = _JINJA_ENV.get_template(template_name) 34 | return tmpl.render(data) 35 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/arch.jsonschema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Architecture Entity Schema", 4 | "description": "Schema for architecture entity definitions", 5 | "type": "object", 6 | "required": ["ruyi-entity", "arch"], 7 | "properties": { 8 | "ruyi-entity": { 9 | "type": "string", 10 | "description": "Version of the entity schema", 11 | "enum": ["v0"] 12 | }, 13 | "arch": { 14 | "type": "object", 15 | "required": ["id", "display_name"], 16 | "properties": { 17 | "id": { 18 | "type": "string", 19 | "description": "Unique identifier for the architecture" 20 | }, 21 | "display_name": { 22 | "type": "string", 23 | "description": "Human-readable name for the architecture" 24 | } 25 | } 26 | }, 27 | "related": { 28 | "type": "array", 29 | "description": "List of related entity references", 30 | "items": { 31 | "type": "string", 32 | "pattern": "^.+:.+" 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ruyi/pluginhost/plugin_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from ..cli.cmd import AdminCommand 5 | 6 | if TYPE_CHECKING: 7 | from ..cli.completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class AdminRunPluginCommand( 12 | AdminCommand, 13 | cmd="run-plugin-cmd", 14 | help="Run a plugin-defined command", 15 | ): 16 | @classmethod 17 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 18 | p.add_argument( 19 | "cmd_name", 20 | type=str, 21 | metavar="COMMAND-NAME", 22 | help="Command name", 23 | ) 24 | p.add_argument( 25 | "cmd_args", 26 | type=str, 27 | nargs="*", 28 | metavar="COMMAND-ARG", 29 | help="Arguments to pass to the plugin command", 30 | ) 31 | 32 | @classmethod 33 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 34 | cmd_name = args.cmd_name 35 | cmd_args = args.cmd_args 36 | 37 | return cfg.repo.run_plugin_cmd(cmd_name, cmd_args) 38 | -------------------------------------------------------------------------------- /resources/bundled/ruyi-venv.toml.jinja: -------------------------------------------------------------------------------- 1 | [config] 2 | profile = "{{ profile }}" 3 | {% if sysroot -%} 4 | sysroot = "{{ sysroot }}" 5 | {%- endif %} 6 | 7 | [metadata] 8 | version = 1 9 | 10 | {% macro pkg_metadata(p) -%} 11 | repo_id = "{{ p['repo_id'] }}" 12 | category = "{{ p['category'] }}" 13 | name = "{{ p['name'] }}" 14 | version = "{{ p['version'] }}" 15 | {%- endmacro %} 16 | 17 | {% if metadata["sysroot_pkg"] %} 18 | [metadata.packages.sysroot] 19 | {{ pkg_metadata(metadata["sysroot_pkg"]) }} 20 | {% endif %} 21 | 22 | {% if metadata["emulator_pkgs"] %} 23 | {% for target_arch, p in metadata["emulator_pkgs"].items() -%} 24 | [metadata.packages.emulator."{{ target_arch }}"] 25 | {{ pkg_metadata(p) }} 26 | {% endfor %} 27 | {% endif %} 28 | 29 | {% if metadata["extra_pkgs"] %} 30 | {% for p in metadata["extra_pkgs"] -%} 31 | [[metadata.packages.extra]] 32 | {{ pkg_metadata(p) }} 33 | {% endfor %} 34 | {% endif %} 35 | 36 | {% if metadata["toolchain_pkgs"] %} 37 | {% for target_arch, p in metadata["toolchain_pkgs"].items() -%} 38 | [metadata.packages.toolchain."{{ target_arch }}"] 39 | {{ pkg_metadata(p) }} 40 | {% endfor %} 41 | {% endif %} 42 | -------------------------------------------------------------------------------- /ruyi/telemetry/event.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, TypeGuard, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from typing_extensions import NotRequired 5 | 6 | 7 | class TelemetryEvent(TypedDict): 8 | fmt: int 9 | time_bucket: "NotRequired[str]" # canonically "YYYYMMDDHHMM" 10 | kind: str 11 | params: dict[str, object] 12 | 13 | 14 | def is_telemetry_event(x: object) -> TypeGuard[TelemetryEvent]: 15 | if not isinstance(x, dict): 16 | return False 17 | 18 | if not 3 <= len(x.keys()) <= 4: 19 | return False 20 | 21 | try: 22 | if not isinstance(x["fmt"], int): 23 | return False 24 | if not isinstance(x["kind"], str): 25 | return False 26 | if not isinstance(x["params"], dict): 27 | return False 28 | except KeyError: 29 | return False 30 | 31 | try: 32 | if not isinstance(x["time_bucket"], str): 33 | return False 34 | if len(x["time_bucket"]) != 12: 35 | return False 36 | if not x["time_bucket"].isdigit(): 37 | return False 38 | except KeyError: 39 | pass 40 | 41 | return True 42 | -------------------------------------------------------------------------------- /ruyi/device/provision_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from ..cli.cmd import RootCommand 5 | 6 | if TYPE_CHECKING: 7 | from ..cli.completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class DeviceCommand( 12 | RootCommand, 13 | cmd="device", 14 | has_subcommands=True, 15 | help="Manage devices", 16 | ): 17 | @classmethod 18 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 19 | pass 20 | 21 | 22 | class DeviceProvisionCommand( 23 | DeviceCommand, 24 | cmd="provision", 25 | aliases=["flash"], 26 | help="Interactively initialize a device for development", 27 | ): 28 | @classmethod 29 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 30 | pass 31 | 32 | @classmethod 33 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 34 | from .provision import do_provision_interactive 35 | 36 | try: 37 | return do_provision_interactive(cfg) 38 | except KeyboardInterrupt: 39 | cfg.logger.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n") 40 | return 1 41 | -------------------------------------------------------------------------------- /ruyi/utils/frontmatter.py: -------------------------------------------------------------------------------- 1 | # Minimal frontmatter support for Markdown, because [python-frontmatter] is 2 | # not packaged in major Linux distributions, complicating packaging work. 3 | # 4 | # Only the YAML frontmatter is supported here, unlike python-frontmatter 5 | # which supports additionally JSON and TOML frontmatter formats. 6 | # 7 | # [python-frontmatter]: https://github.com/eyeseast/python-frontmatter 8 | 9 | import re 10 | from typing import Final 11 | import yaml 12 | 13 | 14 | FRONTMATTER_BOUNDARY_RE: Final = re.compile(r"(?m)^-{3,}\s*$") 15 | 16 | 17 | class Post: 18 | def __init__(self, metadata: dict[str, object] | None, content: str) -> None: 19 | self._md = metadata 20 | self.content = content 21 | 22 | def get(self, key: str) -> object | None: 23 | return None if self._md is None else self._md.get(key) 24 | 25 | 26 | def loads(s: str) -> Post: 27 | m = FRONTMATTER_BOUNDARY_RE.match(s) 28 | if m is None: 29 | return Post(None, s) 30 | 31 | x = FRONTMATTER_BOUNDARY_RE.split(s, 2) 32 | if len(x) != 3: 33 | return Post(None, s) 34 | 35 | fm, content = x[1], x[2] 36 | 37 | metadata = yaml.safe_load(fm) 38 | return Post(metadata, content) 39 | -------------------------------------------------------------------------------- /ruyi/config/news.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class NewsReadStatusStore: 5 | def __init__(self, path: str) -> None: 6 | self._path = path 7 | self._status: set[str] = set() 8 | self._orig_status: set[str] = set() 9 | 10 | def load(self) -> None: 11 | try: 12 | with open(self._path, "r", encoding="utf-8") as fp: 13 | for line in fp: 14 | self._orig_status.add(line.strip()) 15 | except FileNotFoundError: 16 | return 17 | 18 | self._status = self._orig_status.copy() 19 | 20 | def __contains__(self, key: str) -> bool: 21 | return key in self._status 22 | 23 | def add(self, id: str) -> None: 24 | return self._status.add(id) 25 | 26 | def save(self) -> None: 27 | if self._status == self._orig_status: 28 | return 29 | 30 | content = "".join(f"{id}\n" for id in self._status) 31 | with open(self._path, "w", encoding="utf-8") as fp: 32 | fp.write(content) 33 | 34 | def remove(self) -> None: 35 | try: 36 | os.unlink(self._path) 37 | except FileNotFoundError: 38 | # nothing to remove, that's fine 39 | pass 40 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/example-board-image.before.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | format = "v1" 3 | [[distfiles]] 4 | name = "u-boot-with-spl-meles-16g.bin" 5 | size = 780496 6 | urls = [ "https://mirror.iscas.ac.cn/revyos/extra/images/meles/20250323/u-boot-with-spl-meles-16g.bin",] 7 | restrict = [ "mirror",] 8 | 9 | [distfiles.checksums] 10 | sha256 = "1551ae525700dc6352f42b7d5280f9bd9a383f2d2f9dae8b129388c784eed5e0" 11 | sha512 = "90d7413459d232c3321c9661b8b9d3201e6efc0ed9b681bb51ddf433addc3b784704bfa01f9c8e8f8b38cd4e031ebe7899d6d6c3c091e28d3998907ae2b19256" 12 | 13 | [metadata] 14 | desc = "U-Boot image for Milk-V Meles (16G RAM) and RevyOS 20250323" 15 | upstream_version = "20250323" 16 | [[metadata.service_level]] 17 | level = "good" 18 | 19 | [blob] 20 | distfiles = [ "u-boot-with-spl-meles-16g.bin",] 21 | 22 | [provisionable] 23 | strategy = "fastboot-v1" 24 | 25 | [metadata.vendor] 26 | name = "Milk-V" 27 | eula = "" 28 | 29 | [provisionable.partition_map] 30 | uboot = "u-boot-with-spl-meles-16g.bin" 31 | 32 | # This file is created by program Sync Package Index inside support-matrix 33 | # Run ID: 14662502620 34 | # Run URL: https://github.com/wychlw/support-matrix/actions/runs/14662502620 35 | -------------------------------------------------------------------------------- /ruyi/utils/nuitka.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def get_nuitka_self_exe() -> str: 6 | try: 7 | # Assume we're a Nuitka onefile build, so our parent process is the onefile 8 | # bootstrap process. The onefile bootstrapper puts "our path" in the 9 | # undocumented environment variable $NUITKA_ONEFILE_BINARY, which works 10 | # on both Linux and Windows. 11 | return os.environ["NUITKA_ONEFILE_BINARY"] 12 | except KeyError: 13 | # It seems we are instead launched from the extracted onefile tempdir. 14 | # Assume our name is "ruyi" in this case; directory is available in 15 | # Nuitka metadata. 16 | import ruyi 17 | 18 | return os.path.join(ruyi.__compiled__.containing_dir, "ruyi") 19 | 20 | 21 | def get_argv0() -> str: 22 | import ruyi 23 | 24 | try: 25 | if ruyi.__compiled__.original_argv0 is not None: 26 | return ruyi.__compiled__.original_argv0 27 | except AttributeError: 28 | # Either we're not packaged with Nuitka, or the Nuitka used is 29 | # without our original_argv0 patch, in which case we cannot do any 30 | # better than simply returning sys.argv[0]. 31 | pass 32 | 33 | return sys.argv[0] 34 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/example-board-image.after.toml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | format = "v1" 4 | 5 | [metadata] 6 | desc = "U-Boot image for Milk-V Meles (16G RAM) and RevyOS 20250323" 7 | vendor = { name = "Milk-V", eula = "" } 8 | upstream_version = "20250323" 9 | 10 | [[metadata.service_level]] 11 | level = "good" 12 | 13 | [[distfiles]] 14 | name = "u-boot-with-spl-meles-16g.bin" 15 | size = 780496 16 | urls = [ 17 | "https://mirror.iscas.ac.cn/revyos/extra/images/meles/20250323/u-boot-with-spl-meles-16g.bin", 18 | ] 19 | restrict = ["mirror"] 20 | 21 | [distfiles.checksums] 22 | sha256 = "1551ae525700dc6352f42b7d5280f9bd9a383f2d2f9dae8b129388c784eed5e0" 23 | sha512 = "90d7413459d232c3321c9661b8b9d3201e6efc0ed9b681bb51ddf433addc3b784704bfa01f9c8e8f8b38cd4e031ebe7899d6d6c3c091e28d3998907ae2b19256" 24 | 25 | [blob] 26 | distfiles = [ 27 | "u-boot-with-spl-meles-16g.bin", 28 | ] 29 | 30 | [provisionable] 31 | strategy = "fastboot-v1" 32 | 33 | [provisionable.partition_map] 34 | uboot = "u-boot-with-spl-meles-16g.bin" 35 | 36 | # This file is created by program Sync Package Index inside support-matrix 37 | # Run ID: 14662502620 38 | # Run URL: https://github.com/wychlw/support-matrix/actions/runs/14662502620 39 | -------------------------------------------------------------------------------- /resources/release-notes-header-template.md: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 此处提供的是 `ruyi` 的单文件二进制发行版,适用于短平快的实验或部署场景。这些二进制也可以在 [RuyiSDK 镜像站][release-mirror]下载到。 6 | 7 | 我们更推荐通过 PyPI 安装 `ruyi`: `pip install ruyi` 或者您选用的 Python 包管理工具的等价命令。 8 | 9 | 也请您查阅[官方文档站][docs-zh]([English][docs-en])以了解使用方法。 10 | 11 | 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. 12 | 13 | We recommend installing `ruyi` via PyPI instead: `pip install ruyi` or the equivalent with any Python package manager of your choice. 14 | 15 | Please also consult [the official documentation site][docs-en] ([中文][docs-zh]) for usage instructions. 16 | 17 | > [!NOTE] 18 | > 对于 `ruyi` 的单文件二进制发行版,您必须将下载的文件重命名为一字不差的 `ruyi` 才能正常使用。 19 | > 20 | > For one-file binary distributions of `ruyi`, you will have to rename the downloaded binary to exactly `ruyi` before using. 21 | 22 | [release-mirror]: @RELEASE_MIRROR_URL@ 23 | [docs-en]: https://ruyisdk.org/en/docs/intro/ 24 | [docs-zh]: https://ruyisdk.org/docs/intro/ 25 | -------------------------------------------------------------------------------- /tests/ruyipkg/test_host.py: -------------------------------------------------------------------------------- 1 | from ruyi.ruyipkg import host 2 | 3 | 4 | def test_canonicalize_arch() -> None: 5 | testcases = [ 6 | ("AMD64", "x86_64"), 7 | ("amd64", "x86_64"), 8 | ("EM64T", "x86_64"), 9 | ("ARM64", "aarch64"), 10 | ("arm64", "aarch64"), 11 | ("x86_64", "x86_64"), 12 | ("aarch64", "aarch64"), 13 | ("riscv64", "riscv64"), 14 | ] 15 | 16 | for input, expected in testcases: 17 | assert host.canonicalize_arch_str(input) == expected 18 | 19 | 20 | def test_canonicalize_host() -> None: 21 | assert host.canonicalize_host_str("arm64") == "linux/aarch64" 22 | assert host.canonicalize_host_str("aarch64") == "linux/aarch64" 23 | assert host.canonicalize_host_str("darwin/arm64") == "darwin/aarch64" 24 | assert host.canonicalize_host_str("linux/riscv64") == "linux/riscv64" 25 | assert host.canonicalize_host_str("riscv64") == "linux/riscv64" 26 | assert host.canonicalize_host_str("win32/AMD64") == "windows/x86_64" 27 | assert host.canonicalize_host_str("win32/ARM64") == "windows/aarch64" 28 | assert host.canonicalize_host_str("x86_64") == "linux/x86_64" 29 | 30 | assert ( 31 | host.canonicalize_host_str(host.RuyiHost("win32", "AMD64")) == "windows/x86_64" 32 | ) 33 | -------------------------------------------------------------------------------- /scripts/patches/nuitka/0001-workaround-libatomic-linkage-for-static-libpython-on.patch: -------------------------------------------------------------------------------- 1 | From 3fdc249394b912d347165af9d4d1f9910f06c482 Mon Sep 17 00:00:00 2001 2 | From: WANG Xuerui 3 | Date: Tue, 8 Apr 2025 21:26:00 +0800 4 | Subject: [PATCH 1/2] workaround libatomic linkage for static libpython on 5 | riscv 6 | 7 | --- 8 | nuitka/build/Backend.scons | 4 ++++ 9 | 1 file changed, 4 insertions(+) 10 | 11 | diff --git a/nuitka/build/Backend.scons b/nuitka/build/Backend.scons 12 | index f2f0016ba..ffc71984f 100644 13 | --- a/nuitka/build/Backend.scons 14 | +++ b/nuitka/build/Backend.scons 15 | @@ -14,6 +14,7 @@ build process for itself, although it can be compiled using the same method. 16 | 17 | import sys 18 | import os 19 | +import platform 20 | import types 21 | 22 | sys.modules["nuitka"] = types.ModuleType("nuitka") 23 | @@ -760,6 +761,9 @@ elif env.exe_mode or env.dll_mode: 24 | if python_prefix_external != "/usr" and "linux" in sys.platform: 25 | env.Append(LIBS=["dl", "pthread", "util", "rt", "m"]) 26 | 27 | + if platform.machine().startswith("riscv"): 28 | + env.Append(LIBS=["atomic"]) 29 | + 30 | if env.gcc_mode: 31 | if clang_mode: 32 | env.Append(LINKFLAGS=["-Wl,--export-dynamic"]) 33 | -- 34 | 2.48.1 35 | 36 | -------------------------------------------------------------------------------- /docs/ci-release-automation.md: -------------------------------------------------------------------------------- 1 | # CI: 自动化版本发布 2 | 3 | 为方便、规范 RuyiSDK 的发版工作,有必要将这些工作自动化。目前,RuyiSDK 4 | 包管理器(`ruyi`,也即本仓库)已经接入了自动化发版机制。 5 | 6 | ## `ruyi` 的发版自动化 7 | 8 | 详见本仓库的 GitHub Actions workflow 定义。 9 | 10 | ### RuyiSDK 镜像源的同步 11 | 12 | 在 GHA 自动创建 Release 之后,为将此 release 同步进 RuyiSDK 镜像源,从而方便境内用户下载使用,需要额外做一些工作。 13 | 14 | 理想情况下,这可用一个监听 release 类型消息的 GitHub Webhook 实现,但这要求部署一个 15 | HTTP 服务,从而造成很大的额外运维成本。因此,目前使用一种开销相对较高但仍可接受的方式:轮询,来实现与 16 | GitHub Release 的同步。 17 | 18 | 首先准备一台既能访问 GitHub API、GitHub Release assets,又有权限访问 RuyiSDK 19 | rsync 镜像源的 Linux 主机,用来部署 helper 服务。 20 | 21 | 在此主机上准备一个 `ruyi-backend` 的开发环境: 22 | 23 | ```sh 24 | git clone https://github.com/ruyisdk/ruyi-backend.git 25 | cd ruyi-backend 26 | # 略过了初始化 Python virtualenv 的步骤 27 | poetry install 28 | ``` 29 | 30 | 准备一个目录,用于存储 rsync 同步状态与相关的 release assets: 31 | 32 | ```sh 33 | # 假设以 /opt/ruyi-tmp-rsync 为状态目录 34 | mkdir /opt/ruyi-tmp-rsync 35 | ``` 36 | 37 | 配置系统,使此任务被周期性执行。您可参考 [ruyi-backend 项目随附的 systemd 单元定义][systemd-example-units]: 38 | 39 | [systemd-example-units]: https://github.com/ruyisdk/ruyi-backend/tree/main/examples/systemd 40 | 41 | ```sh 42 | # 把示例单元文件复制到 /etc/systemd/system/ 然后调整其内容以适应您的环境 43 | systemctl daemon-reload 44 | systemctl enable ruyi-ci-sync-release.timer 45 | ``` 46 | 47 | 后续,应不时更新此 `ruyi-backend` checkout,并跟进依赖版本变更、此处的流程变更等等。 48 | -------------------------------------------------------------------------------- /ruyi/mux/venv/emulator_cfg.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from typing_extensions import Self 6 | 7 | from ...ruyipkg.pkg_manifest import EmulatorProgDecl 8 | from ...ruyipkg.profile import ProfileProxy 9 | 10 | 11 | class ResolvedEmulatorProg: 12 | def __init__( 13 | self, 14 | display_name: str, 15 | binfmt_misc_str: str | None, 16 | env: dict[str, str] | None, 17 | ) -> None: 18 | self.display_name = display_name 19 | self.binfmt_misc_str = binfmt_misc_str 20 | self.env = env 21 | 22 | @classmethod 23 | def new( 24 | cls, 25 | prog: EmulatorProgDecl, 26 | prog_install_root: os.PathLike[Any], 27 | profile: ProfileProxy, 28 | sysroot: os.PathLike[Any] | None, 29 | ) -> "Self": 30 | return cls( 31 | get_display_name_for_emulator(prog, prog_install_root), 32 | prog.get_binfmt_misc_str(prog_install_root), 33 | profile.get_env_config_for_emu_flavor(prog.flavor, sysroot), 34 | ) 35 | 36 | 37 | def get_display_name_for_emulator( 38 | prog: EmulatorProgDecl, 39 | prog_install_root: os.PathLike[Any], 40 | ) -> str: 41 | return f"{os.path.basename(prog.relative_path)} from {prog_install_root}" 42 | -------------------------------------------------------------------------------- /ruyi/telemetry/scope.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypeAlias, TypeGuard 2 | 3 | TelemetryScopeConfig: TypeAlias = Literal["pm"] | Literal["repo"] 4 | 5 | 6 | def is_telemetry_scope_config(x: object) -> TypeGuard[TelemetryScopeConfig]: 7 | if not isinstance(x, str): 8 | return False 9 | match x: 10 | case "pm" | "repo": 11 | return True 12 | case _: 13 | return False 14 | 15 | 16 | class TelemetryScope: 17 | def __init__(self, repo_name: str | None) -> None: 18 | self._repo_name = repo_name 19 | 20 | def __repr__(self) -> str: 21 | return f"TelemetryScope(repo_name={self._repo_name})" 22 | 23 | def __str__(self) -> str: 24 | if self._repo_name: 25 | return f"repo:{self._repo_name}" 26 | return "pm" 27 | 28 | def __hash__(self) -> int: 29 | # behave like the inner field 30 | return hash(self._repo_name) 31 | 32 | def __eq__(self, value: object) -> bool: 33 | if not isinstance(value, TelemetryScope): 34 | return False 35 | return self._repo_name == value._repo_name 36 | 37 | @property 38 | def repo_name(self) -> str | None: 39 | return self._repo_name 40 | 41 | @property 42 | def is_pm(self) -> bool: 43 | return self._repo_name is None 44 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/cli_completion.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from ..cli.completer import DynamicCompleter 5 | from ..config import GlobalConfig 6 | 7 | 8 | def package_completer_builder( 9 | cfg: "GlobalConfig", 10 | filters: list[Callable[[str], bool]] | None = None, 11 | ) -> "DynamicCompleter": 12 | # Lazy import to avoid circular dependency 13 | from ..ruyipkg.augmented_pkg import ( 14 | AugmentedPkg, 15 | ) # pylint: disable=import-outside-toplevel 16 | from ..ruyipkg.list_filter import ( 17 | ListFilter, 18 | ) # pylint: disable=import-outside-toplevel 19 | 20 | all_pkgs = list( 21 | AugmentedPkg.yield_from_repo( 22 | cfg, 23 | cfg.repo, 24 | ListFilter(), 25 | ensure_repo=False, 26 | ) 27 | ) 28 | if filters is not None: 29 | all_pkgs = [ 30 | pkg 31 | for pkg in all_pkgs 32 | if pkg.name is not None and all(f(pkg.name) for f in filters) 33 | ] 34 | 35 | def f(prefix: str, parsed_args: object, **kwargs: Any) -> list[str]: 36 | return [ 37 | pkg.name 38 | for pkg in all_pkgs 39 | if pkg.name is not None and pkg.name.startswith(prefix) 40 | ] 41 | 42 | return f 43 | -------------------------------------------------------------------------------- /tests/utils/test_toml.py: -------------------------------------------------------------------------------- 1 | import tomlkit 2 | 3 | from ruyi.utils.toml import ( 4 | extract_footer_comments, 5 | extract_header_comments, 6 | ) 7 | 8 | 9 | def test_extract_header_comments() -> None: 10 | a1 = """[foo] 11 | a = 2 12 | """ 13 | assert extract_header_comments(tomlkit.parse(a1)) == [] 14 | 15 | a2 = """ 16 | 17 | # foo 18 | 19 | # bar 20 | 21 | a = 2 22 | # baz 23 | """ 24 | 25 | assert extract_header_comments(tomlkit.parse(a2)) == [ 26 | "# foo\n", 27 | "\n", 28 | "# bar\n", 29 | "\n", 30 | ] 31 | 32 | a3 = """ 33 | # baz 34 | # quux 35 | 36 | [a] 37 | b = 2 38 | """ 39 | 40 | assert extract_header_comments(tomlkit.parse(a3)) == ["# baz\n", "# quux\n", "\n"] 41 | 42 | 43 | def test_extract_footer_comments() -> None: 44 | a1 = """[foo] 45 | a = 2 46 | """ 47 | assert extract_footer_comments(tomlkit.parse(a1)) == [] 48 | 49 | a2 = """ 50 | a = 2 51 | # foo 52 | # bar 53 | """ 54 | 55 | assert extract_footer_comments(tomlkit.parse(a2)) == ["# foo\n", "# bar\n"] 56 | 57 | a3 = """# foo 58 | 59 | # bar 60 | 61 | [a] 62 | foo = 2 63 | 64 | # baz 65 | # baz2 66 | 67 | # quux 68 | """ 69 | 70 | assert extract_footer_comments(tomlkit.parse(a3)) == [ 71 | "\n", 72 | "# baz\n", 73 | "# baz2\n", 74 | "\n", 75 | "# quux\n", 76 | ] 77 | -------------------------------------------------------------------------------- /ruyi/utils/mounts.py: -------------------------------------------------------------------------------- 1 | """Utilities for parsing mount information from /proc/self/mounts.""" 2 | 3 | import pathlib 4 | import re 5 | from typing import NamedTuple 6 | 7 | 8 | class MountInfo(NamedTuple): 9 | source: str 10 | target: str 11 | fstype: str 12 | options: list[str] 13 | 14 | @property 15 | def source_path(self) -> pathlib.Path: 16 | return pathlib.Path(self.source) 17 | 18 | @property 19 | def source_is_blkdev(self) -> bool: 20 | return self.source_path.is_block_device() 21 | 22 | 23 | def parse_mounts(contents: str | None = None) -> list[MountInfo]: 24 | if contents is None: 25 | try: 26 | with open("/proc/self/mounts", "r", encoding="utf-8") as f: 27 | contents = f.read() 28 | except OSError: 29 | return [] 30 | 31 | mounts: list[MountInfo] = [] 32 | for line in contents.splitlines(): 33 | parts = line.split() 34 | if len(parts) < 4: 35 | continue 36 | source, target, fstype, opts = parts[:4] 37 | options = opts.split(",") 38 | source = _unescape_octals(source) 39 | target = _unescape_octals(target) 40 | mounts.append(MountInfo(source, target, fstype, options)) 41 | return mounts 42 | 43 | 44 | def _unescape_octals(s: str) -> str: 45 | return re.sub(r"\\([0-3][0-7]{2})", lambda m: chr(int(m.group(1), 8)), s) 46 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/checksum.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import BinaryIO, Final, Iterable 3 | 4 | SUPPORTED_CHECKSUM_KINDS: Final = {"sha256", "sha512"} 5 | 6 | 7 | def get_hash_instance(kind: str) -> "hashlib._Hash": 8 | if kind not in SUPPORTED_CHECKSUM_KINDS: 9 | raise ValueError(f"checksum algorithm {kind} not supported") 10 | return hashlib.new(kind) 11 | 12 | 13 | class Checksummer: 14 | def __init__(self, file: BinaryIO, checksums: dict[str, str]) -> None: 15 | self.file = file 16 | self.checksums = checksums 17 | 18 | def check(self) -> None: 19 | computed_csums = self.compute() 20 | for kind, expected_csum in self.checksums.items(): 21 | if computed_csums[kind] != expected_csum: 22 | raise ValueError( 23 | f"wrong {kind} checksum: want {expected_csum}, got {computed_csums[kind]}" 24 | ) 25 | 26 | def compute( 27 | self, 28 | kinds: Iterable[str] | None = None, 29 | chunksize: int = 4096, 30 | ) -> dict[str, str]: 31 | if kinds is None: 32 | kinds = self.checksums.keys() 33 | 34 | checksummers = {kind: get_hash_instance(kind) for kind in kinds} 35 | while chunk := self.file.read(chunksize): 36 | for h in checksummers.values(): 37 | h.update(chunk) 38 | 39 | return {kind: h.hexdigest() for kind, h in checksummers.items()} 40 | -------------------------------------------------------------------------------- /tests/ruyipkg/test_unpack.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | from ruyi.log import RuyiLogger 5 | from ruyi.ruyipkg import unpack 6 | from ruyi.ruyipkg.unpack_method import UnpackMethod, determine_unpack_method 7 | 8 | from ..fixtures import RuyiFileFixtureFactory 9 | 10 | 11 | def test_unpack_deb( 12 | ruyi_file: RuyiFileFixtureFactory, 13 | ruyi_logger: RuyiLogger, 14 | tmp_path: pathlib.Path, 15 | ) -> None: 16 | with ruyi_file.path("cpp-for-host_14-20240120-6_riscv64.deb") as p: 17 | assert determine_unpack_method(str(p)) == UnpackMethod.DEB 18 | unpack.do_unpack( 19 | ruyi_logger, 20 | str(p), 21 | str(tmp_path), 22 | 0, 23 | UnpackMethod.DEB, 24 | None, 25 | ) 26 | check = tmp_path / "usr" / "share" / "doc" / "cpp-for-host" 27 | if sys.version_info >= (3, 12): 28 | assert check.exists(follow_symlinks=False) 29 | else: 30 | # Python 3.11 lacks pathlib.Path.exists(follow_symlinks) 31 | # 32 | # we know that this path is going to be a symlink so simply 33 | # ensuring it's existent is enough; asserting that it is dangling 34 | # risks breaking CI on systems where the target actually exists 35 | assert check.lstat() is not None 36 | assert check.is_symlink() 37 | assert str(check.readlink()) == "cpp-riscv64-linux-gnu" 38 | -------------------------------------------------------------------------------- /ruyi/cli/version_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from .cmd import RootCommand 5 | 6 | if TYPE_CHECKING: 7 | from .completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class VersionCommand( 12 | RootCommand, 13 | cmd="version", 14 | help="Print version information", 15 | ): 16 | @classmethod 17 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 18 | pass 19 | 20 | @classmethod 21 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 22 | return cli_version(cfg, args) 23 | 24 | 25 | def cli_version(cfg: "GlobalConfig", args: argparse.Namespace) -> int: 26 | import ruyi 27 | from ..ruyipkg.host import get_native_host 28 | from ..version import COPYRIGHT_NOTICE, MPL_REDIST_NOTICE, RUYI_SEMVER 29 | 30 | print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.") 31 | 32 | if cfg.is_installation_externally_managed: 33 | print("This Ruyi installation is externally managed.") 34 | 35 | print() 36 | 37 | cfg.logger.stdout(COPYRIGHT_NOTICE) 38 | 39 | # Output the MPL notice only when we actually bundle and depend on the 40 | # MPL component(s), which right now is only certifi. Keep the condition 41 | # synced with __main__.py. 42 | if hasattr(ruyi, "__compiled__") and ruyi.__compiled__.standalone: 43 | cfg.logger.stdout(MPL_REDIST_NOTICE) 44 | 45 | return 0 46 | -------------------------------------------------------------------------------- /tests/integration/test_cli_basic.py: -------------------------------------------------------------------------------- 1 | from tests.fixtures import IntegrationTestHarness 2 | 3 | 4 | def test_cli_version(ruyi_cli_runner: IntegrationTestHarness) -> None: 5 | for argv in [ 6 | ["--version"], 7 | ["version"], 8 | ]: 9 | result = ruyi_cli_runner(*argv) 10 | assert result.exit_code == 0 11 | assert "Ruyi" in result.stdout 12 | assert "fatal error" not in result.stderr.lower() 13 | 14 | 15 | def test_cli_list_with_mock_repo(ruyi_cli_runner: IntegrationTestHarness) -> None: 16 | result = ruyi_cli_runner("list", "--name-contains", "sample-cli") 17 | 18 | assert result.exit_code == 0 19 | assert "dev-tools/sample-cli" in result.stdout 20 | 21 | 22 | def test_cli_list_with_custom_package(ruyi_cli_runner: IntegrationTestHarness) -> None: 23 | sha_stub = "1" * 64 24 | manifest = ( 25 | 'format = "v1"\n' 26 | 'kind = ["source"]\n\n' 27 | "[metadata]\n" 28 | 'desc = "Custom integration package"\n' 29 | 'vendor = { name = "Integration Tests", eula = "" }\n\n' 30 | "[[distfiles]]\n" 31 | 'name = "custom-src.tar.zst"\n' 32 | "size = 0\n\n" 33 | "[distfiles.checksums]\n" 34 | f'sha256 = "{sha_stub}"\n' 35 | ) 36 | ruyi_cli_runner.add_package("examples", "custom-cli", "0.1.0", manifest) 37 | 38 | result = ruyi_cli_runner("list", "--category-is", "examples") 39 | 40 | assert result.exit_code == 0 41 | assert "examples/custom-cli" in result.stdout 42 | -------------------------------------------------------------------------------- /stubs/fastjsonschema/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, Mapping 2 | 3 | from .exceptions import ( 4 | JsonSchemaDefinitionException as JsonSchemaDefinitionException, 5 | JsonSchemaException as JsonSchemaException, 6 | JsonSchemaValueException as JsonSchemaValueException, 7 | ) 8 | from .version import VERSION as VERSION 9 | 10 | __all__ = [ 11 | "VERSION", 12 | "JsonSchemaException", 13 | "JsonSchemaValueException", 14 | "JsonSchemaDefinitionException", 15 | "validate", 16 | "compile", 17 | "compile_to_code", 18 | ] 19 | 20 | def validate( 21 | definition: object, 22 | data: object, 23 | handlers: Mapping[str, Callable[[str], object]] = {}, 24 | formats: Mapping[str, str | Callable[[object], bool]] = {}, 25 | use_default: bool = True, 26 | use_formats: bool = True, 27 | detailed_exceptions: bool = True, 28 | ): ... 29 | def compile( 30 | definition: object, 31 | handlers: Mapping[str, Callable[[str], object]] = {}, 32 | formats: Mapping[str, str | Callable[[object], bool]] = {}, 33 | use_default: bool = True, 34 | use_formats: bool = True, 35 | detailed_exceptions: bool = True, 36 | ) -> Callable[[object], object | None]: ... 37 | def compile_to_code( 38 | definition: object, 39 | handlers: Mapping[str, Callable[[str], object]] = {}, 40 | formats: Mapping[str, str | Callable[[object], bool]] = {}, 41 | use_default: bool = True, 42 | use_formats: bool = True, 43 | detailed_exceptions: bool = True, 44 | ) -> str: ... 45 | -------------------------------------------------------------------------------- /tests/ruyipkg/test_format_manifest.py: -------------------------------------------------------------------------------- 1 | import tomlkit 2 | 3 | from ruyi.ruyipkg.canonical_dump import dumps_canonical_package_manifest_toml 4 | from ruyi.ruyipkg.pkg_manifest import PackageManifest 5 | 6 | from ..fixtures import RuyiFileFixtureFactory 7 | 8 | 9 | def test_format_manifest(ruyi_file: RuyiFileFixtureFactory) -> None: 10 | with ruyi_file.path("ruyipkg_suites", "format_manifest") as fixtures_dir: 11 | # Find pairs of before/after files 12 | files = list(fixtures_dir.glob("*.toml")) 13 | cases = [f.name[:-12] for f in files if f.name.endswith(".before.toml")] 14 | 15 | for case_name in cases: 16 | # Determine the expected output file name 17 | before_file = fixtures_dir / f"{case_name}.before.toml" 18 | after_file = fixtures_dir / f"{case_name}.after.toml" 19 | assert after_file.exists(), f"Expected file {after_file} does not exist" 20 | 21 | with open(before_file, "rb") as f: 22 | data = PackageManifest(tomlkit.load(f)) 23 | 24 | # Process with the formatter 25 | result = dumps_canonical_package_manifest_toml(data) 26 | 27 | # Read the expected output 28 | with open(after_file, "r", encoding="utf-8") as g: 29 | expected = g.read() 30 | 31 | assert result == expected, ( 32 | f"Formatted output for {before_file.name} doesn't match expected output. " 33 | f"Check {after_file.name} for the expected formatting result." 34 | ) 35 | -------------------------------------------------------------------------------- /resources/bundled/ruyi-activate.bash.jinja: -------------------------------------------------------------------------------- 1 | # This file must be used with "source bin/ruyi-activate" *from bash* 2 | # you cannot run it directly 3 | 4 | 5 | if [ "${BASH_SOURCE-}" = "$0" ]; then 6 | echo "You must source this script: \$ source $0" >&2 7 | exit 33 8 | fi 9 | 10 | ruyi-deactivate () { 11 | # reset old environment variables 12 | # ! [ -z ${VAR+_} ] returns true if VAR is declared at all 13 | if ! [ -z "${_RUYI_OLD_PATH:+_}" ] ; then 14 | PATH="$_RUYI_OLD_PATH" 15 | export PATH 16 | unset _RUYI_OLD_PATH 17 | fi 18 | 19 | # invalidate the PATH cache 20 | hash -r 2>/dev/null 21 | 22 | if ! [ -z "${_RUYI_OLD_PS1+_}" ] ; then 23 | PS1="$_RUYI_OLD_PS1" 24 | export PS1 25 | unset _RUYI_OLD_PS1 26 | fi 27 | 28 | unset RUYI_VENV 29 | unset RUYI_VENV_PROMPT 30 | if [ ! "${1-}" = "nondestructive" ] ; then 31 | # Self destruct! 32 | unset -f ruyi-deactivate 33 | fi 34 | } 35 | 36 | # unset irrelevant variables 37 | ruyi-deactivate nondestructive 38 | 39 | RUYI_VENV={{ RUYI_VENV | sh }} 40 | export RUYI_VENV 41 | 42 | _RUYI_OLD_PATH="$PATH" 43 | PATH="$RUYI_VENV/bin:$PATH" 44 | export PATH 45 | 46 | # invalidate the PATH cache 47 | hash -r 2>/dev/null 48 | 49 | {% if RUYI_VENV_NAME -%} 50 | RUYI_VENV_PROMPT={{ RUYI_VENV_NAME | sh }} 51 | {%- else -%} 52 | RUYI_VENV_PROMPT="$(basename "$RUYI_VENV")" 53 | {%- endif %} 54 | export RUYI_VENV_PROMPT 55 | 56 | if [ -z "${RUYI_VENV_DISABLE_PROMPT-}" ] ; then 57 | _RUYI_OLD_PS1="${PS1-}" 58 | PS1="«Ruyi ${RUYI_VENV_PROMPT}» ${PS1-}" 59 | export PS1 60 | fi 61 | -------------------------------------------------------------------------------- /ruyi/utils/porcelain.py: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | import enum 3 | import json 4 | import sys 5 | from types import TracebackType 6 | from typing import BinaryIO, TypedDict, TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from typing_extensions import Self 10 | 11 | if sys.version_info >= (3, 11): 12 | 13 | class PorcelainEntityType(enum.StrEnum): 14 | LogV1 = "log-v1" 15 | NewsItemV1 = "newsitem-v1" 16 | PkgListOutputV1 = "pkglistoutput-v1" 17 | EntityListOutputV1 = "entitylistoutput-v1" 18 | 19 | else: 20 | 21 | class PorcelainEntityType(str, enum.Enum): 22 | LogV1 = "log-v1" 23 | NewsItemV1 = "newsitem-v1" 24 | PkgListOutputV1 = "pkglistoutput-v1" 25 | EntityListOutputV1 = "entitylistoutput-v1" 26 | 27 | 28 | class PorcelainEntity(TypedDict): 29 | ty: PorcelainEntityType 30 | 31 | 32 | class PorcelainOutput(AbstractContextManager["PorcelainOutput"]): 33 | def __init__(self, out: BinaryIO | None = None) -> None: 34 | self.out = sys.stdout.buffer if out is None else out 35 | 36 | def __enter__(self) -> "Self": 37 | return self 38 | 39 | def __exit__( 40 | self, 41 | exc_type: type[BaseException] | None, 42 | exc_value: BaseException | None, 43 | traceback: TracebackType | None, 44 | ) -> bool | None: 45 | self.out.flush() 46 | return None 47 | 48 | def emit(self, obj: PorcelainEntity) -> None: 49 | s = json.dumps(obj, ensure_ascii=False, separators=(",", ":")) 50 | self.out.write(s.encode("utf-8")) 51 | self.out.write(b"\n") 52 | -------------------------------------------------------------------------------- /ruyi/cli/completer.py: -------------------------------------------------------------------------------- 1 | """ 2 | helper functions for CLI completions 3 | """ 4 | 5 | import argparse 6 | from typing import Protocol, Any, TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | # A "lie" for type checking purposes. This is a known and wont fix issue for mypy. 10 | # Mypy would think the fallback import needs to be the same type as the first import. 11 | # See: https://github.com/python/mypy/issues/1153 12 | from argcomplete.completers import BaseCompleter 13 | else: 14 | try: 15 | from argcomplete.completers import BaseCompleter 16 | except ImportError: 17 | # Fallback for environments where argcomplete is less than 2.0.0 18 | class BaseCompleter(object): 19 | def __call__( 20 | self, 21 | *, 22 | prefix: str, 23 | action: argparse.Action, 24 | parser: argparse.ArgumentParser, 25 | parsed_args: argparse.Namespace, 26 | ) -> None: 27 | raise NotImplementedError( 28 | "This method should be implemented by a subclass." 29 | ) 30 | 31 | 32 | class NoneCompleter(BaseCompleter): 33 | def __call__( 34 | self, 35 | *, 36 | prefix: str, 37 | action: argparse.Action, 38 | parser: argparse.ArgumentParser, 39 | parsed_args: argparse.Namespace, 40 | ) -> None: 41 | return None 42 | 43 | 44 | class DynamicCompleter(Protocol): 45 | def __call__( 46 | self, 47 | prefix: str, 48 | parsed_args: object, 49 | **kwargs: Any, 50 | ) -> list[str]: ... 51 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/host.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | from typing import NamedTuple 4 | 5 | 6 | class RuyiHost(NamedTuple): 7 | os: str 8 | arch: str 9 | 10 | def __str__(self) -> str: 11 | return f"{self.os}/{self.arch}" 12 | 13 | def canonicalize(self) -> "RuyiHost": 14 | return RuyiHost( 15 | os=canonicalize_os_str(self.os), 16 | arch=canonicalize_arch_str(self.arch), 17 | ) 18 | 19 | 20 | def canonicalize_host_str(host: str | RuyiHost) -> str: 21 | if isinstance(host, str): 22 | frags = host.split("/", 1) 23 | os = "linux" if len(frags) == 1 else frags[0] 24 | arch = frags[0] if len(frags) == 1 else frags[1] 25 | return str(RuyiHost(os, arch).canonicalize()) 26 | 27 | return str(host.canonicalize()) 28 | 29 | 30 | def canonicalize_arch_str(arch: str) -> str: 31 | # Information sources: 32 | # 33 | # * https://bugs.python.org/issue7146#msg94134 34 | # * https://superuser.com/questions/305901/possible-values-of-processor-architecture 35 | match arch.lower(): 36 | case "amd64" | "em64t": 37 | return "x86_64" 38 | case "arm64": 39 | return "aarch64" 40 | case "x86": 41 | return "i686" 42 | case arch_lower: 43 | return arch_lower 44 | 45 | 46 | def canonicalize_os_str(os: str) -> str: 47 | match os: 48 | case "win32": 49 | return "windows" 50 | case _: 51 | return os 52 | 53 | 54 | def get_native_host() -> RuyiHost: 55 | return RuyiHost(os=sys.platform, arch=platform.machine()).canonicalize() 56 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/update_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from ..cli.cmd import RootCommand 5 | 6 | if TYPE_CHECKING: 7 | from ..cli.completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class UpdateCommand( 12 | RootCommand, 13 | cmd="update", 14 | help="Update RuyiSDK repo and packages", 15 | ): 16 | @classmethod 17 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 18 | pass 19 | 20 | @classmethod 21 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 22 | from . import news 23 | from .state import BoundInstallationStateStore 24 | 25 | logger = cfg.logger 26 | mr = cfg.repo 27 | mr.sync() 28 | 29 | # check for upgradable packages 30 | bis = BoundInstallationStateStore(cfg.ruyipkg_global_state, mr) 31 | upgradable = list(bis.iter_upgradable_pkgs(cfg.include_prereleases)) 32 | 33 | if upgradable: 34 | logger.stdout( 35 | "\nNewer versions are available for some of your installed packages:\n" 36 | ) 37 | for pm, new_ver in upgradable: 38 | logger.stdout( 39 | f" - [bold]{pm.category}/{pm.name}[/]: [yellow]{pm.ver}[/] -> [green]{new_ver}[/]" 40 | ) 41 | logger.stdout( 42 | """ 43 | Re-run [yellow]ruyi install[/] to upgrade, and don't forget to re-create any affected 44 | virtual environments.""" 45 | ) 46 | 47 | news.maybe_notify_unread_news(cfg, False) 48 | 49 | return 0 50 | -------------------------------------------------------------------------------- /tests/rit-suites/ruyi-gha.yaml: -------------------------------------------------------------------------------- 1 | # NOTE: This file is adapted from ruyi-litester/suites/ruyi.yaml, to account 2 | # for the unique needs of GitHub Actions and PR checks. 3 | # 4 | # Main change points: 5 | # 6 | # * The default (first) test suite name is renamed to "ruyi-gha" from "ruyi". 7 | # * Removed `ruyi/ruyi-bin-{install,remove}` pre- and post-actions because 8 | # we are the upstream. 9 | # * Removed the `ruyi-bin` variant, same reason as above. 10 | # 11 | # Please keep the changes in sync when you bump the ruyi-litester submodule. 12 | 13 | ruyi-gha: # originally "ruyi" 14 | cases: 15 | # testcases list 16 | - ruyi-help 17 | - ruyi-basic 18 | - ruyi-advance 19 | - ruyi-mugen 20 | pre: 21 | # each pre script should have a corresponding post script 22 | # or set it to _ 23 | - ["ruyi/ruyi-src-install", ] # "ruyi/ruyi-bin-install" removed 24 | post: 25 | - ["ruyi/ruyi-src-remove", ] # "ruyi/ruyi-bin-remove" removed 26 | 27 | ruyi-local: 28 | cases: 29 | - ruyi-help 30 | - ruyi-basic 31 | - ruyi-advance 32 | - ruyi-mugen 33 | pre: 34 | - ["_", ] 35 | post: 36 | - ["_", ] 37 | 38 | ruyi-src: 39 | cases: 40 | - ruyi-help 41 | - ruyi-basic 42 | - ruyi-advance 43 | - ruyi-mugen 44 | pre: 45 | - ["ruyi/ruyi-src-install", ] 46 | post: 47 | - ["ruyi/ruyi-src-remove", ] 48 | 49 | # ruyi-bin: removed for upstream 50 | 51 | ruyi-i18n: 52 | cases: 53 | - ruyi-i18n 54 | pre: 55 | - ["i18n/setup-zh-locale", "i18n/setup-en-locale",] 56 | - ["ruyi/ruyi-src-install", ] 57 | post: 58 | - ["i18n/setup-en-locale", _] 59 | - ["ruyi/ruyi-src-remove", ] 60 | -------------------------------------------------------------------------------- /tests/ruyipkg/test_checksum.py: -------------------------------------------------------------------------------- 1 | import io 2 | import hashlib 3 | 4 | import pytest 5 | 6 | from ruyi.ruyipkg.checksum import get_hash_instance, Checksummer 7 | 8 | 9 | def test_get_hash_instance_supported() -> None: 10 | # these should not raise any exception 11 | get_hash_instance("sha256") 12 | get_hash_instance("sha512") 13 | 14 | 15 | def test_get_hash_instance_unsupported() -> None: 16 | with pytest.raises(ValueError, match="checksum algorithm md5 not supported"): 17 | get_hash_instance("md5") 18 | 19 | 20 | def test_checksummer_compute() -> None: 21 | file_content = b"test content" 22 | expected_sha256 = hashlib.sha256(file_content).hexdigest() 23 | expected_sha512 = hashlib.sha512(file_content).hexdigest() 24 | 25 | file = io.BytesIO(file_content) 26 | checksums = {"sha256": expected_sha256, "sha512": expected_sha512} 27 | checksummer = Checksummer(file, checksums) 28 | 29 | computed_checksums = checksummer.compute() 30 | assert computed_checksums["sha256"] == expected_sha256 31 | 32 | 33 | def test_checksummer_check() -> None: 34 | file_content = b"test content" 35 | expected_sha256 = hashlib.sha256(file_content).hexdigest() 36 | expected_sha512 = hashlib.sha512(file_content).hexdigest() 37 | 38 | file = io.BytesIO(file_content) 39 | checksums = {"sha256": expected_sha256, "sha512": expected_sha512} 40 | checksummer = Checksummer(file, checksums) 41 | 42 | # This should not raise any exception 43 | checksummer.check() 44 | 45 | # Modify the file content to cause a checksum mismatch 46 | file = io.BytesIO(b"modified content") 47 | checksummer = Checksummer(file, checksums) 48 | 49 | with pytest.raises(ValueError, match="wrong sha256 checksum"): 50 | checksummer.check() 51 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/device.jsonschema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Device Entity Schema", 4 | "description": "Schema for device entity definitions", 5 | "type": "object", 6 | "required": ["ruyi-entity", "device"], 7 | "properties": { 8 | "ruyi-entity": { 9 | "type": "string", 10 | "description": "Version of the entity schema", 11 | "enum": ["v0"] 12 | }, 13 | "device": { 14 | "type": "object", 15 | "required": ["id", "display_name"], 16 | "properties": { 17 | "id": { 18 | "type": "string", 19 | "description": "Unique identifier for the device" 20 | }, 21 | "display_name": { 22 | "type": "string", 23 | "description": "Human-readable name for the device" 24 | }, 25 | "variants": { 26 | "type": "array", 27 | "description": "List of device variants (different configurations of the same device)", 28 | "items": { 29 | "type": "object", 30 | "required": ["id", "display_name"], 31 | "properties": { 32 | "id": { 33 | "type": "string", 34 | "description": "Unique identifier for the variant" 35 | }, 36 | "display_name": { 37 | "type": "string", 38 | "description": "Human-readable name for the variant" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | }, 45 | "related": { 46 | "type": "array", 47 | "description": "List of related entity references", 48 | "items": { 49 | "type": "string", 50 | "pattern": "^.+:.+" 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/auto-tag.yml: -------------------------------------------------------------------------------- 1 | name: Auto-tag releases 2 | 3 | on: 4 | push: 5 | paths: 6 | - pyproject.toml 7 | 8 | jobs: 9 | auto-tag: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Skip for tags 13 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 14 | run: exit 0 15 | 16 | - uses: actions/checkout@v5 17 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 18 | with: 19 | ref: ${{ github.head_ref }} 20 | # for tag detection and auto-blaming to work 21 | fetch-depth: 0 22 | # https://github.com/orgs/community/discussions/25617#discussioncomment-3248494 23 | token: ${{ secrets.GHA_PAT_THIS_REPO_RW }} 24 | 25 | - name: Install Poetry 26 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 27 | run: pipx install poetry 28 | 29 | # NOTE: the Poetry venv is created during this step 30 | - uses: actions/setup-python@v5 31 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 32 | with: 33 | python-version-file: pyproject.toml 34 | cache: poetry 35 | 36 | - name: Install deps in the venv 37 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 38 | run: poetry install --with=dev 39 | 40 | - name: Create the tag and push 41 | if: ${{ !startsWith(github.ref, 'refs/tags/') }} 42 | run: | 43 | # Note: the following account information will not work on GHES 44 | git config user.name "github-actions[bot]" 45 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 46 | # No signing secret key for this identity on runners 47 | export RUYI_NO_GPG_SIGN=x 48 | poetry run ./scripts/make-release-tag.py && git push --tags 49 | -------------------------------------------------------------------------------- /ruyi/resource_bundle/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Regenerates data.py from fresh contents. 3 | 4 | import base64 5 | import pathlib 6 | from typing import Any 7 | import zlib 8 | 9 | 10 | def make_payload_from_file(path: pathlib.Path) -> str: 11 | with open(path, "rb") as fp: 12 | content = fp.read() 13 | 14 | return base64.b64encode(zlib.compress(content, 9)).decode("ascii") 15 | 16 | 17 | def main() -> None: 18 | self_path = pathlib.Path(__file__).parent.resolve() 19 | bundled_resource_root = self_path / ".." / ".." / "resources" / "bundled" 20 | 21 | resources: dict[str, str] = {} 22 | template_names: dict[str, str] = {} 23 | for f in bundled_resource_root.iterdir(): 24 | if not f.is_file(): 25 | continue 26 | 27 | resources[f.name] = make_payload_from_file(f) 28 | 29 | if f.suffix.lower() == ".jinja": 30 | # strip the .jinja suffix for the template name 31 | template_names[f.stem] = f.name 32 | 33 | with open(self_path / "data.py", "w", encoding="utf-8") as fp: 34 | 35 | def p(*args: Any) -> None: 36 | return print(*args, file=fp) 37 | 38 | p("# NOTE: This file is auto-generated. DO NOT EDIT!") 39 | p("# Update by running the __main__.py alongside this file\n") 40 | 41 | p("from typing import Final\n\n") 42 | 43 | p("RESOURCES: Final = {") 44 | for filename, payload in sorted(resources.items()): 45 | p(f' "{filename}": b"{payload}", # fmt: skip') 46 | p("}\n") 47 | 48 | p("TEMPLATES: Final = {") 49 | for stem, full_filename in sorted(template_names.items()): 50 | p(f' "{stem}": RESOURCES["{full_filename}"],') 51 | p("}") 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/binary-with-cmds.before.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "RuyiSDK wlink Build (Upstream snapshot, built by PLCT)" 5 | vendor = { name = "PLCT", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz" 9 | size = 902085 10 | strip_components = 2 11 | 12 | [distfiles.checksums] 13 | sha256 = "b78c1fa08142e657349d03f9bc2a90c49afc438eac47b17bcbbed1b2c190d13e" 14 | sha512 = "8f9e65effc56cfd12c67e18c7e431388f01b23b09cc6f33f815bc6a03457ea143ec463501f53fe8b1b72d0528261bbe5566ae43b111fc802c7c2142c0c5dbace" 15 | 16 | [[distfiles]] 17 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz" 18 | size = 941960 19 | strip_components = 2 20 | 21 | [distfiles.checksums] 22 | sha256 = "d798618d4f947584443e5d7e1a5066458274cc80648ffdd66b176c2bd51c45e7" 23 | sha512 = "40b7913e551c952fa252c4b0cda4e071ab20f2b3325850460c2f45fa2c623ced33f9ec696556e3eb5594c4b60227d45b60c0dce1196d391e3935ba11d92b2683" 24 | 25 | [[distfiles]] 26 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz" 27 | size = 916779 28 | strip_components = 2 29 | 30 | [distfiles.checksums] 31 | sha256 = "87ee5c301cf9b5df9b3b759fe77fc06d8225f3cb4a56d66f4c70753b800d8db1" 32 | sha512 = "110fa7816fb6318c3b10952f3f3c9efa8555018134029cdb98e9a2a25f7e76860d70fa0f7cc6dc93a32989909b750470e0de7f39d68af50431ba88b9a237fe51" 33 | 34 | [[binary]] 35 | host = "aarch64" 36 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz"] 37 | commands = { wlink = "bin/wlink" } 38 | 39 | [[binary]] 40 | host = "riscv64" 41 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz"] 42 | 43 | [[binary]] 44 | host = "x86_64" 45 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz"] 46 | 47 | [binary.commands] 48 | foo = "bin/foo" 49 | bar = "bin/bar" 50 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/binary-with-cmds.after.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "RuyiSDK wlink Build (Upstream snapshot, built by PLCT)" 5 | vendor = { name = "PLCT", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz" 9 | size = 902085 10 | strip_components = 2 11 | 12 | [distfiles.checksums] 13 | sha256 = "b78c1fa08142e657349d03f9bc2a90c49afc438eac47b17bcbbed1b2c190d13e" 14 | sha512 = "8f9e65effc56cfd12c67e18c7e431388f01b23b09cc6f33f815bc6a03457ea143ec463501f53fe8b1b72d0528261bbe5566ae43b111fc802c7c2142c0c5dbace" 15 | 16 | [[distfiles]] 17 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz" 18 | size = 941960 19 | strip_components = 2 20 | 21 | [distfiles.checksums] 22 | sha256 = "d798618d4f947584443e5d7e1a5066458274cc80648ffdd66b176c2bd51c45e7" 23 | sha512 = "40b7913e551c952fa252c4b0cda4e071ab20f2b3325850460c2f45fa2c623ced33f9ec696556e3eb5594c4b60227d45b60c0dce1196d391e3935ba11d92b2683" 24 | 25 | [[distfiles]] 26 | name = "wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz" 27 | size = 916779 28 | strip_components = 2 29 | 30 | [distfiles.checksums] 31 | sha256 = "87ee5c301cf9b5df9b3b759fe77fc06d8225f3cb4a56d66f4c70753b800d8db1" 32 | sha512 = "110fa7816fb6318c3b10952f3f3c9efa8555018134029cdb98e9a2a25f7e76860d70fa0f7cc6dc93a32989909b750470e0de7f39d68af50431ba88b9a237fe51" 33 | 34 | [[binary]] 35 | host = "aarch64" 36 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.aarch64.tar.gz"] 37 | 38 | [binary.commands] 39 | wlink = "bin/wlink" 40 | 41 | [[binary]] 42 | host = "riscv64" 43 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.riscv64.tar.gz"] 44 | 45 | [[binary]] 46 | host = "x86_64" 47 | distfiles = ["wlink-0.1.1-ruyi.20250524+git.217f0e51.x86_64.tar.gz"] 48 | 49 | [binary.commands] 50 | bar = "bin/bar" 51 | foo = "bin/foo" 52 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/entities_v0_smoke/_schemas/uarch.jsonschema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "UArch Entity Schema", 4 | "description": "Schema for micro-architecture entity definitions", 5 | "type": "object", 6 | "required": ["ruyi-entity", "uarch"], 7 | "properties": { 8 | "ruyi-entity": { 9 | "type": "string", 10 | "description": "Version of the entity schema", 11 | "enum": ["v0"] 12 | }, 13 | "uarch": { 14 | "type": "object", 15 | "required": ["id", "display_name", "arch"], 16 | "properties": { 17 | "id": { 18 | "type": "string", 19 | "description": "Unique identifier for the microarchitecture" 20 | }, 21 | "display_name": { 22 | "type": "string", 23 | "description": "Human-readable name for the microarchitecture" 24 | }, 25 | "arch": { 26 | "type": "string", 27 | "description": "Architecture family identifier (e.g., riscv64)" 28 | }, 29 | "riscv": { 30 | "type": "object", 31 | "description": "RISC-V specific configuration (only present for RISC-V architectures)", 32 | "properties": { 33 | "isa": { 34 | "type": "string", 35 | "description": "RISC-V ISA specification string" 36 | } 37 | }, 38 | "required": ["isa"] 39 | } 40 | }, 41 | "allOf": [ 42 | { 43 | "if": { 44 | "properties": { "arch": { "const": "riscv64" } } 45 | }, 46 | "then": { 47 | "required": ["riscv"] 48 | } 49 | } 50 | ] 51 | }, 52 | "related": { 53 | "type": "array", 54 | "description": "List of related entity references", 55 | "items": { 56 | "type": "string", 57 | "pattern": "^.+:.+" 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /scripts/make-reproducible-source-tarball.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" 6 | TMPDIR='' 7 | 8 | _cleanup() { 9 | [[ -n $TMPDIR ]] && rm -rf "$TMPDIR" 10 | } 11 | 12 | get_repo_commit_time() { 13 | TZ=UTC0 git log -1 --format='tformat:%cd' --date='format:%Y-%m-%dT%H:%M:%SZ' 14 | } 15 | 16 | reproducible_tar() { 17 | local args=( 18 | --sort=name 19 | --format=posix 20 | --pax-option='exthdr.name=%d/PaxHeaders/%f' 21 | --pax-option='delete=atime,delete=ctime' 22 | --clamp-mtime 23 | --mtime="$SOURCE_EPOCH" 24 | --numeric-owner 25 | --owner=0 26 | --group=0 27 | "$@" 28 | ) 29 | 30 | LC_ALL=C tar "${args[@]}" 31 | } 32 | 33 | # shellcheck disable=SC2120 34 | reproducible_gzip() { 35 | gzip -9 -n "$@" 36 | } 37 | 38 | main() { 39 | local version source_epoch staging_dirname dest_dir 40 | 41 | cd "$REPO_ROOT" 42 | version="$(git describe)" 43 | source_epoch="$(get_repo_commit_time)" 44 | staging_dirname="ruyi-$version" 45 | artifact_name="$staging_dirname.tar.gz" 46 | dest_dir="${1:=$REPO_ROOT/tmp}" 47 | 48 | TMPDIR="$(mktemp -d)" 49 | trap _cleanup EXIT 50 | 51 | git clone --recurse-submodules "$REPO_ROOT" "$TMPDIR/$staging_dirname" 52 | pushd "$TMPDIR/$staging_dirname" > /dev/null 53 | # remove Git metadata 54 | find . -name .git -exec rm -rf '{}' '+' 55 | # set all file timestamps to $SOURCE_EPOCH 56 | find . -exec touch -md "$source_epoch" '{}' '+' 57 | popd > /dev/null 58 | 59 | pushd "$TMPDIR" > /dev/null 60 | reproducible_tar -cf - "./$staging_dirname" | reproducible_gzip > "$dest_dir/$artifact_name" 61 | popd > /dev/null 62 | 63 | echo "info: repo HEAD content is reproducibly packed at $dest_dir/$artifact_name" 64 | [[ -n $GITHUB_OUTPUT ]] && echo "artifact_name=$artifact_name" > "$GITHUB_OUTPUT" 65 | } 66 | 67 | main "$@" 68 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/protocols.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Protocol 2 | 3 | from .pkg_manifest import BoundPackageManifest 4 | 5 | 6 | class ProvidesPackageManifests(Protocol): 7 | """A protocol that defines methods for providing package manifests.""" 8 | 9 | def get_pkg( 10 | self, 11 | name: str, 12 | category: str, 13 | ver: str, 14 | ) -> BoundPackageManifest | None: 15 | """Returns the package manifest by exact match, or None if not found.""" 16 | ... 17 | 18 | def iter_pkg_manifests(self) -> Iterable[BoundPackageManifest]: 19 | """Iterates over all package manifests provided by this store.""" 20 | ... 21 | 22 | def iter_pkgs( 23 | self, 24 | ) -> Iterable[tuple[str, str, dict[str, BoundPackageManifest]]]: 25 | """Iterates over all package manifests provided by this store, returning 26 | ``(category, package_name, pkg_manifests_by_versions)``.""" 27 | ... 28 | 29 | def iter_pkg_vers( 30 | self, 31 | name: str, 32 | category: str | None = None, 33 | ) -> Iterable[BoundPackageManifest]: 34 | """Iterates over all versions of a certain package provided by this store, 35 | specified by name and optionally category.""" 36 | ... 37 | 38 | def get_pkg_latest_ver( 39 | self, 40 | name: str, 41 | category: str | None = None, 42 | include_prerelease_vers: bool = False, 43 | ) -> BoundPackageManifest: 44 | """Returns the latest version of a package provided by this store, 45 | specified by name and optionally category. 46 | 47 | If ``include_prerelease_vers`` is True, it will also consider prerelease 48 | versions. Raises KeyError if no such package exists.""" 49 | ... 50 | 51 | # To be removed later along with slug support 52 | def get_pkg_by_slug(self, slug: str) -> BoundPackageManifest | None: 53 | """Returns the package with the specified slug from this store, or None 54 | if not found.""" 55 | ... 56 | -------------------------------------------------------------------------------- /scripts/set-gha-env.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | from typing import cast 6 | 7 | if sys.version_info >= (3, 11): 8 | import tomllib 9 | else: 10 | import tomli as tomllib 11 | 12 | 13 | def main() -> None: 14 | v = get_semver() 15 | set_release_mirror_url_for_gha(v) 16 | 17 | 18 | def get_semver() -> str: 19 | # assume CWD is project root, which is guaranteed to be the case (see 20 | # end of file) 21 | with open("pyproject.toml", "rb") as fp: 22 | pyproject = tomllib.load(fp) 23 | 24 | return cast(str, pyproject["project"]["version"]) 25 | 26 | 27 | def set_release_mirror_url_for_gha(version: str) -> None: 28 | release_url_base = "https://mirror.iscas.ac.cn/ruyisdk/ruyi/releases/" 29 | testing_url_base = "https://mirror.iscas.ac.cn/ruyisdk/ruyi/testing/" 30 | 31 | # Do not depend on external libraries so this can work in plain GHA 32 | # environment without any venv setup. See the SemVer spec -- as long as 33 | # we don't have build tags containing "-" we should be fine, which is 34 | # exactly the case. 35 | # 36 | # sv = Version.parse(version) 37 | # is_prerelease = sv.prerelease 38 | is_prerelease = "-" in version 39 | 40 | url_base = testing_url_base if is_prerelease else release_url_base 41 | url = f"{url_base}{version}/" 42 | set_gha_output("release_mirror_url", url) 43 | 44 | 45 | def set_gha_output(k: str, v: str) -> None: 46 | if "\n" in v: 47 | raise ValueError("this helper is only for small one-line outputs") 48 | 49 | # only do this when the GitHub Actions output file is available 50 | # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter 51 | outfile = os.environ.get("GITHUB_OUTPUT", "") 52 | if not outfile: 53 | return 54 | 55 | with open(outfile, "a", encoding="utf-8") as fp: 56 | fp.write(f"{k}={v}\n") 57 | 58 | 59 | if __name__ == "__main__": 60 | # cd to project root 61 | os.chdir(os.path.join(os.path.dirname(__file__), "..")) 62 | main() 63 | -------------------------------------------------------------------------------- /scripts/dist-image/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM ubuntu:22.04 3 | 4 | ARG TARGETARCH 5 | ARG BUILDER_UID=1000 6 | ARG BUILDER_GID=1000 7 | 8 | SHELL ["/bin/bash", "-c"] 9 | 10 | RUN < None: 16 | class MockContextManager(AbstractContextManager[int]): 17 | def __init__(self) -> None: 18 | self.entered = 0 19 | self.exited = 0 20 | 21 | def __enter__(self) -> int: 22 | self.entered += 1 23 | return 233 24 | 25 | def __exit__( 26 | self, 27 | exc_type: type[BaseException] | None, 28 | exc_value: BaseException | None, 29 | traceback: TracebackType | None, 30 | ) -> bool | None: 31 | self.exited += 1 32 | return None 33 | 34 | with ruyi_file.plugin_suite("with_") as plugin_root: 35 | phctx = PluginHostContext.new(ruyi_logger, plugin_root) 36 | ev = phctx.make_evaluator() 37 | 38 | fn1 = phctx.get_from_plugin("foo", "fn1") 39 | assert fn1 is not None 40 | cm1 = MockContextManager() 41 | ret1 = ev.eval_function(fn1, cm1) 42 | assert cm1.entered == 1 43 | assert cm1.exited == 1 44 | assert ret1 == 466 45 | 46 | # even when the plugin side panics, the context manager semantics 47 | # shall remain enforced 48 | fn2 = phctx.get_from_plugin("foo", "fn2") 49 | assert fn2 is not None 50 | cm2 = MockContextManager() 51 | with pytest.raises((RuntimeError, AttributeError)): 52 | ev.eval_function(fn2, cm2) 53 | assert cm2.entered == 1 54 | assert cm2.exited == 1 55 | 56 | def inner_fn3(x: int) -> int: 57 | return x - 233 58 | 59 | fn3 = phctx.get_from_plugin("foo", "fn3") 60 | assert fn3 is not None 61 | cm3 = MockContextManager() 62 | ret3 = ev.eval_function(fn3, cm3, inner_fn3) 63 | assert cm3.entered == 1 64 | assert cm3.exited == 1 65 | assert ret3 == 0 66 | -------------------------------------------------------------------------------- /scripts/_image_tag_base.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # this file is meant to be sourced 3 | 4 | _COMMON_DIST_IMAGE_TAG="ghcr.io/ruyisdk/ruyi-python-dist:20251024" 5 | 6 | # Map of `uname -m` outputs to Debian arch name convention which Ruyi adopts 7 | # 8 | # This list is incomplete: it currently only contains architectures for which 9 | # Docker-based dist builds are supported, and that are officially supported 10 | # by the RuyiSDK project. This means if you want to build for an architecture 11 | # that is not officially supported, and if `uname -m` output differs from 12 | # the Debian name for it, you will have to specify the correct arch name on 13 | # the command line. 14 | declare -A _UNAME_ARCH_MAP=( 15 | ["aarch64"]="arm64" 16 | ["i686"]="i386" 17 | ["riscv64"]="riscv64" 18 | ["x86_64"]="amd64" 19 | ) 20 | 21 | convert_uname_arch_to_ruyi() { 22 | echo "${_UNAME_ARCH_MAP["$1"]:-"$1"}" 23 | } 24 | 25 | declare -A _RUYI_DIST_IMAGE_TAGS=( 26 | ["amd64"]="$_COMMON_DIST_IMAGE_TAG" 27 | ["arm64"]="$_COMMON_DIST_IMAGE_TAG" 28 | ["riscv64"]="$_COMMON_DIST_IMAGE_TAG" 29 | ) 30 | 31 | is_docker_dist_build_supported() { 32 | local arch="$1" 33 | [[ -n "${_RUYI_DIST_IMAGE_TAGS["$arch"]}" ]] 34 | } 35 | 36 | ensure_docker_dist_build_supported() { 37 | local arch="$1" 38 | local loglevel=error 39 | 40 | if [[ -n "$RUYI_DIST_FORCE_IMAGE_TAG" ]]; then 41 | loglevel=warning 42 | fi 43 | 44 | if ! is_docker_dist_build_supported "$arch"; then 45 | echo "$loglevel: unsupported arch $arch for Docker-based dist builds" >&2 46 | echo "info: supported arches:" "${!_RUYI_DIST_IMAGE_TAGS[@]}" >&2 47 | if [[ $loglevel == error ]]; then 48 | echo "info: you can set RUYI_DIST_FORCE_IMAGE_TAG (and maybe RUYI_DIST_GOARCH) if you insist" >&2 49 | exit 1 50 | fi 51 | fi 52 | } 53 | 54 | image_tag_base() { 55 | local arch="$1" 56 | 57 | if [[ -n "$RUYI_DIST_FORCE_IMAGE_TAG" ]]; then 58 | echo "warning: forcing use of dist image $RUYI_DIST_FORCE_IMAGE_TAG" >&2 59 | echo "$RUYI_DIST_FORCE_IMAGE_TAG" 60 | return 0 61 | fi 62 | 63 | ensure_docker_dist_build_supported "$arch" 64 | echo "${_RUYI_DIST_IMAGE_TAGS["$arch"]}" 65 | } 66 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/prefer-quirks-to-flavors.after.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "RuyiSDK RISC-V Linux GNU Toolchain 20240222 (T-Head 2.8.0 sources, built by PLCT)" 5 | vendor = { name = "PLCT", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" 9 | size = 303887180 10 | 11 | [distfiles.checksums] 12 | sha256 = "ad98f0a337fc79faa0e28c9d65c667192c787a7a12a34e326b4fc46dcfefc82e" 13 | sha512 = "fcb7e7e071ee421626189da67f9e4bbd0da16aed0f8f12646eac20583454689aa239277156118d484a89eb9e68f266dbb98885e6fb851fb934b6ab2a17ab57a5" 14 | 15 | [[distfiles]] 16 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" 17 | size = 309153492 18 | 19 | [distfiles.checksums] 20 | sha256 = "81cfe107bf0121c94fe25db53ea9a7205ebeda686ae7ff60136d42637ccfa3ed" 21 | sha512 = "133a8dc2169549c18bfc98606fb39968eb437bb51724a2611950dcd4051942475d45df4a8b945e1846569b543d94a153337f5c48b1fd2d78c6bb9778c121a730" 22 | 23 | [[distfiles]] 24 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz" 25 | size = 323299980 26 | 27 | [distfiles.checksums] 28 | sha256 = "66af0f05f9f71849c909cbf071412501068e44a99cfcceb3fb07e686b2e8c898" 29 | sha512 = "7f20aa294ffb000cb52331bf8acab6086995ca2bbd8dd5ce569c7a85ef9b3516a8443080d54f21ae23ffa107456e9d22e7510daf3d64b9a81b75cdd1b578eb5d" 30 | 31 | [[binary]] 32 | host = "aarch64" 33 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] 34 | 35 | [[binary]] 36 | host = "riscv64" 37 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] 38 | 39 | [[binary]] 40 | host = "x86_64" 41 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz"] 42 | 43 | [toolchain] 44 | target = "riscv64-plctxthead-linux-gnu" 45 | quirks = ["xthead"] 46 | components = [ 47 | { name = "binutils", version = "2.35" }, 48 | { name = "gcc", version = "10.2.0" }, 49 | { name = "gdb", version = "10.0" }, 50 | { name = "glibc", version = "2.33" }, 51 | { name = "linux-headers", version = "6.4" }, 52 | ] 53 | included_sysroot = "riscv64-plctxthead-linux-gnu/sysroot" 54 | -------------------------------------------------------------------------------- /tests/fixtures/ruyipkg_suites/format_manifest/prefer-quirks-to-flavors.before.toml: -------------------------------------------------------------------------------- 1 | format = "v1" 2 | 3 | [metadata] 4 | desc = "RuyiSDK RISC-V Linux GNU Toolchain 20240222 (T-Head 2.8.0 sources, built by PLCT)" 5 | vendor = { name = "PLCT", eula = "" } 6 | 7 | [[distfiles]] 8 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" 9 | size = 303887180 10 | 11 | [distfiles.checksums] 12 | sha256 = "ad98f0a337fc79faa0e28c9d65c667192c787a7a12a34e326b4fc46dcfefc82e" 13 | sha512 = "fcb7e7e071ee421626189da67f9e4bbd0da16aed0f8f12646eac20583454689aa239277156118d484a89eb9e68f266dbb98885e6fb851fb934b6ab2a17ab57a5" 14 | 15 | [[distfiles]] 16 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz" 17 | size = 309153492 18 | 19 | [distfiles.checksums] 20 | sha256 = "81cfe107bf0121c94fe25db53ea9a7205ebeda686ae7ff60136d42637ccfa3ed" 21 | sha512 = "133a8dc2169549c18bfc98606fb39968eb437bb51724a2611950dcd4051942475d45df4a8b945e1846569b543d94a153337f5c48b1fd2d78c6bb9778c121a730" 22 | 23 | [[distfiles]] 24 | name = "RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz" 25 | size = 323299980 26 | 27 | [distfiles.checksums] 28 | sha256 = "66af0f05f9f71849c909cbf071412501068e44a99cfcceb3fb07e686b2e8c898" 29 | sha512 = "7f20aa294ffb000cb52331bf8acab6086995ca2bbd8dd5ce569c7a85ef9b3516a8443080d54f21ae23ffa107456e9d22e7510daf3d64b9a81b75cdd1b578eb5d" 30 | 31 | [[binary]] 32 | host = "aarch64" 33 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-aarch64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] 34 | 35 | [[binary]] 36 | host = "riscv64" 37 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-HOST-riscv64-linux-gnu-riscv64-plctxthead-linux-gnu.tar.xz"] 38 | 39 | [[binary]] 40 | host = "x86_64" 41 | distfiles = ["RuyiSDK-20240222-T-Head-Sources-T-Head-2.8.0-riscv64-plctxthead-linux-gnu.tar.xz"] 42 | 43 | [toolchain] 44 | target = "riscv64-plctxthead-linux-gnu" 45 | flavors = ["xthead"] 46 | components = [ 47 | { name = "binutils", version = "2.35" }, 48 | { name = "gcc", version = "10.2.0" }, 49 | { name = "gdb", version = "10.0" }, 50 | { name = "glibc", version = "2.33" }, 51 | { name = "linux-headers", version = "6.4" }, 52 | ] 53 | included_sysroot = "riscv64-plctxthead-linux-gnu/sysroot" 54 | -------------------------------------------------------------------------------- /ruyi/utils/prereqs.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | from typing import Final, Iterable, NoReturn 4 | 5 | from ..cli.user_input import pause_before_continuing 6 | from ..log import RuyiLogger, humanize_list 7 | 8 | 9 | def has_cmd_in_path(cmd: str) -> bool: 10 | return shutil.which(cmd) is not None 11 | 12 | 13 | _CMDS: Final = ( 14 | "bzip2", 15 | "gunzip", 16 | "lz4", 17 | "tar", 18 | "xz", 19 | "zstd", 20 | "unzip", 21 | # commands used by the device provisioner 22 | "sudo", 23 | "dd", 24 | "fastboot", 25 | ) 26 | 27 | _CMD_PRESENCE_MAP: Final[dict[str, bool]] = {} 28 | 29 | 30 | def init_cmd_presence_map() -> None: 31 | _CMD_PRESENCE_MAP.clear() 32 | for cmd in _CMDS: 33 | _CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd) 34 | 35 | 36 | def ensure_cmds( 37 | logger: RuyiLogger, 38 | cmds: Iterable[str], 39 | interactive_retry: bool = True, 40 | ) -> None | NoReturn: 41 | # only allow interactive retry if stdin is a TTY 42 | interactive_retry = interactive_retry and sys.stdin.isatty() 43 | 44 | while True: 45 | if not _CMD_PRESENCE_MAP or interactive_retry: 46 | init_cmd_presence_map() 47 | 48 | # in case any command's availability is not cached in advance 49 | for cmd in cmds: 50 | if cmd not in _CMD_PRESENCE_MAP: 51 | _CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd) 52 | 53 | absent_cmds = sorted( 54 | cmd for cmd in cmds if not _CMD_PRESENCE_MAP.get(cmd, False) 55 | ) 56 | if not absent_cmds: 57 | return None 58 | 59 | cmds_str = humanize_list(absent_cmds, item_color="yellow") 60 | prompt = f"The command(s) {cmds_str} cannot be found in PATH, which [yellow]ruyi[/] requires" 61 | if not interactive_retry: 62 | logger.F(prompt) 63 | logger.I("please install and retry") 64 | sys.exit(1) 65 | 66 | logger.W(prompt) 67 | logger.I( 68 | "please install them and press [green]Enter[/] to retry, or [green]Ctrl+C[/] to exit" 69 | ) 70 | try: 71 | pause_before_continuing(logger) 72 | except EOFError: 73 | logger.I("exiting due to EOF") 74 | sys.exit(1) 75 | except KeyboardInterrupt: 76 | logger.I("exiting due to keyboard interrupt") 77 | sys.exit(1) 78 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/unpack_method.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | import sys 4 | from typing import Final 5 | 6 | RE_TARBALL: Final = re.compile(r"\.tar(?:\.gz|\.bz2|\.lz4|\.xz|\.zst)?$") 7 | 8 | 9 | if sys.version_info >= (3, 11): 10 | 11 | class UnpackMethod(enum.StrEnum): 12 | UNKNOWN = "" 13 | AUTO = "auto" 14 | TAR_AUTO = "tar.auto" 15 | 16 | RAW = "raw" 17 | GZ = "gz" 18 | BZ2 = "bz2" 19 | LZ4 = "lz4" 20 | XZ = "xz" 21 | ZST = "zst" 22 | 23 | TAR = "tar" 24 | TAR_GZ = "tar.gz" 25 | TAR_BZ2 = "tar.bz2" 26 | TAR_LZ4 = "tar.lz4" 27 | TAR_XZ = "tar.xz" 28 | TAR_ZST = "tar.zst" 29 | 30 | ZIP = "zip" 31 | DEB = "deb" 32 | 33 | else: 34 | 35 | class UnpackMethod(str, enum.Enum): 36 | UNKNOWN = "" 37 | AUTO = "auto" 38 | TAR_AUTO = "tar.auto" 39 | 40 | RAW = "raw" 41 | GZ = "gz" 42 | BZ2 = "bz2" 43 | LZ4 = "lz4" 44 | XZ = "xz" 45 | ZST = "zst" 46 | 47 | TAR = "tar" 48 | TAR_GZ = "tar.gz" 49 | TAR_BZ2 = "tar.bz2" 50 | TAR_LZ4 = "tar.lz4" 51 | TAR_XZ = "tar.xz" 52 | TAR_ZST = "tar.zst" 53 | 54 | ZIP = "zip" 55 | DEB = "deb" 56 | 57 | 58 | class UnrecognizedPackFormatError(Exception): 59 | def __init__(self, filename: str) -> None: 60 | self.filename = filename 61 | 62 | def __str__(self) -> str: 63 | return f"don't know how to unpack file {self.filename}" 64 | 65 | 66 | def determine_unpack_method( 67 | filename: str, 68 | ) -> UnpackMethod: 69 | filename_lower = filename.lower() 70 | if m := RE_TARBALL.search(filename_lower): 71 | return UnpackMethod(m.group(0)[1:]) 72 | if filename_lower.endswith(".deb"): 73 | return UnpackMethod.DEB 74 | if filename_lower.endswith(".zip"): 75 | return UnpackMethod.ZIP 76 | if filename_lower.endswith(".gz"): 77 | # bare gzip file 78 | return UnpackMethod.GZ 79 | if filename_lower.endswith(".bz2"): 80 | # bare bzip2 file 81 | return UnpackMethod.BZ2 82 | if filename_lower.endswith(".lz4"): 83 | # bare lz4 file 84 | return UnpackMethod.LZ4 85 | if filename_lower.endswith(".xz"): 86 | # bare xz file 87 | return UnpackMethod.XZ 88 | if filename_lower.endswith(".zst"): 89 | # bare zstd file 90 | return UnpackMethod.ZST 91 | return UnpackMethod.UNKNOWN 92 | -------------------------------------------------------------------------------- /docs/ci-self-hosted-runner.md: -------------------------------------------------------------------------------- 1 | # Repo CI: Self-hosted runner 管理 2 | 3 | 目前 GitHub Actions 官方提供的 runners 仅支持 amd64 架构,且官方的 self-hosted runner 支持仅覆盖 4 | amd64 与 arm64 架构。鉴于 `ruyi` 需要支持 amd64、arm64 与 riscv64 三种架构,并且 5 | Nuitka [架构上无法支持交叉编译](https://github.com/Nuitka/Nuitka/issues/43)而 6 | QEMU 模拟下的 Nuitka 又很慢(半小时左右),因此总之我们都需要自行维护一些 7 | runners 以使 CI 运行速度不至于过分缓慢。 8 | 9 | ## riscv64 runner 10 | 11 | GitHub Actions Runner 官方暂未提供 riscv64 架构支持,所幸社区已有勇士将流程走通。 12 | 我们使用的是 [dkurt/github_actions_riscv](https://github.com/dkurt/github_actions_riscv) 13 | 项目提供的成品包。 14 | 15 | 有一些地方需要注意: 16 | 17 | * v2.312.0 中的 `externals/node16` 未替换为 riscv64 二进制,这会导致 `actions/cache@v4` 18 | 等 action 运行失败。需要手动编译替换。 19 | * 如果宿主系统是 Debian 系的发行版:Runner 依赖 `docker` 但发行版未打包。 20 | 需要安装 `podman` 并做些特殊处理。 21 | 22 | ### 替换 Node.js 16.x 23 | 24 | v2.312.0 中的 `node16` 是 16.20.2 这个当下最新的 LTS 版本。应该不用非得是这个版本。 25 | 26 | 去 https://nodejs.org/download/release/latest-v16.x/ 下载源码,解压,然后构建 tarball: 27 | 28 | ```sh 29 | # 以 v16.20.2 为例 30 | 31 | tar xf node-v16.20.2.tar.xz 32 | cd node-16.20.2 33 | 34 | # 如果准备启用 LTO,可能需要调整 LTO 并发数,否则默认的 4 喂不饱一些核数多的硬件 35 | # vim common.gypi 36 | # 寻找 flto=4 的字样并调整之 37 | 38 | # 自行调整并发 39 | # 该版本 node 自带的 openssl 无法以默认参数通过编译(系统会被探测为 x86_64), 40 | # 因此需要在系统级别安装 libssl-dev 并动态链接之 41 | # 为了提高构建速度,使用 Ninja (apt-get install ninja-build) 42 | make binary -j64 CONFIG_FLAGS='--enable-lto --ninja --shared-openssl' 43 | ``` 44 | 45 | 然后替换 GHA runner 的 `externals/node16`: 46 | 47 | ```sh 48 | cd /path/to/gha/externals 49 | rm -rf node16 50 | tar xf /path/to/your/node-v16.20.2-linux-riscv64.tar.xz 51 | mv node-v16.20.2-linux-riscv64 node16 52 | ``` 53 | 54 | ### 配置 podman 55 | 56 | 本库使用基于容器的 CI 配置,因此需要在 runner 宿主上准备好容器运行时。由于目前 57 | Debian riscv64 port 没有打包 `docker`,我们换用 `podman`。但 GitHub Actions runner 58 | 官方[暂未支持 `podman`](https://github.com/actions/runner/issues/505),因此也需要一些特殊处理。 59 | 60 | 为了避免不必要的麻烦,最好在 GHA 以自己的用户身份第一次发起 `podman` 调用之前执行。 61 | 62 | ```sh 63 | cd /usr/local/bin 64 | sudo ln -s /usr/bin/podman docker 65 | 66 | cd /var/run 67 | sudo ln -s podman/podman.sock docker.sock 68 | 69 | # 本例中 GHA runner 以 gha 用户身份执行,系统上已分配了 100000-231071 的 subuid/subgid 范围 70 | # 请自行调整 71 | sudo usermod --add-subuids 231072-296607 --add-subgids 231072-296607 gha 72 | ``` 73 | 74 | 阅读材料: 75 | 76 | * 关于 `cannot re-exec process` 相关错误 77 | - https://github.com/containers/podman/issues/9137 78 | - https://github.com/containers/podman/issues/14635 79 | * [Podman rootless mode tutorial](https://github.com/containers/podman/blob/v4.9/docs/tutorials/rootless_tutorial.md) 80 | -------------------------------------------------------------------------------- /tests/utils/test_l10n.py: -------------------------------------------------------------------------------- 1 | from ruyi.utils.l10n import lang_code_to_lang_region, LangAndRegion, match_lang_code 2 | 3 | 4 | def test_lang_code_to_lang_region() -> None: 5 | assert lang_code_to_lang_region("en", False) == LangAndRegion("en", "en", None) 6 | assert lang_code_to_lang_region("en", True) == LangAndRegion("en", "en", "US") 7 | assert lang_code_to_lang_region("en_SG", False) == LangAndRegion( 8 | "en_SG", "en", "SG" 9 | ) 10 | assert lang_code_to_lang_region("zh", False) == LangAndRegion("zh", "zh", None) 11 | assert lang_code_to_lang_region("zh", True) == LangAndRegion("zh", "zh", "CN") 12 | assert lang_code_to_lang_region("zh_HK", False) == LangAndRegion( 13 | "zh_HK", "zh", "HK" 14 | ) 15 | assert lang_code_to_lang_region("cmn", False) == LangAndRegion("cmn", "cmn", None) 16 | assert lang_code_to_lang_region("cmn", True) == LangAndRegion("cmn", "cmn", None) 17 | 18 | 19 | def test_match_lang_code() -> None: 20 | assert match_lang_code("zh", ["en"]) == "en" 21 | assert match_lang_code("en_US", ["en"]) == "en" 22 | assert match_lang_code("en", ["en_US"]) == "en_US" 23 | assert match_lang_code("en_US", ["en_US"]) == "en_US" 24 | 25 | assert match_lang_code("zh", ["en", "zh"]) == "zh" 26 | assert match_lang_code("zh", ["en", "zh_CN"]) == "zh_CN" 27 | assert match_lang_code("zh", ["en", "zh_HK"]) == "zh_HK" 28 | assert match_lang_code("zh_HK", ["en", "zh_CN"]) == "zh_CN" 29 | assert match_lang_code("zh_CN", ["en", "zh_HK"]) == "zh_HK" 30 | 31 | # match according to region 32 | assert match_lang_code("ga", ["en", "en_IE", "zh_CN"]) == "en_IE" 33 | 34 | # match according to language 35 | assert match_lang_code("pt", ["pt_BR", "en", "zh"]) == "pt_BR" 36 | 37 | # fallback in the order of en_US, en_*, zh_CN, zh_* 38 | assert ( 39 | match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE", "en", "en_US"]) 40 | == "en_US" 41 | ) 42 | assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE", "en"]) == "en" 43 | assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN", "en_IE"]) == "en_IE" 44 | assert match_lang_code("pt", ["ga", "zh_HK", "zh", "zh_CN"]) == "zh_CN" 45 | assert match_lang_code("pt", ["ga", "zh_HK", "zh"]) == "zh" 46 | assert match_lang_code("pt", ["ga", "zh_HK"]) == "zh_HK" 47 | 48 | # fallback to the lexicographically first one 49 | assert match_lang_code("ru", ["ga", "es_ES"]) == "es_ES" 50 | assert match_lang_code("ru", ["es_ES", "ga"]) == "es_ES" 51 | -------------------------------------------------------------------------------- /docs/repo-pkg-versioning-convention.md: -------------------------------------------------------------------------------- 1 | # RuyiSDK 软件源的软件包版本约定 2 | 3 | RuyiSDK 包管理器对所有软件包都采用[语义化版本][semver](SemVer)风格的版本号。而 RuyiSDK 打包的许多软件,其上游并未采用 4 | SemVer 规范,这样便给打包者带来了“我在打包上游版本 X 时,应当采用怎样的 RuyiSDK 版本 Y”这样一个问题。 5 | 6 | [semver]: https://semver.org/ 7 | 8 | 下面按照软件包的上游性质进行分类讨论。 9 | 10 | ## 以 RuyiSDK 团队或相关方为上游的软件包 11 | 12 | 由于这些软件包的打包与发布过程为 RuyiSDK 团队所控制或影响,考虑到 RuyiSDK 包管理器这一分发渠道,RuyiSDK 13 | 团队一般会选择 SemVer 风格的版本。此时上游版本号与 RuyiSDK 软件包版本号始终保持一致。 14 | 15 | 对于工具链打包(涉及众多组件版本)、滚动打包(上游版本不更新或不清晰)等场景,此时可以附加 RuyiSDK 16 | 特定的日期时间戳,见“RuyiSDK 日期时间戳”一节。 17 | 18 | ## 不以 RuyiSDK 团队或相关方为上游的软件包 19 | 20 | 这些软件包的版本号规律 RuyiSDK 团队不能决定,因此对于它们向 RuyiSDK 软件包版本号的映射方式,存在几种情况。 21 | 22 | * 上游采用 SemVer。 23 | * 可以直接采用上游版本号。 24 | * 上游采用[日历化版本][calver](CalVer)或其他版本方案,但与 SemVer 相对较为兼容;或上游尽管不采用 SemVer 但该软件的流行程度相当之高。 25 | * 当对应 SemVer 大版本号的位置发生变化时,用户必须或十分建议跟进升级; 26 | * 且当对应 SemVer 大版本号的位置不变时,用户不是那么需要跟进升级。 27 | * 或者,如果不满足上述任一条件,但由于用户群体的心智已经十分根深蒂固,以至于采用编造的 28 | SemVer 方案反而将对用户体验造成损失:例如 GCC、LLVM/Clang、QEMU 都不采用 29 | SemVer,它们的大版本变更不见得会影响所有项目,但想必很难说服用户使用不同的版本号称呼这些软件。 30 | * 在这些情况下,可以直接采用上游版本号。 31 | * 上游采用 CalVer 或其他版本方案,且与 SemVer 相对不兼容,且相应软件不甚驰名。 32 | * 不满足前述 CalVer 情况的任一判断要件,将造成用户使用不便:要么无意中被卡在旧的版本,要么将收到“SemVer major”一致但与先前所用版本不兼容的新版本。 33 | * 需要维护者按照 SemVer 规范,编造一种映射。目前不对映射的具体方式进行强制规定。举例如下: 34 | * 上游版本 `23.10`,对应 RuyiSDK 版本 `1.2310.0`。 35 | * 与上述上游版本兼容的 `24.04`,对应 RuyiSDK 版本 `1.2404.0`。 36 | * 与上述上游版本不兼容的 `24.10`,对应 RuyiSDK 版本 `2.2410.0`。 37 | * 上游采用其他版本方案。 38 | * 也需要维护者按照 SemVer 规范,编造一种映射。目前不对映射的具体方式进行强制规定。 39 | 40 | 在映射版本号的过程中,应尽量保持关键要素的对齐。例如: 41 | 42 | * 为 Python 软件包所采用的 [PEP 440][pep-0440] 风格版本,其“prerelease”部分如果为 43 | `a` `b`,应被映射到 SemVer 的 `alpha` `beta` 写法。 44 | * 如上游版本号中包含 Git commit hash,为减少对排序算法的影响,应将其表示为 SemVer build tag,并在必要时补充 RuyiSDK 日期时间戳等体现排序的信息。 45 | * 例如:`emulator/qemu-user-riscv-xthead` 的 `6.1.0-ruyi.20231207+g03813c9fe8` 版本。 46 | 47 | [pep-0440]: https://peps.python.org/pep-0440/ 48 | 49 | ## RuyiSDK 日期时间戳 50 | 51 | RuyiSDK 包管理器会特殊对待形如 `-ruyi.YYYYMMDD` 的 SemVer prerelease 标签:如无其他 52 | prerelease 标记,则不将其视作 prerelease 版本。 53 | 54 | 作为[日历化版本][calver](CalVer)的实践,这使得 RuyiSDK 可以为相同的上游版本打出不同的包:如为了修复打包脚本中的错误或上游未修复的紧急问题等。或者对于那些 55 | RuyiSDK 能够自行决定发版方式与节奏的包,发版日期即可直接成为版本,使得发版的心智负担更低。 56 | 57 | [calver]: https://calver.org/ 58 | 59 | 原则上,只要一个软件包的内容受到了源自 RuyiSDK 的影响,包括但不限于: 60 | 61 | * RuyiSDK 团队对上游发行版本进行了重打包,且对文件内容进行了修改; 62 | * 软件包是 RuyiSDK 基于上游提供的源码包自行编译构建的; 63 | 64 | 那么在该软件包的版本号中,就应体现一个 RuyiSDK 日期时间戳。 65 | 66 | ## 上游版本号与 RuyiSDK 版本号映射关系的记录 67 | 68 | 目前 RuyiSDK 软件源并未提供记录该信息的手段。后续需要在 RuyiSDK 软件源中对此进行支持。在此之前,需要开发者在下游工具中自行跟踪相关信息。 69 | -------------------------------------------------------------------------------- /ruyi/utils/ar.py: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | from typing import BinaryIO, TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from types import TracebackType 6 | 7 | import arpy 8 | 9 | 10 | class ArpyArchiveWrapper(arpy.Archive, AbstractContextManager["arpy.Archive"]): 11 | """Compatibility shim for arpy.Archive, for easy interop with both arpy 1.x 12 | and 2.x.""" 13 | 14 | def __init__( 15 | self, 16 | filename: str | None = None, 17 | fileobj: BinaryIO | None = None, 18 | ) -> None: 19 | super().__init__(filename=filename, fileobj=fileobj) 20 | 21 | def __enter__(self) -> arpy.Archive: 22 | if hasattr(super(), "__enter__"): 23 | # in case we're working with a newer arpy version that has a 24 | # non-trivial __enter__ implementation 25 | return super().__enter__() 26 | 27 | # backport of arpy 2.x __enter__ implementation 28 | return self 29 | 30 | def __exit__( 31 | self, 32 | exc_type: type[BaseException] | None, 33 | exc_value: BaseException | None, 34 | traceback: "TracebackType | None", 35 | ) -> None: 36 | if hasattr(super(), "__exit__"): 37 | return super().__exit__(exc_type, exc_value, traceback) 38 | 39 | # backport of arpy 2.x __exit__ implementation 40 | self.close() 41 | 42 | def infolist(self) -> list[arpy.ArchiveFileHeader]: 43 | if hasattr(super(), "infolist"): 44 | return super().infolist() 45 | 46 | # backport of arpy 2.x infolist() 47 | self.read_all_headers() 48 | return [ 49 | header 50 | for header in self.headers 51 | if header.type 52 | in ( 53 | arpy.HEADER_BSD, 54 | arpy.HEADER_NORMAL, 55 | arpy.HEADER_GNU, 56 | ) 57 | ] 58 | 59 | def open(self, name: bytes | arpy.ArchiveFileHeader) -> arpy.ArchiveFileData: 60 | if hasattr(super(), "open"): 61 | return super().open(name) 62 | 63 | # backport of arpy 2.x open() 64 | if isinstance(name, bytes): 65 | ar_file = self.archived_files.get(name) 66 | if ar_file is None: 67 | raise KeyError("There is no item named %r in the archive" % (name,)) 68 | 69 | return ar_file 70 | 71 | if name not in self.headers: 72 | raise KeyError("Provided header does not match this archive") 73 | 74 | return arpy.ArchiveFileData(ar_obj=self, header=name) 75 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/list_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from ..cli.cmd import RootCommand 5 | from .list_filter import ListFilter, ListFilterAction 6 | 7 | if TYPE_CHECKING: 8 | from ..cli.completion import ArgumentParser 9 | from ..config import GlobalConfig 10 | 11 | 12 | class ListCommand( 13 | RootCommand, 14 | cmd="list", 15 | has_subcommands=True, 16 | is_subcommand_required=False, 17 | has_main=True, 18 | help="List available packages in configured repository", 19 | ): 20 | @classmethod 21 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 22 | p.add_argument( 23 | "--verbose", 24 | "-v", 25 | action="store_true", 26 | help="Also show details for every package", 27 | ) 28 | 29 | # filter expressions 30 | p.add_argument( 31 | "--is-installed", 32 | action=ListFilterAction, 33 | nargs=1, 34 | dest="filters", 35 | help="Match packages that are installed (y/true/1) or not installed (n/false/0)", 36 | ) 37 | p.add_argument( 38 | "--category-contains", 39 | action=ListFilterAction, 40 | nargs=1, 41 | dest="filters", 42 | help="Match packages from categories whose names contain the given string", 43 | ) 44 | p.add_argument( 45 | "--category-is", 46 | action=ListFilterAction, 47 | nargs=1, 48 | dest="filters", 49 | help="Match packages from the given category", 50 | ) 51 | p.add_argument( 52 | "--name-contains", 53 | action=ListFilterAction, 54 | nargs=1, 55 | dest="filters", 56 | help="Match packages whose names contain the given string", 57 | ) 58 | 59 | if gc.is_experimental: 60 | p.add_argument( 61 | "--related-to-entity", 62 | action=ListFilterAction, 63 | nargs=1, 64 | dest="filters", 65 | help="Match packages related to the given entity", 66 | ) 67 | 68 | @classmethod 69 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 70 | from .list import do_list 71 | 72 | verbose: bool = args.verbose 73 | filters: ListFilter = args.filters 74 | 75 | return do_list( 76 | cfg, 77 | filters=filters, 78 | verbose=verbose, 79 | ) 80 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/admin_checksum.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Any, TypeGuard 4 | 5 | from tomlkit import document, table 6 | from tomlkit.items import AoT, Table 7 | from tomlkit.toml_document import TOMLDocument 8 | 9 | from ..log import RuyiLogger 10 | from . import checksum 11 | from .pkg_manifest import DistfileDeclType, RestrictKind 12 | 13 | 14 | def do_admin_checksum( 15 | logger: RuyiLogger, 16 | files: list[os.PathLike[Any]], 17 | format: str, 18 | restrict: list[str], 19 | ) -> int: 20 | if not validate_restrict_kinds(restrict): 21 | logger.F(f"invalid restrict kinds given: {restrict}") 22 | return 1 23 | 24 | entries = [gen_distfile_entry(logger, f, restrict) for f in files] 25 | if format == "toml": 26 | doc = emit_toml_distfiles_section(entries) 27 | logger.D(f"{doc}") 28 | sys.stdout.write(doc.as_string()) 29 | return 0 30 | 31 | raise RuntimeError("unrecognized output format; should never happen") 32 | 33 | 34 | def validate_restrict_kinds(input: list[str]) -> TypeGuard[list[RestrictKind]]: 35 | for x in input: 36 | match x: 37 | case "fetch" | "mirror": 38 | pass 39 | case _: 40 | return False 41 | return True 42 | 43 | 44 | def gen_distfile_entry( 45 | logger: RuyiLogger, 46 | path: os.PathLike[Any], 47 | restrict: list[RestrictKind], 48 | ) -> DistfileDeclType: 49 | logger.D(f"generating distfile entry for {path}") 50 | with open(path, "rb") as fp: 51 | filesize = os.stat(fp.fileno()).st_size 52 | c = checksum.Checksummer(fp, {}) 53 | checksums = c.compute(kinds=checksum.SUPPORTED_CHECKSUM_KINDS) 54 | 55 | obj: DistfileDeclType = { 56 | "name": os.path.basename(path), 57 | "size": filesize, 58 | "checksums": checksums, 59 | } 60 | 61 | if restrict: 62 | obj["restrict"] = restrict 63 | 64 | return obj 65 | 66 | 67 | def emit_toml_distfiles_section(x: list[DistfileDeclType]) -> TOMLDocument: 68 | doc = document() 69 | 70 | arr: list[Table] = [] 71 | for dd in x: 72 | t = table() 73 | t.add("name", dd["name"]) 74 | t.add("size", dd["size"]) 75 | if r := dd.get("restrict"): 76 | t.add("restrict", r) 77 | t.add("checksums", emit_toml_checksums(dd["checksums"])) 78 | arr.append(t) 79 | 80 | doc.add("distfiles", AoT(arr)) 81 | return doc 82 | 83 | 84 | def emit_toml_checksums(x: dict[str, str]) -> Table: 85 | t = table() 86 | for k in sorted(x.keys()): 87 | t.add(k, x[k]) 88 | return t 89 | -------------------------------------------------------------------------------- /ruyi/telemetry/aggregate.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, TypeAlias, TypedDict, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from typing_extensions import NotRequired 5 | 6 | from ..utils.node_info import NodeInfo 7 | from .event import TelemetryEvent 8 | 9 | 10 | class AggregatedTelemetryEvent(TypedDict): 11 | time_bucket: str 12 | kind: str 13 | params: list[tuple[str, str]] 14 | count: int 15 | 16 | 17 | class UploadPayload(TypedDict): 18 | fmt: int 19 | nonce: str 20 | ruyi_version: str 21 | report_uuid: "NotRequired[str]" 22 | """Optional field in case the client wishes to report this, and nothing 23 | else. If `installation` is present, this field is ignored.""" 24 | installation: "NotRequired[NodeInfo | None]" 25 | """More detailed installation info that the client has user consent to report.""" 26 | events: list[AggregatedTelemetryEvent] 27 | """Aggregated telemetry events that the client has user consent to upload.""" 28 | 29 | 30 | def stringify_param_val(v: object) -> str: 31 | if v is None: 32 | return "null" 33 | if isinstance(v, bool): 34 | return "1" if v else "0" 35 | if isinstance(v, bytes): 36 | return v.decode("utf-8") 37 | if isinstance(v, str): 38 | return v 39 | return str(v) 40 | 41 | 42 | AggregateKey: TypeAlias = tuple[tuple[str, str], ...] 43 | 44 | 45 | def _make_aggregate_key(ev: TelemetryEvent) -> AggregateKey: 46 | param_list = [(k, stringify_param_val(v)) for k, v in ev["params"].items()] 47 | param_list.sort() 48 | return tuple([("", ev["kind"])] + param_list) 49 | 50 | 51 | def aggregate_events( 52 | events: Iterable[TelemetryEvent], 53 | ) -> Iterable[AggregatedTelemetryEvent]: 54 | # dict[time_bucket, dict[AggregateKey, count]] 55 | buf: dict[str, dict[AggregateKey, int]] = {} 56 | for raw_ev in events: 57 | time_bucket = raw_ev.get("time_bucket") 58 | if time_bucket is None: 59 | continue 60 | if time_bucket not in buf: 61 | buf[time_bucket] = {} 62 | 63 | agg_key = _make_aggregate_key(raw_ev) 64 | if agg_key not in buf[time_bucket]: 65 | buf[time_bucket][agg_key] = 1 66 | else: 67 | buf[time_bucket][agg_key] += 1 68 | 69 | for time_bucket in sorted(buf.keys()): 70 | bucket_events = buf[time_bucket] 71 | for agg_key in sorted(bucket_events.keys()): 72 | yield { 73 | "time_bucket": time_bucket, 74 | "kind": agg_key[0][1], 75 | "params": list(agg_key[1:]), 76 | "count": bucket_events[agg_key], 77 | } 78 | -------------------------------------------------------------------------------- /stubs/arpy.pyi: -------------------------------------------------------------------------------- 1 | import io 2 | import types 3 | from typing import BinaryIO 4 | 5 | HEADER_BSD: int 6 | HEADER_GNU: int 7 | HEADER_GNU_TABLE: int 8 | HEADER_GNU_SYMBOLS: int 9 | HEADER_NORMAL: int 10 | HEADER_TYPES: dict[int, str] 11 | GLOBAL_HEADER_LEN: int 12 | HEADER_LEN: int 13 | 14 | class ArchiveFormatError(Exception): ... 15 | class ArchiveAccessError(IOError): ... 16 | 17 | class ArchiveFileHeader: 18 | type: int 19 | size: int 20 | timestamp: int 21 | uid: int | None 22 | gid: int | None 23 | mode: int 24 | offset: int 25 | name: bytes 26 | file_offset: int | None 27 | proxy_name: bytes 28 | def __init__(self, header: bytes, offset: int) -> None: ... 29 | 30 | class ArchiveFileData(io.IOBase): 31 | header: ArchiveFileHeader 32 | arobj: Archive 33 | last_offset: int 34 | def __init__(self, ar_obj: Archive, header: ArchiveFileHeader) -> None: ... 35 | def read(self, size: int | None = None) -> bytes: ... 36 | def tell(self) -> int: ... 37 | def seek(self, offset: int, whence: int = 0) -> int: ... 38 | def seekable(self) -> bool: ... 39 | def __enter__(self) -> ArchiveFileData: ... 40 | def __exit__( 41 | self, 42 | _exc_type: type[BaseException] | None, 43 | _exc_value: BaseException | None, 44 | _traceback: types.TracebackType | None, 45 | ) -> None: ... 46 | 47 | class ArchiveFileDataThin(ArchiveFileData): 48 | file_path: str 49 | def __init__(self, ar_obj: Archive, header: ArchiveFileHeader) -> None: ... 50 | def read(self, size: int | None = None) -> bytes: ... 51 | 52 | class Archive: 53 | headers: list[ArchiveFileHeader] 54 | file: BinaryIO 55 | position: int 56 | reached_eof: bool 57 | file_data_class: type[ArchiveFileData] | type[ArchiveFileDataThin] 58 | next_header_offset: int 59 | gnu_table: dict[int, bytes] 60 | archived_files: dict[bytes, ArchiveFileData] 61 | def __init__( 62 | self, filename: str | None = None, fileobj: BinaryIO | None = None 63 | ) -> None: ... 64 | def read_next_header(self) -> ArchiveFileHeader | None: ... 65 | def __next__(self) -> ArchiveFileData: ... 66 | next = __next__ 67 | def __iter__(self) -> Archive: ... 68 | def read_all_headers(self) -> None: ... 69 | def close(self) -> None: ... 70 | def namelist(self) -> list[bytes]: ... 71 | def infolist(self) -> list[ArchiveFileHeader]: ... 72 | def open(self, name: bytes | ArchiveFileHeader) -> ArchiveFileData: ... 73 | def __enter__(self) -> Archive: ... 74 | def __exit__( 75 | self, 76 | _exc_type: type[BaseException] | None, 77 | _exc_value: BaseException | None, 78 | _traceback: types.TracebackType | None, 79 | ) -> None: ... 80 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/news_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import TYPE_CHECKING 3 | 4 | from ..cli.cmd import RootCommand 5 | 6 | if TYPE_CHECKING: 7 | from ..cli.completion import ArgumentParser 8 | from ..config import GlobalConfig 9 | 10 | 11 | class NewsCommand( 12 | RootCommand, 13 | cmd="news", 14 | has_subcommands=True, 15 | is_subcommand_required=False, 16 | has_main=True, 17 | help="List and read news items from configured repository", 18 | ): 19 | _my_parser: "ArgumentParser | None" = None 20 | 21 | @classmethod 22 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 23 | cls._my_parser = p 24 | 25 | @classmethod 26 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 27 | from .news import maybe_notify_unread_news 28 | 29 | assert cls._my_parser is not None 30 | cls._my_parser.print_help() 31 | maybe_notify_unread_news(cfg, True) 32 | 33 | return 0 34 | 35 | 36 | class NewsListCommand( 37 | NewsCommand, 38 | cmd="list", 39 | help="List news items", 40 | ): 41 | @classmethod 42 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 43 | p.add_argument( 44 | "--new", 45 | action="store_true", 46 | help="List unread news items only", 47 | ) 48 | 49 | @classmethod 50 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 51 | from .news import do_news_list 52 | 53 | only_unread: bool = args.new 54 | return do_news_list( 55 | cfg, 56 | only_unread, 57 | ) 58 | 59 | 60 | class NewsReadCommand( 61 | NewsCommand, 62 | cmd="read", 63 | help="Read news items", 64 | description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.", 65 | ): 66 | @classmethod 67 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 68 | p.add_argument( 69 | "--quiet", 70 | "-q", 71 | action="store_true", 72 | help="Do not output anything and only mark as read", 73 | ) 74 | p.add_argument( 75 | "item", 76 | type=str, 77 | nargs="*", 78 | help="Ordinal or ID of the news item(s) to read", 79 | ) 80 | 81 | @classmethod 82 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 83 | from .news import do_news_read 84 | 85 | quiet: bool = args.quiet 86 | items_strs: list[str] = args.item 87 | 88 | return do_news_read( 89 | cfg, 90 | quiet, 91 | items_strs, 92 | ) 93 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/admin_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | from typing import TYPE_CHECKING, cast 4 | 5 | from ..cli.cmd import AdminCommand 6 | 7 | if TYPE_CHECKING: 8 | from ..cli.completion import ArgumentParser 9 | from ..config import GlobalConfig 10 | 11 | 12 | class AdminChecksumCommand( 13 | AdminCommand, 14 | cmd="checksum", 15 | help="Generate a checksum section for a manifest file for the distfiles given", 16 | ): 17 | @classmethod 18 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 19 | p.add_argument( 20 | "--format", 21 | "-f", 22 | type=str, 23 | choices=["toml"], 24 | default="toml", 25 | help="Format of checksum section to generate in", 26 | ) 27 | p.add_argument( 28 | "--restrict", 29 | type=str, 30 | default="", 31 | help="the 'restrict' field to use for all mentioned distfiles, separated with comma", 32 | ) 33 | p.add_argument( 34 | "file", 35 | type=str, 36 | nargs="+", 37 | help="Path to the distfile(s) to checksum", 38 | ) 39 | 40 | @classmethod 41 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 42 | from .admin_checksum import do_admin_checksum 43 | 44 | logger = cfg.logger 45 | files = args.file 46 | format = args.format 47 | restrict_str = cast(str, args.restrict) 48 | restrict = restrict_str.split(",") if restrict_str else [] 49 | 50 | return do_admin_checksum(logger, files, format, restrict) 51 | 52 | 53 | class AdminFormatManifestCommand( 54 | AdminCommand, 55 | cmd="format-manifest", 56 | help="Format the given package manifests into canonical TOML representation", 57 | ): 58 | @classmethod 59 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 60 | p.add_argument( 61 | "file", 62 | type=str, 63 | nargs="+", 64 | help="Path to the distfile(s) to generate manifest for", 65 | ) 66 | 67 | @classmethod 68 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 69 | from .canonical_dump import dumps_canonical_package_manifest_toml 70 | from .pkg_manifest import PackageManifest 71 | 72 | files = args.file 73 | 74 | for f in files: 75 | p = pathlib.Path(f) 76 | pm = PackageManifest.load_from_path(p) 77 | d = dumps_canonical_package_manifest_toml(pm) 78 | 79 | dest_path = p.with_suffix(".toml") 80 | with open(dest_path, "w", encoding="utf-8") as fp: 81 | fp.write(d) 82 | 83 | return 0 84 | -------------------------------------------------------------------------------- /ruyi/cli/oobe.py: -------------------------------------------------------------------------------- 1 | """First-run (Out-of-the-box) experience for ``ruyi``.""" 2 | 3 | import os 4 | import sys 5 | from typing import Callable, TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from ..config import GlobalConfig 9 | 10 | 11 | SHELL_AUTO_COMPLETION_TIP = """ 12 | [bold green]tip[/]: you can enable shell auto-completion for [yellow]ruyi[/] by adding the 13 | following line to your [green]{shrc}[/], if you have not done so already: 14 | 15 | [green]eval "$(ruyi --output-completion-script={shell})"[/] 16 | 17 | You can do so by running the following command later: 18 | 19 | [green]echo 'eval "$(ruyi --output-completion-script={shell})"' >> {shrc}[/] 20 | """ 21 | 22 | 23 | class OOBE: 24 | """Out-of-the-box experience (OOBE) handler for RuyiSDK CLI.""" 25 | 26 | def __init__(self, gc: "GlobalConfig") -> None: 27 | self._gc = gc 28 | self.handlers: list[Callable[[], None]] = [ 29 | self._builtin_shell_completion_tip, 30 | ] 31 | 32 | def is_first_run(self) -> bool: 33 | # We now always have our first-run indicator because of the minimal 34 | # telemetry mode. 35 | return self._gc.telemetry.is_first_run 36 | 37 | def should_prompt(self) -> bool: 38 | from ..utils.global_mode import is_env_var_truthy 39 | 40 | if not sys.stdin.isatty() or not sys.stdout.isatty(): 41 | # This is of higher priority than even the debug override, because 42 | # we don't want to mess up non-interactive sessions even in case of 43 | # debugging. 44 | return False 45 | 46 | if is_env_var_truthy(os.environ, "RUYI_DEBUG_FORCE_FIRST_RUN"): 47 | return True 48 | 49 | return self.is_first_run() 50 | 51 | def maybe_prompt(self) -> None: 52 | if not self.should_prompt(): 53 | return 54 | 55 | logger = self._gc.logger 56 | logger.I( 57 | "Welcome to RuyiSDK! This appears to be your first run of [yellow]ruyi[/].", 58 | ) 59 | 60 | for handler in self.handlers: 61 | handler() 62 | 63 | def _builtin_shell_completion_tip(self) -> None: 64 | from ..utils.node_info import probe_for_shell 65 | from .completion import SUPPORTED_SHELLS 66 | 67 | # Only show the tip if we're not externally managed by a package manager, 68 | # because we expect proper shell integration to be done by distro packagers 69 | if self._gc.is_installation_externally_managed: 70 | return 71 | 72 | shell = probe_for_shell(os.environ) 73 | if shell not in SUPPORTED_SHELLS: 74 | return 75 | 76 | self._gc.logger.stdout( 77 | SHELL_AUTO_COMPLETION_TIP.format( 78 | shell=shell, 79 | shrc=f"~/.{shell}rc", 80 | ) 81 | ) 82 | -------------------------------------------------------------------------------- /ruyi/utils/l10n.py: -------------------------------------------------------------------------------- 1 | import locale 2 | 3 | from typing import Iterable, NamedTuple 4 | 5 | 6 | class LangAndRegion(NamedTuple): 7 | raw: str 8 | lang: str 9 | region: str | None 10 | 11 | 12 | def lang_code_to_lang_region(lang_code: str, guess_region: bool) -> LangAndRegion: 13 | if not guess_region and "_" not in lang_code: 14 | return LangAndRegion(lang_code, lang_code, None) 15 | 16 | lang_region_str = locale.normalize(lang_code).split(".")[0] 17 | parts = lang_region_str.split("_", 2) 18 | if len(parts) == 1: 19 | return LangAndRegion(lang_code, lang_region_str, None) 20 | return LangAndRegion(lang_code, parts[0], parts[1]) 21 | 22 | 23 | def match_lang_code( 24 | req: str, 25 | avail: Iterable[str], 26 | ) -> str: 27 | """Returns a proper available language code based on a list of available 28 | language codes, and a request.""" 29 | 30 | if not isinstance(avail, set) or not isinstance(avail, frozenset): 31 | avail = set(avail) 32 | 33 | # return the only one choice if this is the case 34 | if len(avail) == 1: 35 | return next(iter(avail)) 36 | 37 | # try exact match 38 | if req in avail: 39 | return req 40 | 41 | return _match_lang_code_slowpath( 42 | lang_code_to_lang_region(req, True), 43 | [lang_code_to_lang_region(x, False) for x in avail], 44 | ) 45 | 46 | 47 | def _match_lang_code_slowpath( 48 | req: LangAndRegion, 49 | avail: list[LangAndRegion], 50 | ) -> str: 51 | # pick one with the requested region 52 | if req.region is not None: 53 | for x in avail: 54 | if x.region == req.region: 55 | return x.raw 56 | 57 | # if no match, pick one with the requested language 58 | for x in avail: 59 | if x.lang == req.lang: 60 | return x.raw 61 | 62 | # neither matches, fallback to (en_US, en, en_*, zh_CN, zh, zh_*) 63 | # in that order 64 | fallback_en = {x.region: x.raw for x in avail if x.lang == "en"} 65 | if fallback_en: 66 | if "US" in fallback_en: 67 | return fallback_en["US"] 68 | if None in fallback_en: 69 | return fallback_en[None] 70 | return fallback_en[sorted(x for x in fallback_en.keys() if x is not None)[0]] 71 | 72 | fallback_zh = {x.region: x.raw for x in avail if x.lang == "zh"} 73 | if fallback_zh: 74 | if "CN" in fallback_zh: 75 | return fallback_zh["CN"] 76 | if None in fallback_zh: 77 | return fallback_zh[None] 78 | return fallback_zh[sorted(x for x in fallback_zh.keys() if x is not None)[0]] 79 | 80 | # neither en nor zh is available (which is highly unlikely at present) 81 | # pick the first available one as a last resort 82 | # sort the list before picking for determinism 83 | return sorted(x.raw for x in avail)[0] 84 | -------------------------------------------------------------------------------- /docs/naming-of-devices-and-images.md: -------------------------------------------------------------------------------- 1 | # 设备、系统镜像的命名约定 2 | 3 | 为便于自动化集成、管理 RuyiSDK 所支持的设备型号与系统镜像,也便于用户、开发者理解、接受,有必要为设备与系统镜像在 4 | RuyiSDK 体系内的命名作出一些约定。 5 | 6 | 以下是目前在用的约定,随着事情发展,可能会有调整。 7 | 8 | ## 设备型号 ID 9 | 10 | 设备型号 ID (device ID) 应当符合 `$vendor-$model` 的形式,其中 `$vendor` 是供应商 ID,`$model` 是型号 ID。 11 | 12 | 例: 13 | 14 | * `sipeed-lcon4a`: Sipeed Lichee Console 4A 15 | * `sipeed-tangmega138kpro`: Sipeed Tang Mega 138K Pro 16 | * `starfive-visionfive`: StarFive VisionFive 17 | * `wch-ch32v203-evb`: WCH CH32V203 EVB 18 | 19 | ### 供应商 ID 20 | 21 | 供应商 ID 一般取相应供应商的英文商标名的全小写形式;如果供应商全名显得太长,也可取其知名缩写的全小写形式。 22 | 23 | 已知(已在使用)的供应商 ID 如下: 24 | 25 | | 供应商 ID | 供应商名称 | 26 | |-----------|------------| 27 | | `awol` | Allwinner | 28 | | `canaan` | Canaan | 29 | | `milkv` | Milk-V | 30 | | `pine64` | Pine64 | 31 | | `sifive` | SiFive | 32 | | `sipeed` | Sipeed | 33 | | `spacemit` | SpacemiT | 34 | | `starfive` | StarFive | 35 | | `wch` | WinChipHead | 36 | 37 | 如后续有增加适配其他未在列表中的供应商,请同步更新此文档。 38 | 39 | ### 型号 ID 40 | 41 | 型号 ID 的具体形式目前没有特别的约定,但一般遵循以下规则: 42 | 43 | * 如在厂商文档、示例代码、SDK 等公开资料存在较为一致的 codename 称呼,则使用 codename 的全小写形式。例如: 44 | * Duo S = `duos` 45 | * Kendryte K230 = `k230` 46 | * LicheePi 4A = `lpi4a` 47 | * Meles = `meles` 48 | * Pioneer Box = `pioneer` 49 | * 如相应厂商没有对某型板卡使用完善、一致的 codename,但在自然语言中,该板卡一般被称作“芯片型号 50 | (chip model) + 产品形态 (form factor)”的形式,则使用 `$chip_model-$form_factor` 51 | 的全小写形式。例如: 52 | * CH32V203 EVB = `ch32v203-evb` 53 | * 如果上述两条都不能很好满足,则使用产品市场名称的全小写形式。例如: 54 | * Tang Mega 138K Pro = `tangmega138kpro` 55 | 56 | ## 型号变体 ID 57 | 58 | 有些不同的板卡 SKU 型号之间存在相当的相似度,一般是源自某些产品属性维度的排列组合。在 59 | RuyiSDK 设备安装器中,我们不在“设备”一级区分这些 SKU,而是将相关联的 SKU 全部视作某个型号的“变体” (variant),以便降低用户的信息处理负担。 60 | 61 | 有些时候,虽然某个型号有多种变体,但从软件视角看来它们完全兼容,此时出于维护成本考虑,也可以不单独定义变体。但对于软件上不能做到完全兼容的多种变体,为了成功支持它们,就必须定义清楚。 62 | 63 | 由于 SKU 的制定方式众多,我们对于变体 ID 的具体写法除了应为全小写形式之外,不作明确的风格要求,但一般以简短为好。自动化处理相关数据的组件可能需要支持一定程度的模板字符串渲染等功能。 64 | 65 | 例如: 66 | 67 | * `sipeed-lpi4a` 有 8G RAM 与 16G RAM 两种配置,部分软件不能通用,必须区分。设置两种变体: 68 | * `8g`: `Sipeed LicheePi 4A (8G RAM)` 69 | * `16g`: `Sipeed LicheePi 4A (16G RAM)` 70 | * `wch-ch32v203-evb` 有 11 种配置,对应 CH32V203 的 11 种各项指标各异的 SKU。设置 11 种变体: 71 | * `c6t6`: `WCH CH32V203 EVB (CH32V203C6T6)` 72 | * `c8t6`: `WCH CH32V203 EVB (CH32V203C8T6)` 73 | * `c8u6`: `WCH CH32V203 EVB (CH32V203C8U6)` 74 | * `f6p6`: `WCH CH32V203 EVB (CH32V203F6P6)` 75 | * etc. 76 | * 多数型号没有明确的变体,对此均设置单一 `generic` 变体,称呼为 `generic variant`。以 `sipeed-maix1` 为例: 77 | * `generic`: `Sipeed Maix-I (generic variant)` 78 | 79 | ## 系统镜像包名 80 | 81 | 应为 `board-image/$os-$device_id` 或 `board-image/$os-$device_id-$variant` (当 82 | variant 不为 `generic` 且区分 variant 很重要时) 的形式。 83 | 84 | 例如: 85 | 86 | * `board-image/revyos-milkv-meles`: 虽然 `milkv-meles` 有 `4g` 与 `8g` 两种变体,但 87 | RevyOS 对此无感,故不在命名上体现变体。 88 | * `board-image/uboot-revyos-milkv-meles-4g`: 由于 U-Boot 对板载 RAM 89 | 容量敏感,故需要在名称上区分不同变体。 90 | -------------------------------------------------------------------------------- /tests/ruyipkg/test_entity_providers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping, Sequence 2 | 3 | from ruyi.log import RuyiLogger 4 | from ruyi.ruyipkg.entity_provider import BaseEntityProvider, FSEntityProvider 5 | from ruyi.ruyipkg.entity import EntityStore 6 | 7 | from ..fixtures import RuyiFileFixtureFactory 8 | 9 | 10 | class MockEntityProvider(BaseEntityProvider): 11 | """A mock entity provider for testing.""" 12 | 13 | def discover_schemas(self) -> dict[str, object]: 14 | """Return a mock schema.""" 15 | return { 16 | "os": { 17 | "$schema": "http://json-schema.org/draft-07/schema#", 18 | "type": "object", 19 | "properties": { 20 | "os": { 21 | "type": "object", 22 | "properties": { 23 | "display_name": {"type": "string"}, 24 | "version": {"type": "string"}, 25 | }, 26 | "required": ["display_name"], 27 | } 28 | }, 29 | } 30 | } 31 | 32 | def load_entities( 33 | self, 34 | entity_types: Sequence[str], 35 | ) -> Mapping[str, Mapping[str, Mapping[str, Any]]]: 36 | """Return mock entity data if 'os' is in entity_types.""" 37 | if "os" not in entity_types: 38 | return {} 39 | 40 | return { 41 | "os": { 42 | "linux": {"os": {"display_name": "Linux", "version": "6.6.0"}}, 43 | "freebsd": {"os": {"display_name": "FreeBSD", "version": "14.0"}}, 44 | } 45 | } 46 | 47 | 48 | def test_entity_store_with_custom_provider( 49 | ruyi_file: RuyiFileFixtureFactory, 50 | ruyi_logger: RuyiLogger, 51 | ) -> None: 52 | """Test using EntityStore with a custom provider.""" 53 | 54 | with ruyi_file.path("ruyipkg_suites", "entities_v0_smoke") as entities_path: 55 | # Create store with both filesystem and mock providers 56 | fs_provider = FSEntityProvider(ruyi_logger, entities_path) 57 | mock_provider = MockEntityProvider() 58 | store = EntityStore(ruyi_logger, fs_provider, mock_provider) 59 | 60 | # Verify entity types from both providers are available 61 | entity_types = set(store.get_entity_types()) 62 | assert "cpu" in entity_types # from filesystem 63 | assert "os" in entity_types # from mock provider 64 | 65 | # Verify we can get entities from both providers 66 | cpu = store.get_entity("cpu", "xiangshan-nanhu") 67 | assert cpu is not None 68 | assert cpu.entity_type == "cpu" 69 | 70 | os = store.get_entity("os", "linux") 71 | assert os is not None 72 | assert os.entity_type == "os" 73 | assert os.display_name == "Linux" 74 | assert os.data.get("version") == "6.6.0" 75 | -------------------------------------------------------------------------------- /ruyi/config/errors.py: -------------------------------------------------------------------------------- 1 | from os import PathLike 2 | from typing import Any, Sequence 3 | 4 | 5 | class InvalidConfigSectionError(Exception): 6 | def __init__(self, section: str) -> None: 7 | super().__init__() 8 | self._section = section 9 | 10 | def __str__(self) -> str: 11 | return f"invalid config section: {self._section}" 12 | 13 | def __repr__(self) -> str: 14 | return f"InvalidConfigSectionError({self._section!r})" 15 | 16 | 17 | class InvalidConfigKeyError(Exception): 18 | def __init__(self, key: str | Sequence[str]) -> None: 19 | super().__init__() 20 | self._key = key 21 | 22 | def __str__(self) -> str: 23 | return f"invalid config key: {self._key}" 24 | 25 | def __repr__(self) -> str: 26 | return f"InvalidConfigKeyError({self._key:!r})" 27 | 28 | 29 | class InvalidConfigValueTypeError(TypeError): 30 | def __init__( 31 | self, 32 | key: str | Sequence[str], 33 | val: object | None, 34 | expected: type | Sequence[type], 35 | ) -> None: 36 | super().__init__() 37 | self._key = key 38 | self._val = val 39 | self._expected = expected 40 | 41 | def __str__(self) -> str: 42 | return f"invalid value type for config key {self._key}: {type(self._val)}, expected {self._expected}" 43 | 44 | def __repr__(self) -> str: 45 | return f"InvalidConfigValueTypeError({self._key!r}, {self._val!r}, {self._expected:!r})" 46 | 47 | 48 | class InvalidConfigValueError(ValueError): 49 | def __init__( 50 | self, 51 | key: str | Sequence[str] | None, 52 | val: object | None, 53 | typ: type | Sequence[type], 54 | ) -> None: 55 | super().__init__() 56 | self._key = key 57 | self._val = val 58 | self._typ = typ 59 | 60 | def __str__(self) -> str: 61 | return ( 62 | f"invalid config value for key {self._key} (type {self._typ}): {self._val}" 63 | ) 64 | 65 | def __repr__(self) -> str: 66 | return ( 67 | f"InvalidConfigValueError({self._key:!r}, {self._val:!r}, {self._typ:!r})" 68 | ) 69 | 70 | 71 | class MalformedConfigFileError(Exception): 72 | def __init__(self, path: PathLike[Any]) -> None: 73 | super().__init__() 74 | self._path = path 75 | 76 | def __str__(self) -> str: 77 | return f"malformed config file: {self._path}" 78 | 79 | def __repr__(self) -> str: 80 | return f"MalformedConfigFileError({self._path:!r})" 81 | 82 | 83 | class ProtectedGlobalConfigError(Exception): 84 | def __init__(self, key: str | Sequence[str]) -> None: 85 | super().__init__() 86 | self._key = key 87 | 88 | def __str__(self) -> str: 89 | return f"attempt to modify protected global config key: {self._key}" 90 | 91 | def __repr__(self) -> str: 92 | return f"ProtectedGlobalConfigError({self._key!r})" 93 | -------------------------------------------------------------------------------- /ruyi/utils/markdown.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console, ConsoleOptions, RenderResult 2 | from rich.markdown import CodeBlock, Heading, Markdown, MarkdownContext 3 | from rich.syntax import Syntax 4 | from rich.text import Text 5 | 6 | 7 | class SlimHeading(Heading): 8 | def on_enter(self, context: MarkdownContext) -> None: 9 | try: 10 | # the heading level is indicated in the tag name in rich >= 13.2.0, 11 | # e.g. self.tag == 'h1', but directly stored in earlier versions 12 | # as self.level. 13 | # 14 | # see https://github.com/Textualize/rich/commit/a20c3d5468d02a55 15 | heading_level = int(self.tag[1:]) # type: ignore[attr-defined,unused-ignore] 16 | except AttributeError: 17 | heading_level = self.level # type: ignore[attr-defined,unused-ignore] 18 | 19 | context.enter_style(self.style_name) 20 | self.text = Text("#" * heading_level + " ", context.current_style) 21 | 22 | def __rich_console__( 23 | self, 24 | console: Console, 25 | options: ConsoleOptions, 26 | ) -> RenderResult: 27 | yield self.text 28 | 29 | 30 | # inspired by https://github.com/Textualize/rich/issues/3154 31 | class NonWrappingCodeBlock(CodeBlock): 32 | def __rich_console__( 33 | self, 34 | console: Console, 35 | options: ConsoleOptions, 36 | ) -> RenderResult: 37 | # re-enable non-wrapping options locally for code blocks 38 | render_options = options.update(no_wrap=True, overflow="ignore") 39 | 40 | code = str(self.text).rstrip() 41 | syntax = Syntax( 42 | code, 43 | self.lexer_name, 44 | theme=self.theme, 45 | word_wrap=False, 46 | # not supported in rich <= 12.4.0 (Textualize/rich#2247) but fortunately 47 | # zero padding is the default anyway 48 | # padding=0, 49 | ) 50 | return syntax.highlight(code).__rich_console__(console, render_options) 51 | 52 | 53 | class RuyiStyledMarkdown(Markdown): 54 | elements = Markdown.elements 55 | elements["fence"] = NonWrappingCodeBlock 56 | elements["heading_open"] = SlimHeading 57 | 58 | # rich < 13.2.0 59 | # see https://github.com/Textualize/rich/commit/745bd99e416c2806 60 | # it doesn't hurt to just unconditionally add them like below 61 | elements["code"] = NonWrappingCodeBlock 62 | elements["code_block"] = NonWrappingCodeBlock 63 | elements["heading"] = SlimHeading 64 | 65 | def __rich_console__( 66 | self, 67 | console: Console, 68 | options: ConsoleOptions, 69 | ) -> RenderResult: 70 | # we have to undo the ruyi-global console's non-wrapping setting 71 | # for proper CLI rendering of long lines 72 | render_options = options.update(no_wrap=False, overflow="fold") 73 | return super().__rich_console__(console, render_options) 74 | -------------------------------------------------------------------------------- /scripts/lint-cli-startup-flow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | # import these common stdlib modules beforehand, to help reduce clutter 7 | # these must be modules that does not significantly affect the ruyi CLI's 8 | # startup performance 9 | STDLIBS_TO_PRELOAD = [ 10 | "argparse", 11 | "bz2", 12 | "datetime", 13 | "functools", 14 | "itertools", 15 | "lzma", 16 | "pathlib", 17 | "platform", 18 | "shutil", 19 | "typing", 20 | "os", 21 | "zlib", 22 | ] 23 | 24 | if sys.version_info >= (3, 14): 25 | STDLIBS_TO_PRELOAD.append("annotationlib") 26 | 27 | CURRENT_ALLOWLIST = { 28 | "ruyi", 29 | "ruyi.cli", 30 | "ruyi.cli.builtin_commands", 31 | "ruyi.cli.cmd", 32 | "ruyi.cli.completion", 33 | "ruyi.cli.config_cli", 34 | "ruyi.cli.self_cli", 35 | "ruyi.cli.version_cli", 36 | "ruyi.device", 37 | "ruyi.device.provision_cli", 38 | "ruyi.mux", 39 | "ruyi.mux.venv", 40 | "ruyi.mux.venv.venv_cli", 41 | "ruyi.pluginhost", 42 | "ruyi.pluginhost.plugin_cli", 43 | "ruyi.ruyipkg", 44 | "ruyi.ruyipkg.admin_cli", 45 | "ruyi.ruyipkg.cli_completion", # part of the argparse machinery 46 | "ruyi.ruyipkg.entity_cli", 47 | "ruyi.ruyipkg.host", # light-weight enough 48 | "ruyi.ruyipkg.install_cli", 49 | "ruyi.ruyipkg.list_cli", 50 | "ruyi.ruyipkg.list_filter", # part of the argparse machinery 51 | "ruyi.ruyipkg.news_cli", 52 | "ruyi.ruyipkg.profile_cli", 53 | "ruyi.ruyipkg.update_cli", 54 | "ruyi.telemetry", 55 | "ruyi.telemetry.telemetry_cli", 56 | "ruyi.utils", 57 | "ruyi.utils.global_mode", # light-weight enough 58 | } 59 | 60 | 61 | def main() -> int: 62 | for lib in STDLIBS_TO_PRELOAD: 63 | __import__(lib) 64 | 65 | before = set(sys.modules.keys()) 66 | a = time.monotonic_ns() 67 | 68 | from ruyi.cli import builtin_commands 69 | 70 | b = time.monotonic_ns() 71 | print(f"Import of built-in commands took {((b - a) / 1_000_000):.2f} ms.") 72 | del builtin_commands 73 | 74 | after = set(sys.modules.keys()) 75 | modules_brought_in = after - before 76 | unwanted_modules = modules_brought_in - CURRENT_ALLOWLIST 77 | if not unwanted_modules: 78 | return 0 79 | 80 | print( 81 | """\ 82 | Some previously unneeded modules are now imported during built-in commands 83 | initialization: 84 | """ 85 | ) 86 | for module in sorted(unwanted_modules): 87 | print(f" - {module}") 88 | print( 89 | """ 90 | Please assess the impact on CLI startup performance before: 91 | 92 | - allowing the module(s) by revising this script, or 93 | - deferring the import(s) so they do not slow down CLI startup. 94 | """ 95 | ) 96 | return 1 97 | 98 | 99 | if __name__ == "__main__": 100 | sys.exit(main()) 101 | -------------------------------------------------------------------------------- /scripts/lint-version-metadata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import ast 4 | import os 5 | import sys 6 | 7 | if sys.version_info >= (3, 11): 8 | import tomllib 9 | else: 10 | import tomli as tomllib 11 | 12 | 13 | def main() -> None: 14 | # assume CWD is project root, which is guaranteed to be the case (see 15 | # end of file) 16 | with open("pyproject.toml", "rb") as fp: 17 | poetry2_project = tomllib.load(fp) 18 | poetry2_version = poetry2_project["project"]["version"] 19 | 20 | with open("contrib/poetry-1.x/pyproject.toml", "rb") as fp: 21 | poetry1_project = tomllib.load(fp) 22 | poetry1_version = poetry1_project["tool"]["poetry"]["version"] 23 | 24 | if poetry1_version != poetry2_version: 25 | print("fatal error: Poetry 1.x metadata inconsistent with primary data source") 26 | print(f"info: primary pyproject.toml has project.version = '{poetry2_version}'") 27 | print(f"info: Poetry 1.x has tool.poetry.version = '{poetry1_version}'") 28 | sys.exit(1) 29 | 30 | print("info: project version consistent between primary and Poetry 1.x metadata") 31 | 32 | ret = lint_ruyi_version_str("ruyi/version.py", poetry2_version) 33 | if ret: 34 | print( 35 | "info: hint, if you want to refactor RUYI_SEMVER, you need to make changes to this lint too" 36 | ) 37 | sys.exit(ret) 38 | 39 | 40 | def lint_ruyi_version_str(filename: str, expected_ver: str) -> int: 41 | with open(filename, "rb") as fp: 42 | contents = fp.read() 43 | 44 | module = ast.parse(contents, filename) 45 | found_ver: str | None = None 46 | for stmt in module.body: 47 | if not isinstance(stmt, ast.AnnAssign): 48 | continue 49 | if not isinstance(stmt.target, ast.Name): 50 | continue 51 | if stmt.target.id == "RUYI_SEMVER": 52 | if not isinstance(stmt.value, ast.Constant): 53 | print("fatal error: RUYI_SEMVER not a constant") 54 | return 1 55 | if not isinstance(stmt.value.value, str): 56 | print("fatal error: RUYI_SEMVER not a string") 57 | return 1 58 | found_ver = stmt.value.value 59 | 60 | if found_ver is None: 61 | print("fatal error: RUYI_SEMVER annotation assignment not found") 62 | return 1 63 | 64 | if found_ver != expected_ver: 65 | print( 66 | "fatal error: ruyi.version.RUYI_SEMVER inconsistent with primary data source" 67 | ) 68 | print(f"info: primary pyproject.toml has project.version = '{expected_ver}'") 69 | print(f"info: ruyi.version.RUYI_SEMVER = '{found_ver}'") 70 | return 1 71 | 72 | print("info: ruyi.version.RUYI_SEMVER consistent with primary metadata") 73 | return 0 74 | 75 | 76 | if __name__ == "__main__": 77 | # cd to project root 78 | os.chdir(os.path.join(os.path.dirname(__file__), "..")) 79 | main() 80 | -------------------------------------------------------------------------------- /tests/config/test_schema.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from ruyi.config.errors import InvalidConfigValueError 6 | from ruyi.config.schema import decode_value, encode_value, _decode_single_type_value 7 | from ruyi.utils.toml import NoneValue 8 | 9 | 10 | def test_decode_value_bool() -> None: 11 | assert decode_value("installation.externally_managed", "true") is True 12 | 13 | assert _decode_single_type_value(None, "true", bool) is True 14 | assert _decode_single_type_value(None, "false", bool) is False 15 | assert _decode_single_type_value(None, "yes", bool) is True 16 | assert _decode_single_type_value(None, "no", bool) is False 17 | assert _decode_single_type_value(None, "1", bool) is True 18 | assert _decode_single_type_value(None, "0", bool) is False 19 | with pytest.raises(InvalidConfigValueError): 20 | _decode_single_type_value(None, "invalid", bool) 21 | with pytest.raises(InvalidConfigValueError): 22 | _decode_single_type_value(None, "x", bool) 23 | with pytest.raises(InvalidConfigValueError): 24 | _decode_single_type_value(None, "True", bool) 25 | 26 | 27 | def test_decode_value_str() -> None: 28 | assert decode_value("repo.branch", "main") == "main" 29 | assert _decode_single_type_value(None, "main", str) == "main" 30 | 31 | 32 | def test_decode_value_datetime() -> None: 33 | tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) 34 | assert ( 35 | decode_value("telemetry.upload_consent", "2024-12-01T12:00:00Z") == tz_aware_dt 36 | ) 37 | assert ( 38 | _decode_single_type_value(None, "2024-12-01T12:00:00Z", datetime.datetime) 39 | == tz_aware_dt 40 | ) 41 | assert ( 42 | _decode_single_type_value(None, "2024-12-01T12:00:00+00:00", datetime.datetime) 43 | == tz_aware_dt 44 | ) 45 | 46 | # naive datetimes are decoded using the implicit local timezone 47 | _decode_single_type_value(None, "2024-12-01T12:00:00", datetime.datetime) 48 | 49 | 50 | def test_encode_value_none() -> None: 51 | with pytest.raises(NoneValue): 52 | encode_value(None) 53 | 54 | 55 | def test_encode_value_bool() -> None: 56 | assert encode_value(True) == "true" 57 | assert encode_value(False) == "false" 58 | 59 | 60 | def test_encode_value_int() -> None: 61 | assert encode_value(123) == "123" 62 | 63 | 64 | def test_encode_value_str() -> None: 65 | assert encode_value("") == "" 66 | assert encode_value("main") == "main" 67 | 68 | 69 | def test_encode_value_datetime() -> None: 70 | tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) 71 | assert encode_value(tz_aware_dt) == "2024-12-01T12:00:00Z" 72 | 73 | # specifically check that naive datetimes are rejected 74 | tz_naive_dt = datetime.datetime(2024, 12, 1, 12, 0, 0) 75 | with pytest.raises( 76 | ValueError, match="only timezone-aware datetimes are supported for safety" 77 | ): 78 | encode_value(tz_naive_dt) 79 | -------------------------------------------------------------------------------- /scripts/organize-release-artifacts.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import pathlib 5 | import shutil 6 | import sys 7 | 8 | if sys.version_info >= (3, 11): 9 | import tomllib 10 | else: 11 | import tomli as tomllib 12 | 13 | 14 | def main(argv: list[str]) -> int: 15 | if len(argv) != 2: 16 | print(f"usage: {argv[0]} ", file=sys.stderr) 17 | return 1 18 | 19 | workdir = pathlib.Path(argv[1]).resolve() 20 | 21 | project_root = (pathlib.Path(os.path.dirname(__file__)) / "..").resolve() 22 | with open(project_root / "pyproject.toml", "rb") as fp: 23 | pyproject = tomllib.load(fp) 24 | 25 | try: 26 | version = pyproject["project"]["version"] 27 | except KeyError: 28 | # In case the packaging environment has Poetry 1.x metadata switched 29 | # in 30 | version = pyproject["tool"]["poetry"]["version"] 31 | 32 | # layout of release-artifacts-dir just after the download-artifacts@v4 33 | # action: 34 | # 35 | # release-artifacts-dir 36 | # ├── ruyi-XXXXXXXX.tar.gz 37 | # │   └── ruyi-XXXXXXXX.tar.gz 38 | # ├── ruyi.amd64 39 | # │   └── ruyi 40 | # ├── ruyi.arm64 41 | # │   └── ruyi 42 | # ├── ruyi.riscv64 43 | # │   └── ruyi 44 | # └── ruyi.windows-amd64.exe 45 | # └── ruyi.exe 46 | # 47 | # we want to organize it into the following layout: 48 | # 49 | # release-artifacts-dir 50 | # ├── ruyi-XXXXXXXX.tar.gz 51 | # ├── ruyi-.amd64 52 | # ├── ruyi-.arm64 53 | # └── ruyi-.riscv64 54 | # 55 | # i.e. with the non-Linux build removed, with the directory structure 56 | # flattened, and with the semver attached. 57 | 58 | os.chdir(workdir) 59 | 60 | # for now, hardcode the exact artifacts we want 61 | included_arches = ("amd64", "arm64", "riscv64") 62 | wanted_names = {f"ruyi.{arch}" for arch in included_arches} 63 | names = os.listdir(".") 64 | for name in names: 65 | if name.endswith(".tar.gz"): 66 | src_path = os.path.join(name, name) 67 | tmp_path = f"{name}.new" 68 | print(f"moving tarball {src_path} outside") 69 | os.rename(src_path, tmp_path) 70 | os.rmdir(name) 71 | os.rename(tmp_path, name) 72 | continue 73 | 74 | if not name.startswith("ruyi"): 75 | print(f"ignoring {name}") 76 | continue 77 | 78 | if name not in wanted_names: 79 | print(f"removing unwanted {name}") 80 | shutil.rmtree(name) 81 | continue 82 | 83 | # assume name is ruyi.{arch} 84 | arch = name.rsplit(".", 1)[1] 85 | src_name = os.path.join(name, "ruyi") 86 | dest_name = f"ruyi-{version}.{arch}" 87 | print(f"moving {src_name} to {dest_name}") 88 | os.rename(src_name, dest_name) 89 | os.chmod(dest_name, 0o755) 90 | os.rmdir(name) 91 | 92 | return 0 93 | 94 | 95 | if __name__ == "__main__": 96 | sys.exit(main(sys.argv)) 97 | -------------------------------------------------------------------------------- /tests/config/test_editor.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | if sys.version_info >= (3, 11): 5 | import tomllib 6 | else: 7 | import tomli as tomllib 8 | 9 | import pytest 10 | 11 | from ruyi.config.editor import ConfigEditor 12 | from ruyi.config.errors import ( 13 | InvalidConfigKeyError, 14 | InvalidConfigSectionError, 15 | InvalidConfigValueTypeError, 16 | MalformedConfigFileError, 17 | ProtectedGlobalConfigError, 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def temp_config_file(tmp_path: pathlib.Path) -> pathlib.Path: 23 | return tmp_path / "config.toml" 24 | 25 | 26 | def test_enter_exit(temp_config_file: pathlib.Path) -> None: 27 | editor = ConfigEditor(temp_config_file) 28 | with editor as e: 29 | assert e is editor 30 | e.set_value("telemetry.mode", "off") 31 | # no stage() so no file writing 32 | assert not temp_config_file.exists() 33 | 34 | 35 | def test_set_value(temp_config_file: pathlib.Path) -> None: 36 | with ConfigEditor(temp_config_file) as e: 37 | with pytest.raises(InvalidConfigKeyError): 38 | e.set_value("invalid_key", "value") 39 | 40 | with pytest.raises(InvalidConfigValueTypeError): 41 | e.set_value("telemetry.mode", True) 42 | 43 | with pytest.raises(InvalidConfigValueTypeError): 44 | e.set_value("telemetry.mode", 1) 45 | 46 | with pytest.raises(ProtectedGlobalConfigError): 47 | e.set_value("installation.externally_managed", True) 48 | 49 | e.set_value("telemetry.mode", "off") 50 | e.stage() 51 | 52 | with open(temp_config_file, "rb") as fp: 53 | content = tomllib.load(fp) 54 | assert "installation" not in content 55 | assert content["telemetry"]["mode"] == "off" 56 | 57 | 58 | def test_unset_value_remove_section(temp_config_file: pathlib.Path) -> None: 59 | with ConfigEditor(temp_config_file) as e: 60 | e.set_value("telemetry.mode", "off") 61 | e.set_value("repo.remote", "http://test.example.com") 62 | e.stage() 63 | 64 | with open(temp_config_file, "rb") as fp: 65 | content = tomllib.load(fp) 66 | assert content["repo"]["remote"] == "http://test.example.com" 67 | assert content["telemetry"]["mode"] == "off" 68 | 69 | with ConfigEditor(temp_config_file) as e: 70 | e.unset_value("telemetry.mode") 71 | e.remove_section("repo") 72 | e.stage() 73 | 74 | with pytest.raises(InvalidConfigSectionError): 75 | e.remove_section("foo") 76 | 77 | with open(temp_config_file, "rb") as fp: 78 | content = tomllib.load(fp) 79 | assert "repo" not in content 80 | assert "telemetry" in content 81 | assert "mode" not in content["telemetry"] 82 | 83 | 84 | def test_malformed_config_file_error(temp_config_file: pathlib.Path) -> None: 85 | with open(temp_config_file, "wb") as fp: 86 | fp.write(b"repo = 1\n") 87 | 88 | with pytest.raises(MalformedConfigFileError): 89 | with ConfigEditor(temp_config_file) as e: 90 | e.set_value("repo.branch", "foo") 91 | -------------------------------------------------------------------------------- /ruyi/utils/xdg_basedir.py: -------------------------------------------------------------------------------- 1 | # Re-implementation of necessary XDG Base Directory Specification semantics 2 | # without pyxdg, which is under LGPL and not updated for the latest spec 3 | # revision (0.6 vs 0.8 released in 2021). 4 | 5 | import os 6 | import pathlib 7 | from typing import Iterable, NamedTuple 8 | 9 | 10 | class XDGPathEntry(NamedTuple): 11 | path: pathlib.Path 12 | is_global: bool 13 | 14 | 15 | def _paths_from_env(env: str, default: str) -> Iterable[pathlib.Path]: 16 | v = os.environ.get(env, default) 17 | for p in v.split(":"): 18 | yield pathlib.Path(p) 19 | 20 | 21 | class XDGBaseDir: 22 | def __init__(self, app_name: str) -> None: 23 | self.app_name = app_name 24 | 25 | @property 26 | def cache_home(self) -> pathlib.Path: 27 | v = os.environ.get("XDG_CACHE_HOME", "") 28 | return pathlib.Path(v) if v else pathlib.Path.home() / ".cache" 29 | 30 | @property 31 | def config_home(self) -> pathlib.Path: 32 | v = os.environ.get("XDG_CONFIG_HOME", "") 33 | return pathlib.Path(v) if v else pathlib.Path.home() / ".config" 34 | 35 | @property 36 | def data_home(self) -> pathlib.Path: 37 | v = os.environ.get("XDG_DATA_HOME", "") 38 | return pathlib.Path(v) if v else pathlib.Path.home() / ".local" / "share" 39 | 40 | @property 41 | def state_home(self) -> pathlib.Path: 42 | v = os.environ.get("XDG_STATE_HOME", "") 43 | return pathlib.Path(v) if v else pathlib.Path.home() / ".local" / "state" 44 | 45 | @property 46 | def config_dirs(self) -> Iterable[XDGPathEntry]: 47 | # from highest precedence to lowest 48 | for p in _paths_from_env("XDG_CONFIG_DIRS", "/etc/xdg"): 49 | yield XDGPathEntry(p, True) 50 | 51 | @property 52 | def data_dirs(self) -> Iterable[XDGPathEntry]: 53 | # from highest precedence to lowest 54 | for p in _paths_from_env("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/"): 55 | yield XDGPathEntry(p, True) 56 | 57 | # derived info 58 | 59 | @property 60 | def app_cache(self) -> pathlib.Path: 61 | return self.cache_home / self.app_name 62 | 63 | @property 64 | def app_config(self) -> pathlib.Path: 65 | return self.config_home / self.app_name 66 | 67 | @property 68 | def app_data(self) -> pathlib.Path: 69 | return self.data_home / self.app_name 70 | 71 | @property 72 | def app_state(self) -> pathlib.Path: 73 | return self.state_home / self.app_name 74 | 75 | @property 76 | def app_config_dirs(self) -> Iterable[XDGPathEntry]: 77 | # from highest precedence to lowest 78 | yield XDGPathEntry(self.app_config, False) 79 | for e in self.config_dirs: 80 | yield XDGPathEntry(e.path / self.app_name, e.is_global) 81 | 82 | @property 83 | def app_data_dirs(self) -> Iterable[XDGPathEntry]: 84 | # from highest precedence to lowest 85 | yield XDGPathEntry(self.app_data, False) 86 | for e in self.data_dirs: 87 | yield XDGPathEntry(e.path / self.app_name, e.is_global) 88 | -------------------------------------------------------------------------------- /scripts/make-release-tag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import pathlib 5 | import subprocess 6 | import sys 7 | from typing import NoReturn 8 | 9 | from pygit2 import Repository 10 | from tomlkit_extras import TOMLDocumentDescriptor, load_toml_file 11 | 12 | 13 | try: 14 | from semver.version import Version # type: ignore[import-untyped,unused-ignore] 15 | except ModuleNotFoundError: 16 | from semver import VersionInfo as Version # type: ignore[import-untyped,unused-ignore] 17 | 18 | 19 | def render_tag_message(version: str) -> str: 20 | return f"Ruyi {version}" 21 | 22 | 23 | def fatal(msg: str) -> NoReturn: 24 | print(f"fatal: {msg}", file=sys.stderr) 25 | sys.exit(1) 26 | 27 | 28 | def main() -> None: 29 | # assume CWD is project root, which is guaranteed to be the case (see 30 | # end of file) 31 | pyproject = load_toml_file(pathlib.Path("pyproject.toml")) 32 | pyproject_desc = TOMLDocumentDescriptor(pyproject) 33 | 34 | project_table = pyproject_desc.get_table("project") 35 | version_field = project_table.fields["version"] 36 | lineno = version_field.line_no 37 | version = version_field.value 38 | if not isinstance(version, str): 39 | fatal(f"expected project.version to be a string, got {type(version)}") 40 | 41 | # Check if the version is a valid semver version 42 | try: 43 | Version.parse(version) 44 | except ValueError as e: 45 | fatal(f"invalid semver {version} in pyproject.toml: {e}") 46 | 47 | print(f"info: project version is {version}, defined at pyproject.toml:{lineno}") 48 | 49 | # Check if the tag is already present 50 | repo = Repository(".") 51 | try: 52 | tag_ref = repo.lookup_reference(f"refs/tags/{version}") 53 | except KeyError: 54 | tag_ref = None 55 | 56 | if tag_ref is not None: 57 | print(f"info: tag {version} already exists") 58 | # idempotence: don't fail the workflow with non-zero status code 59 | sys.exit(0) 60 | 61 | # Blame pyproject.toml to find the commit bumping the version 62 | blame = repo.blame("pyproject.toml") 63 | ver_bump_commit_id = blame.for_line(lineno).final_commit_id 64 | print(f"info: the version-bumping commit is {ver_bump_commit_id}") 65 | 66 | ver_bump_commit = repo.get(ver_bump_commit_id) 67 | if ver_bump_commit is None: 68 | fatal(f"could not find version-bumping commit {ver_bump_commit_id}") 69 | 70 | # Create the tag with Git command line to allow for GPG signing 71 | argv = ["git", "tag", "-m", render_tag_message(version)] 72 | 73 | if "RUYI_NO_GPG_SIGN" in os.environ: 74 | argv.extend(["-a", "--no-sign"]) 75 | else: 76 | argv.append("-s") 77 | 78 | argv.extend([version, str(ver_bump_commit_id)]) 79 | 80 | print(f"info: invoking git: {' '.join(argv)}") 81 | subprocess.run(argv, check=True) 82 | 83 | print(f"info: tag {version} created successfully") 84 | sys.exit(0) 85 | 86 | 87 | if __name__ == "__main__": 88 | # cd to project root 89 | os.chdir(os.path.join(os.path.dirname(__file__), "..")) 90 | main() 91 | -------------------------------------------------------------------------------- /docs/programmatic-usage.md: -------------------------------------------------------------------------------- 1 | # 如何程序化地与 `ruyi` 交互 2 | 3 | 在一些场景下,如编辑器或 IDE 的 RuyiSDK 插件,这些外部程序需要与 `ruyi` 进行 non-trivial 4 | 的交互:不仅仅是以一定的命令行参数调用 `ruyi` 并判断其退出状态码,而需要处理一定的输出信息,甚至可能还涉及向 5 | `ruyi` 输入大量信息。我们不希望这些外部程序解析 `ruyi` 面向人类的、不保证格式始终兼容的命令行输出格式,而希望暴露一个对机器友好的、尽量保证稳定、兼容的界面。 6 | 7 | 借鉴了 Git 一些命令所支持的 `--porcelain` 选项,我们为 `ruyi` 也定义了全局选项 8 | `--porcelain`,用来启用这样的输出格式。并非所有的 `ruyi` 子命令都适配了 `--porcelain` 9 | 选项:对于那些暂未适配或没有适配意义的子命令,`ruyi` 除日志输出之外的行为将保持不变。 10 | 11 | 注意:由于 `ruyi` 的 `--porcelain` 选项是全局的,调用者需要将它置于 `argv` 12 | 中的所有子命令之前,否则 `ruyi` 将会报错。 13 | 14 | ```sh 15 | # Correct 16 | ruyi --porcelain news list 17 | 18 | # Wrong 19 | # ruyi: error: unrecognized arguments: --porcelain 20 | ruyi news list --porcelain 21 | ``` 22 | 23 | ## `ruyi` 的 porcelain 输出模式 24 | 25 | 当处于 porcelain 输出模式时,如无特别说明,`ruyi` 的 stdout 与 stderr 输出格式将变为一行一个 26 | JSON 对象。`ruyi` 不保证此 JSON 序列化结果仅包含 ASCII 字符:目前序列化这些对象时,在 Python 一侧采用了 27 | `ensure_ascii=False` 的配置。 28 | 29 | 所有的 porcelain 输出对象都有 `ty` 字段,用来指示此对象的类型。目前已定义的类型有以下几种: 30 | 31 | ```python 32 | # ty: "log-v1" 33 | class PorcelainLog(PorcelainEntity): 34 | t: int 35 | """Timestamp of the message line in microseconds""" 36 | 37 | lvl: str 38 | """Log level of the message line (one of D, F, I, W)""" 39 | 40 | msg: str 41 | """Message content""" 42 | 43 | 44 | # ty: "newsitem-v1" 45 | # see ruyipkg/news.py 46 | class PorcelainNewsItemV1(PorcelainEntity): 47 | id: str 48 | ord: int 49 | is_read: bool 50 | langs: list[PorcelainNewsItemContentV1] 51 | 52 | 53 | # ty: "pkglistoutput-v1" 54 | # see ruyipkg/pkg_cli.py 55 | class PorcelainPkgListOutputV1(PorcelainEntity): 56 | category: str 57 | name: str 58 | vers: list[PorcelainPkgVersionV1] 59 | 60 | 61 | # ty: "entitylistoutput-v1" 62 | # see ruyipkg/entity_provider.py 63 | class PorcelainEntityListOutputV1(PorcelainEntity): 64 | entity_type: str 65 | entity_id: str 66 | display_name: str | None 67 | data: Mapping[str, Any] 68 | related_refs: list[str] 69 | reverse_refs: list[str] 70 | ``` 71 | 72 | 当工作在 porcelain 输出模式时,`ruyi` 平时的 stderr 日志信息格式将变为类型为 `log-v1` 的输出对象。 73 | 每条消息都带时间戳、日志级别,消息正文末尾不会被自动附加 1 个换行(但如果某条日志的末尾碰巧有一个或一些换行,那么这些换行将不会被删除)。 74 | 75 | ## 已适配 porcelain 输出模式的命令 76 | 77 | ### `ruyi list` 78 | 79 | 调用方式: 80 | 81 | ```sh 82 | ruyi --porcelain list 83 | ``` 84 | 85 | 输出格式: 86 | 87 | * stdout:一行一个 `pkglistoutput-v1` 类型的对象 88 | * stderr:无意义 89 | 90 | 请注意:`-v` 选项在 porcelain 输出模式下会被无视。 91 | 92 | ### `ruyi entity list` 93 | 94 | 调用方式: 95 | 96 | ```sh 97 | ruyi --porcelain entity list 98 | # 仅输出特定类型的实体 99 | ruyi --porcelain entity list -t cpu -t device 100 | ``` 101 | 102 | 输出格式: 103 | 104 | * stdout:一行一个 `entitylistoutput-v1` 类型的对象 105 | * stderr:无意义 106 | 107 | ### `ruyi news list` 108 | 109 | 调用方式: 110 | 111 | ```sh 112 | ruyi --porcelain news list 113 | # 如同常规命令行用法,仅请求未读条目也是允许的 114 | ruyi --porcelain news list --new 115 | ``` 116 | 117 | 输出格式: 118 | 119 | * stdout:一行一个 `newsitem-v1` 类型的对象 120 | * stderr:无意义 121 | 122 | 请注意:单纯调用 `ruyi news list` 不会更新文章的已读状态。请另行进行 123 | `ruyi news read -q item...` 的调用以标记用户实际阅读了的文章。 124 | -------------------------------------------------------------------------------- /CONTRIBUTING.zh.md: -------------------------------------------------------------------------------- 1 | # 为 RuyiSDK 做贡献 2 | 3 | 感谢您有兴趣为 RuyiSDK 做贡献!本文档提供了贡献指南,并解释了为本项目做贡献的要求。 4 | 5 | 阅读本文的其它语言版本: 6 | 7 | * [English](./CONTRIBUTING.md) 8 | 9 | ## 行为准则 10 | 11 | 在为 RuyiSDK 做贡献时,请尊重并考虑他人。我们旨在为所有贡献者营造一个开放和友好的环境。 12 | 13 | 请您遵守[《RuyiSDK 社区行为准则》](https://ruyisdk.org/code_of_conduct)。 14 | 15 | ## 开发者原创声明(DCO) 16 | 17 | 我们要求 RuyiSDK 的所有贡献都包含[开发者原创声明(DCO)](https://developercertificate.org/)。DCO 是一种轻量级方式,使贡献者可以证明他们编写或有权提交所贡献的代码。 18 | 19 | ### 什么是 DCO? 20 | 21 | DCO 是您通过签署(sign-off)提交的方式而作出的声明。其全文非常简短,转载如下: 22 | 23 | ```plain 24 | Developer Certificate of Origin 25 | Version 1.1 26 | 27 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 28 | 29 | Everyone is permitted to copy and distribute verbatim copies of this 30 | license document, but changing it is not allowed. 31 | 32 | 33 | Developer's Certificate of Origin 1.1 34 | 35 | By making a contribution to this project, I certify that: 36 | 37 | (a) The contribution was created in whole or in part by me and I 38 | have the right to submit it under the open source license 39 | indicated in the file; or 40 | 41 | (b) The contribution is based upon previous work that, to the best 42 | of my knowledge, is covered under an appropriate open source 43 | license and I have the right under that license to submit that 44 | work with modifications, whether created in whole or in part 45 | by me, under the same open source license (unless I am 46 | permitted to submit under a different license), as indicated 47 | in the file; or 48 | 49 | (c) The contribution was provided directly to me by some other 50 | person who certified (a), (b) or (c) and I have not modified 51 | it. 52 | 53 | (d) I understand and agree that this project and the contribution 54 | are public and that a record of the contribution (including all 55 | personal information I submit with it, including my sign-off) is 56 | maintained indefinitely and may be redistributed consistent with 57 | this project or the open source license(s) involved. 58 | ``` 59 | 60 | ### 如何签署提交 61 | 62 | 您需要在每个提交的说明中添加一行 `Signed-off-by`,证明您同意 DCO: 63 | 64 | ```plain 65 | Signed-off-by: 您的姓名 66 | ``` 67 | 68 | 您可以通过在提交时使用 `-s` 或 `--signoff` 参数自动添加此行: 69 | 70 | ```sh 71 | git commit -s -m "您的提交说明" 72 | ``` 73 | 74 | 确保签名中的姓名和电子邮件与您的 Git 配置匹配。您可以使用以下命令设置您的 Git 姓名和电子邮件: 75 | 76 | ```sh 77 | git config --global user.name "您的姓名" 78 | git config --global user.email "your.email@example.com" 79 | ``` 80 | 81 | ### CI 中的 DCO 验证 82 | 83 | 所有拉取请求(PR)都会在我们的持续集成 (CI) 流程中接受自动化 DCO 检查。此检查会验证您的拉取请求中的所有提交是否都有适当的 84 | DCO 签名。如果任何提交缺少签名,CI 检查将失败,在解决问题之前,您的拉取请求将无法被合并。 85 | 86 | ## 拉取请求流程 87 | 88 | 1. 从 `main` 分支派生(fork)相应的仓库并创建您的分支。 89 | 2. 进行更改,确保它们遵循项目的编码风格和约定。 90 | 3. 必要时添加测试。 91 | 4. 确保您的提交已包含 DCO 签名。 92 | 5. 必要时更新文档。 93 | 6. 向主仓库提交拉取请求。 94 | 95 | ## 开发环境设置 96 | 97 | 有关设置开发环境的信息,请参阅[构建文档](./docs/building.md)。 98 | 99 | ## 报告问题 100 | 101 | 如果您发现错误或有功能请求,请在[工单系统](https://github.com/ruyisdk/ruyi/issues)中创建问题。 102 | 103 | ## 许可证 104 | 105 | 您同意您对 RuyiSDK 的贡献将遵循 [Apache 2.0 许可证](./LICENSE-Apache.txt)。 106 | -------------------------------------------------------------------------------- /ruyi/utils/ci.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | 4 | def is_running_in_ci(os_environ: Mapping[str, str]) -> bool: 5 | """Simplified and quick CI check meant for basic judgement.""" 6 | if os_environ.get("CI", "") == "true": 7 | return True 8 | elif os_environ.get("TF_BUILD", "") == "True": 9 | return True 10 | return False 11 | 12 | 13 | def probe_for_ci(os_environ: Mapping[str, str]) -> str | None: 14 | # https://www.appveyor.com/docs/environment-variables/ 15 | if os_environ.get("APPVEYOR", "").lower() == "true": 16 | return "appveyor" 17 | # https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#system-variables-devops-services 18 | elif os_environ.get("TF_BUILD", "") == "True": 19 | return "azure" 20 | # https://circleci.com/docs/variables/#built-in-environment-variables 21 | elif os_environ.get("CIRCLECI", "") == "true": 22 | return "circleci" 23 | # https://cirrus-ci.org/guide/writing-tasks/#environment-variables 24 | elif os_environ.get("CIRRUS_CI", "") == "true": 25 | return "cirrus" 26 | # https://gitea.com/gitea/act_runner/pulls/113 27 | # this should be checked before GHA because upstream maintains compatibility 28 | # with GHA by also providing GHA-style preset variables 29 | # TODO: also detect Forgejo 30 | elif os_environ.get("GITEA_ACTIONS", "") == "true": 31 | return "gitea" 32 | # https://gitee.com/help/articles/4358#article-header8 33 | elif "GITEE_PIPELINE_NAME" in os_environ: 34 | return "gitee" 35 | # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables 36 | elif os_environ.get("GITHUB_ACTIONS", "") == "true": 37 | return "github" 38 | # https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#predefined-variables 39 | elif os_environ.get("GITLAB_CI", "") == "true": 40 | return "gitlab" 41 | # https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables 42 | # may have false-negatives but likely no false-positives 43 | elif "JENKINS_URL" in os_environ: 44 | return "jenkins" 45 | # https://gitee.com/openeuler/mugen 46 | # seems nothing except $OET_PATH is guaranteed 47 | elif "OET_PATH" in os_environ: 48 | return "mugen" 49 | # there seems to be no designated marker for openQA, test a couple of 50 | # hopefully ubiquitous variables to avoid going through the entire key set 51 | elif "OPENQA_CONFIG" in os_environ or "OPENQA_URL" in os_environ: 52 | return "openqa" 53 | # https://docs.travis-ci.com/user/environment-variables/#default-environment-variables 54 | elif os_environ.get("TRAVIS", "") == "true": 55 | return "travis" 56 | # https://docs.koderover.com/zadig/Zadig%20v3.1/project/build/ 57 | # https://github.com/koderover/zadig/blob/v3.1.0/pkg/microservice/jobexecutor/core/service/job.go#L117 58 | elif os_environ.get("ZADIG", "") == "true": 59 | return "zadig" 60 | elif os_environ.get("CI", "") == "true": 61 | return "unidentified" 62 | 63 | return None 64 | -------------------------------------------------------------------------------- /docs/building.md: -------------------------------------------------------------------------------- 1 | # `ruyi` 的构建方式 2 | 3 | 为了[让构建产物可复现](https://reproducible-builds.org/),`ruyi` 默认使用基于 4 | Docker 的构建方式。但考虑到调试、复杂的发行版打包场景、为非官方支持架构打包等等因素,`ruyi` 5 | 的构建系统也支持以环境变量的形式被调用。 6 | 7 | ## 官方支持架构列表 8 | 9 | 目前 RuyiSDK 官方支持的架构有: 10 | 11 | |`dist.sh` 架构名|`uname -m` 输出| 12 | |----------------|---------------| 13 | |`amd64`|`x86_64`| 14 | |`arm64`|`aarch64`| 15 | |`riscv64`|`riscv64`| 16 | 17 | 在这些架构上,目前 RuyiSDK 官方支持的操作系统是 Linux。 18 | 19 | 如果一个架构与操作系统的组合没有出现在这里,其实也有很大可能 `ruyi` 能够在其上正常工作。事实上,只要 20 | `ruyi` 涉及的少数原生扩展库能够在该系统上被构建、工作,那么 `ruyi` 就可以工作。目前这些库有: 21 | 22 | * [`pygit2`](https://pypi.org/project/pygit2/):涉及 `openssl`、`libssh2`、`libgit2`、`cffi` 23 | 24 | 请注意:因为 RuyiSDK 官方软件源中的软件包目前主要以二进制方式分发,且 25 | RuyiSDK 团队只会为官方支持的架构、操作系统提供二进制包,所以尽管您可以为非官方支持的架构或操作系统构建出 26 | `ruyi`,但这样构建出的 `ruyi` 用途可能十分有限。如果您仍然准备这样做,您需要有**自行维护一套“平行宇宙”软件源**的预期。 27 | 28 | ## Linux 环境下基于 Docker 的构建 29 | 30 | 如果不需要什么特殊定制,`ruyi` 的构建方法十分简单。因为会使用预制的构建容器镜像的缘故,在宿主方面需要做的准备工作很少。您只需要确保: 31 | 32 | * bash 版本大于等于 4.0, 33 | * `docker` 可用, 34 | * GitHub 容器镜像源 `ghcr.io` 可访问, 35 | 36 | 便可在 `ruyi` 仓库根目录下执行: 37 | 38 | ```sh 39 | # 为当前(宿主)架构构建 ruyi 40 | # 仅保证在官方支持架构上正常工作 41 | ./scripts/dist.sh 42 | 43 | # 也可以明确指定目标架构 44 | # 受限于 Nuitka 工作原理,必须使用目标架构的 Python 执行构建。 45 | # 因此如果您需要交叉构建,则需要首先自行配置 QEMU linux-user binfmt_misc 46 | ./scripts/dist.sh riscv64 47 | ``` 48 | 49 | 许多发行版的 QEMU linux-user 模拟器包都会自带 binfmt\_misc 配置,例如在 50 | systemd 系统上,往 `/etc/binfmt.d` 安装相应的配置文件。由于模拟器的执行环境在 51 | Docker 容器内,因此需要使用静态链接的 QEMU linux-user 模拟器,并且您需要确保 52 | binfmt\_misc 配置中使用了 `F` (freeze) flag 以保证从未经修改的目标架构 sysroot 中也能访问到模拟器程序。 53 | 54 | ## Linux 环境下非基于 Docker 的构建 55 | 56 | 对于没有条件运行 Docker,或者官方未提供适用的构建容器镜像等等场合,您只能选择非基于 57 | Docker 的构建方式。您需要自行准备环境: 58 | 59 | * Python 版本:详见 `pyproject.toml`。目前官方使用的 Python 版本为 3.12.x。 60 | * 需要在 `PATH` 中有以下软件可用: 61 | * 所有情况下 62 | * `poetry` 63 | * 需要现场编译原生扩展的情况下 64 | * `auditwheel` 65 | * `cibuildwheel` 66 | * `maturin` 67 | 68 | 如果您的架构、操作系统不在官方支持的列表,那么 `scripts/dist.sh` 将发出警告并自动切换为非 69 | Docker 的构建。不过,如果您的环境实际上支持 `docker` 并且您仿照 `scripts/dist-image` 70 | 中的官方构建容器镜像描述自行打包了您环境的构建容器镜像,您也可以强制使用 Docker 构建: 71 | 72 | ```sh 73 | export RUYI_DIST_FORCE_IMAGE_TAG=your-account/your-builder-image:tag 74 | 75 | # 如果您的架构的 Docker 架构名(几乎总是等价于 GOARCH)与 dist.sh 或曰 Debian 76 | # 架构名不同,则设置 RUYI_DIST_GOARCH 77 | # 此处假设您的架构在 `uname -m` 叫 foo64el,在 Debian 叫 foo64,在 Go 叫 foo64le 78 | export RUYI_DIST_GOARCH=foo64le 79 | 80 | ./scripts/dist.sh foo64 81 | ``` 82 | 83 | 可以设置以下的环境变量来覆盖它们各自的默认取值。以下约定: 84 | 85 | * 以 `$REPO_ROOT` 表示 `ruyi` 仓库的 checkout 路径, 86 | * 以 `$ARCH` 表示 `scripts/dist.sh` 接受的参数,即 Debian 式的目标架构名。 87 | 88 | |变量名|含义|默认取值| 89 | |------|----|--------| 90 | |`CCACHE_DIR`|ccache 缓存|`$REPO_ROOT/tmp/ccache.$ARCH`| 91 | |`MAKEFLAGS`|`make` 默认参数|`-j$(nproc)`| 92 | |`POETRY_CACHE_DIR`|Poetry 缓存|`$REPO_ROOT/tmp/poetry-cache.$ARCH`| 93 | |`RUYI_DIST_BUILD_DIR`|`ruyi` 构建临时目录|`$REPO_ROOT/tmp/build.$ARCH`| 94 | |`RUYI_DIST_CACHE_DIR`|`ruyi` 构建系统缓存|`$REPO_ROOT/tmp/ruyi-dist-cache.$ARCH`| 95 | 96 | 请自行翻阅源码以了解更详细的行为。如果此文档没有收到及时更新,也应以源码行为为准。 97 | 98 | ## Windows 环境下的构建 99 | 100 | 除了使用 PowerShell 以及 Windows 各种惯例之外,Windows 下构建 `ruyi` 的方式与 101 | Linux 环境下的非基于 Docker 的构建很相似。请参考 GitHub Actions 中的相应定义。 102 | -------------------------------------------------------------------------------- /resources/bundled/_ruyi_completion: -------------------------------------------------------------------------------- 1 | #compdef ruyi 2 | 3 | # Likely a trimmed down version of https://github.com/kislyuk/argcomplete/blob/main/argcomplete/bash_completion.d/_python-argcomplete 4 | # Helpers have distinct prefix from the original script to avoid name collisions 5 | 6 | # Run something, muting output or redirecting it to the debug stream 7 | # depending on the value of _ARC_DEBUG. 8 | # If ARGCOMPLETE_USE_TEMPFILES is set, use tempfiles for IPC. 9 | __python_argcomplete_ruyi_run() { 10 | if [[ -z "${ARGCOMPLETE_USE_TEMPFILES-}" ]]; then 11 | __python_argcomplete_ruyi_run_inner "$@" 12 | return 13 | fi 14 | local tmpfile="$(mktemp)" 15 | _ARGCOMPLETE_STDOUT_FILENAME="$tmpfile" __python_argcomplete_ruyi_run_inner "$@" 16 | local code=$? 17 | cat "$tmpfile" 18 | rm "$tmpfile" 19 | return $code 20 | } 21 | 22 | __python_argcomplete_ruyi_run_inner() { 23 | if [[ -z "${_ARC_DEBUG-}" ]]; then 24 | "$@" 8>&1 9>&2 1>/dev/null 2>&1 &1 9>&2 1>&9 2>&1 /dev/null; then 54 | SUPPRESS_SPACE=1 55 | fi 56 | COMPREPLY=($(IFS="$IFS" \ 57 | COMP_LINE="$COMP_LINE" \ 58 | COMP_POINT="$COMP_POINT" \ 59 | COMP_TYPE="$COMP_TYPE" \ 60 | _ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \ 61 | _ARGCOMPLETE=1 \ 62 | _ARGCOMPLETE_SHELL="bash" \ 63 | _ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \ 64 | __python_argcomplete_ruyi_run ${script:-$1})) 65 | if [[ $? != 0 ]]; then 66 | unset COMPREPLY 67 | elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then 68 | compopt -o nospace 69 | fi 70 | fi 71 | } 72 | if [[ -z "${ZSH_VERSION-}" ]]; then 73 | complete -o nospace -o default -o bashdefault -F _python_argcomplete_ruyi ruyi 74 | else 75 | # When called by the Zsh completion system, this will end with 76 | # "loadautofunc" when initially autoloaded and "shfunc" later on, otherwise, 77 | # the script was "eval"-ed so use "compdef" to register it with the 78 | # completion system 79 | autoload is-at-least 80 | if [[ $zsh_eval_context == *func ]]; then 81 | _python_argcomplete_ruyi "$@" 82 | else 83 | compdef _python_argcomplete_ruyi ruyi 84 | fi 85 | fi 86 | -------------------------------------------------------------------------------- /ruyi/mux/venv/venv_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | from typing import TYPE_CHECKING 4 | 5 | from ...cli.cmd import RootCommand 6 | 7 | if TYPE_CHECKING: 8 | from ...cli.completion import ArgumentParser 9 | from ...config import GlobalConfig 10 | 11 | 12 | class VenvCommand( 13 | RootCommand, 14 | cmd="venv", 15 | help="Generate a virtual environment adapted to the chosen toolchain and profile", 16 | ): 17 | @classmethod 18 | def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None: 19 | p.add_argument("profile", type=str, help="Profile to use for the environment") 20 | p.add_argument("dest", type=str, help="Path to the new virtual environment") 21 | p.add_argument( 22 | "--name", 23 | "-n", 24 | type=str, 25 | default=None, 26 | help="Override the venv's name", 27 | ) 28 | p.add_argument( 29 | "--toolchain", 30 | "-t", 31 | type=str, 32 | action="append", 33 | help="Specifier(s) (atoms) of the toolchain package(s) to use", 34 | ) 35 | p.add_argument( 36 | "--emulator", 37 | "-e", 38 | type=str, 39 | help="Specifier (atom) of the emulator package to use", 40 | ) 41 | p.add_argument( 42 | "--with-sysroot", 43 | action="store_true", 44 | dest="with_sysroot", 45 | default=True, 46 | help="Provision a fresh sysroot inside the new virtual environment (default)", 47 | ) 48 | p.add_argument( 49 | "--without-sysroot", 50 | action="store_false", 51 | dest="with_sysroot", 52 | help="Do not include a sysroot inside the new virtual environment", 53 | ) 54 | p.add_argument( 55 | "--sysroot-from", 56 | type=str, 57 | help="Specifier (atom) of the sysroot package to use, in favor of the toolchain-included one if applicable", 58 | ) 59 | p.add_argument( 60 | "--extra-commands-from", 61 | type=str, 62 | action="append", 63 | help="Specifier(s) (atoms) of extra package(s) to add commands to the new virtual environment", 64 | ) 65 | 66 | @classmethod 67 | def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int: 68 | from ...ruyipkg.host import get_native_host 69 | from .maker import do_make_venv 70 | 71 | profile_name: str = args.profile 72 | dest = pathlib.Path(args.dest) 73 | with_sysroot: bool = args.with_sysroot 74 | override_name: str | None = args.name 75 | tc_atoms_str: list[str] | None = args.toolchain 76 | emu_atom_str: str | None = args.emulator 77 | sysroot_atom_str: str | None = args.sysroot_from 78 | extra_cmd_atoms_str: list[str] | None = args.extra_commands_from 79 | host = str(get_native_host()) 80 | 81 | return do_make_venv( 82 | cfg, 83 | host, 84 | profile_name, 85 | dest, 86 | with_sysroot, 87 | override_name, 88 | tc_atoms_str, 89 | emu_atom_str, 90 | sysroot_atom_str, 91 | extra_cmd_atoms_str, 92 | ) 93 | -------------------------------------------------------------------------------- /ruyi/ruyipkg/msg.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypedDict, TypeGuard, cast 2 | 3 | from jinja2 import BaseLoader, Environment, TemplateNotFound 4 | 5 | from ..utils.l10n import match_lang_code 6 | 7 | 8 | RepoMessagesV1Type = TypedDict( 9 | "RepoMessagesV1Type", 10 | { 11 | "ruyi-repo-messages": str, 12 | # lang_code: message_content 13 | }, 14 | ) 15 | 16 | 17 | def validate_repo_messages_v1(x: object) -> TypeGuard[RepoMessagesV1Type]: 18 | if not isinstance(x, dict): 19 | return False 20 | x = cast(dict[str, object], x) 21 | if x.get("ruyi-repo-messages", "") != "v1": 22 | return False 23 | return True 24 | 25 | 26 | def group_messages_by_lang_code(decl: RepoMessagesV1Type) -> dict[str, dict[str, str]]: 27 | obj = cast(dict[str, dict[str, str]], decl) 28 | 29 | result: dict[str, dict[str, str]] = {} 30 | for msgid, msg_decl in obj.items(): 31 | # skip the file type marker 32 | if msgid == "ruyi-repo-messages": 33 | continue 34 | 35 | for lang_code, msg in msg_decl.items(): 36 | if lang_code not in result: 37 | result[lang_code] = {} 38 | result[lang_code][msgid] = msg 39 | 40 | return result 41 | 42 | 43 | class RepoMessageStore: 44 | def __init__(self, decl: RepoMessagesV1Type) -> None: 45 | self._msgs_by_lang_code = group_messages_by_lang_code(decl) 46 | self._cached_envs_by_lang_code: dict[str, Environment] = {} 47 | 48 | @classmethod 49 | def from_object(cls, obj: object) -> "RepoMessageStore": 50 | if not validate_repo_messages_v1(obj): 51 | # TODO: more detail in the error message 52 | raise RuntimeError("malformed v1 repo messages definition") 53 | return cls(obj) 54 | 55 | def get_message_template(self, msgid: str, lang_code: str) -> str | None: 56 | resolved_lang_code = match_lang_code(lang_code, self._msgs_by_lang_code.keys()) 57 | return self._msgs_by_lang_code[resolved_lang_code].get(msgid) 58 | 59 | def get_jinja(self, lang_code: str) -> Environment: 60 | if lang_code in self._cached_envs_by_lang_code: 61 | return self._cached_envs_by_lang_code[lang_code] 62 | 63 | env = Environment( 64 | loader=RepoMessageLoader(self, lang_code), 65 | autoescape=False, # we're not producing HTML 66 | auto_reload=False, # we're serving static assets 67 | ) 68 | self._cached_envs_by_lang_code[lang_code] = env 69 | return env 70 | 71 | def render_message( 72 | self, 73 | msgid: str, 74 | lang_code: str, 75 | params: dict[str, str], 76 | add_trailing_newline: bool = False, 77 | ) -> str: 78 | env = self.get_jinja(lang_code) 79 | tmpl = env.get_template(msgid) 80 | result = tmpl.render(params) 81 | if add_trailing_newline and not result.endswith("\n"): 82 | return result + "\n" 83 | return result 84 | 85 | 86 | class RepoMessageLoader(BaseLoader): 87 | def __init__(self, store: RepoMessageStore, lang_code: str) -> None: 88 | self.store = store 89 | self.lang_code = lang_code 90 | 91 | def get_source( 92 | self, 93 | environment: Environment, 94 | template: str, 95 | ) -> tuple[str, (str | None), (Callable[[], bool] | None)]: 96 | result = self.store.get_message_template(template, self.lang_code) 97 | if result is None: 98 | raise TemplateNotFound(template) 99 | return result, None, None 100 | -------------------------------------------------------------------------------- /contrib/poetry-1.x/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core<2"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "ruyi" 7 | version = "0.44.0-alpha.20251218" 8 | description = "Package manager for RuyiSDK" 9 | keywords = ["ruyi", "ruyisdk"] 10 | license = "Apache-2.0" 11 | readme = "README.md" 12 | authors = [ 13 | "WANG Xuerui ", 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Environment :: Console", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: POSIX :: Linux", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | "Topic :: Software Development :: Build Tools", 27 | "Topic :: Software Development :: Embedded Systems", 28 | "Topic :: System :: Software Distribution", 29 | "Typing :: Typed", 30 | ] 31 | include = ["ruyi/py.typed"] 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.10" 35 | argcomplete = ">=2.0.0" 36 | arpy = "*" 37 | fastjsonschema = ">=2.15.1" 38 | jinja2 = "^3" 39 | # cannot represent multiple pygit2's with different python version markers 40 | pygit2 = ">=1.6" 41 | pyyaml = ">=5.4" 42 | requests = "^2" 43 | rich = ">=11.2.0" 44 | semver = ">=2.10" 45 | tomlkit = ">=0.9" 46 | tomli = { version = ">=1.2", python = "<3.11" } 47 | tzdata = { version = "*", platform = "win32" } 48 | # markupsafe 3.0.3's pyproject.toml causes MixedArrayTypesError for Poetry 1.0.7 49 | markupsafe = "<3.0.3" 50 | 51 | [tool.poetry.scripts] 52 | ruyi = "ruyi.__main__:entrypoint" 53 | 54 | [project.urls] 55 | homepage = "https://ruyisdk.org" 56 | documentation = "https://ruyisdk.org/docs/intro" 57 | download = "https://ruyisdk.org/download" 58 | github = "https://github.com/ruyisdk/ruyi" 59 | issues = "https://github.com/ruyisdk/ruyi/issues" 60 | repository = "https://github.com/ruyisdk/ruyi.git" 61 | 62 | 63 | [tool.mypy] 64 | files = ["ruyi", "scripts", "tests"] 65 | exclude = [ 66 | "tests/ruyi-litester", 67 | ] 68 | show_error_codes = true 69 | strict = true 70 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 71 | 72 | # https://github.com/eyeseast/python-frontmatter/issues/112 73 | # https://github.com/python/mypy/issues/8545 74 | # have to supply the typing info until upstream releases a new version with 75 | # the py.typed marker included 76 | mypy_path = "./stubs" 77 | 78 | 79 | [tool.pylic] 80 | safe_licenses = [ 81 | "Apache Software License", 82 | "BSD License", 83 | "GPLv2 with linking exception", 84 | "MIT", # pyright spells "MIT License" differently 85 | "MIT License", 86 | "Mozilla Public License 2.0 (MPL 2.0)", # needs mention in license notices 87 | "PSF-2.0", # typing_extensions 4.13 88 | 89 | # not ruyi deps, but brought in by pylic which unfortunately cannot live 90 | # outside of the project venv in order to work. 91 | # Fortunately though, they are all permissive licenses, so inclusion of 92 | # them would not accidentally allow unsafe licenses into the project. 93 | "ISC License (ISCL)", # shellingham 94 | "BSD-2-Clause", # boolean.py 95 | "BSD-3-Clause", # click 96 | "Apache-2.0", # license-expression 97 | ] 98 | 99 | 100 | [tool.pyright] 101 | include = ["ruyi", "scripts", "tests"] 102 | exclude = ["**/__pycache__", "tests/ruyi-litester", "tmp"] 103 | stubPath = "./stubs" 104 | pythonPlatform = "Linux" 105 | 106 | 107 | [tool.pytest.ini_options] 108 | testpaths = ["tests"] 109 | 110 | 111 | [tool.ruff] 112 | extend-exclude = [ 113 | "tests/ruyi-litester", 114 | ] 115 | -------------------------------------------------------------------------------- /ruyi/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | import ruyi 7 | from ruyi.utils.ci import is_running_in_ci 8 | from ruyi.utils.global_mode import ( 9 | EnvGlobalModeProvider, 10 | ENV_FORCE_ALLOW_ROOT, 11 | TRUTHY_ENV_VAR_VALUES, 12 | is_env_var_truthy, 13 | ) 14 | from ruyi.utils.node_info import probe_for_container_runtime 15 | 16 | # NOTE: no imports that directly or indirectly pull in pygit2 should go here, 17 | # because import of pygit2 will fail if done before ssl_patch. Notably this 18 | # means no GlobalConfig here because it depends on ruyi.ruyipkg.repo. 19 | 20 | 21 | def _is_running_as_root() -> bool: 22 | # this is way too simplistic but works on *nix systems which is all we 23 | # support currently 24 | if hasattr(os, "getuid"): 25 | return os.getuid() == 0 26 | return False 27 | 28 | 29 | def _is_allowed_to_run_as_root() -> bool: 30 | if is_env_var_truthy(os.environ, ENV_FORCE_ALLOW_ROOT): 31 | return True 32 | if is_running_in_ci(os.environ): 33 | # CI environments are usually considered to be controlled, and safe 34 | # for root usage. 35 | return True 36 | if probe_for_container_runtime(os.environ) != "unknown": 37 | # So are container environments. 38 | return True 39 | return False 40 | 41 | 42 | def entrypoint() -> None: 43 | gm = EnvGlobalModeProvider(os.environ, sys.argv) 44 | 45 | # NOTE: import of `ruyi.log` takes ~90ms on my machine, so initialization 46 | # of logging is deferred as late as possible 47 | 48 | if _is_running_as_root() and not _is_allowed_to_run_as_root(): 49 | from ruyi.log import RuyiConsoleLogger 50 | 51 | logger = RuyiConsoleLogger(gm) 52 | 53 | logger.F("refusing to run as super user outside CI without explicit consent") 54 | 55 | choices = ", ".join(f"'{x}'" for x in TRUTHY_ENV_VAR_VALUES) 56 | logger.I( 57 | f"re-run with environment variable [yellow]{ENV_FORCE_ALLOW_ROOT}[/] set to one of [yellow]{choices}[/] to signify consent" 58 | ) 59 | sys.exit(1) 60 | 61 | if not sys.argv: 62 | from ruyi.log import RuyiConsoleLogger 63 | 64 | logger = RuyiConsoleLogger(gm) 65 | 66 | logger.F("no argv?") 67 | sys.exit(1) 68 | 69 | if gm.is_packaged and ruyi.__compiled__.standalone: 70 | # If we're running from a bundle, our bundled libssl may remember a 71 | # different path for loading certificates than appropriate for the 72 | # current system, in which case the pygit2 import will fail. To avoid 73 | # this we have to patch ssl.get_default_verify_paths with additional 74 | # logic. 75 | # 76 | # this must happen before pygit2 is imported 77 | from ruyi.utils import ssl_patch 78 | 79 | del ssl_patch 80 | 81 | from ruyi.utils.nuitka import get_nuitka_self_exe, get_argv0 82 | 83 | # note down our own executable path, for identity-checking in mux, if not 84 | # we're not already Nuitka-compiled 85 | # 86 | # we assume the one-file build if Nuitka is detected; sys.argv[0] does NOT 87 | # work if it's just `ruyi` so we have to check our parent process in that case 88 | self_exe = get_nuitka_self_exe() if gm.is_packaged else __file__ 89 | sys.argv[0] = get_argv0() 90 | gm.record_self_exe(sys.argv[0], __file__, self_exe) 91 | 92 | from ruyi.config import GlobalConfig 93 | from ruyi.cli.main import main 94 | from ruyi.log import RuyiConsoleLogger 95 | 96 | logger = RuyiConsoleLogger(gm) 97 | gc = GlobalConfig.load_from_config(gm, logger) 98 | sys.exit(main(gm, gc, sys.argv)) 99 | 100 | 101 | if __name__ == "__main__": 102 | entrypoint() 103 | -------------------------------------------------------------------------------- /docs/dep-baseline.md: -------------------------------------------------------------------------------- 1 | # `ruyi` 的依赖兼容基线 2 | 3 | 为降低发行版打包的工作量,以及保证非单文件形式分发的 `ruyi` 能与发行版在系统级提供的各种依赖组件正常交互,有必要认真对待 4 | `ruyi` 的各种依赖的版本。在实现或修复某些功能的时候,如果涉及新增依赖或变更依赖版本,需要谨慎行事。 5 | 6 | 本文档是对 RuyiSDK 文档站[《RuyiSDK 的平台支持情况》](https://ruyisdk.org/docs/Other/platform-support/)一文,从开发角度进行的补充:为了实现 7 | RuyiSDK 所承诺的平台兼容性,在代码层面需要考虑的各项依赖的最低版本。 8 | 9 | 以下是 `ruyi` 重点依赖的 **架构相关** 软件包在一些发行版的提供情况: 10 | 11 | 12 | 13 | 14 | | 发行版版本 | Python | pygit2 | pyyaml | 15 | |-------------------------|--------|--------|------------------------| 16 | | Debian 12 | 3.11 | 1.11.1 | 6.0 [^debian-pyyaml] | 17 | | Debian 13[^deb13] | 3.13 | 1.17.0 | 6.0.2 [^debian-pyyaml] | 18 | | Fedora 39 | 3.12 | 1.14.0 | 6.0.1 | 19 | | Fedora 40 | 3.12 | 1.14.0 | 6.0.1 | 20 | | Fedora 41 | 3.13 | 1.15.1 | 6.0.1 | 21 | | Fedora 42 | 3.13 | 1.17.0 | 6.0.2 | 22 | | OpenCloudOS 9.4 | 3.11 | 1.12.2 | 6.0.1 | 23 | | openEuler 24.03 LTS | 3.11 | :x: | 6.0.1 | 24 | | openEuler 24.03 LTS SP2 | 3.11 | :x: | 6.0.1 | 25 | | openEuler 25.03 | 3.11 | :x: | 6.0.2 | 26 | | openKylin 2.0 | 3.12 | :x: | 6.0.1 [^debian-pyyaml] | 27 | | Ubuntu 22.04 LTS | 3.10 | 1.6.1 | 5.4.1 [^debian-pyyaml] | 28 | | Ubuntu 24.04 LTS | 3.12 | 1.14.1 | 6.0.1 [^debian-pyyaml] | 29 | 30 | 31 | 32 | [^deb13]: 尚未正式发布,但软件包版本已在一定程度上冻结 33 | [^debian-pyyaml]: 包名为 `python3-yaml` 34 | 35 | 以下是 `ruyi` 依赖的 **架构无关** 软件包在一些发行版的提供情况: 36 | 37 | 38 | 39 | 40 | | 发行版版本 | argcomplete | arpy | certifi | fastjsonschema | jinja2 | requests | rich | semver | tomlkit | typing\_extensions | 41 | | ----------------------- | ----------- | ----- | ---------- | -------------- | ------ | -------- | ------ | ------ | ------- | ------------------ | 42 | | Debian 12 | 3.6.2 | 1.1.1 | 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 | 43 | | Debian 13[^deb13] | 3.6.2 | 1.1.1 | 2025.1.31 | 2.21.1 | 3.1.6 | 2.32.3 | 13.9.4 | 3.0.2 | 0.13.2 | 4.13.2 | 44 | | Fedora 39 | 2.0.0 | 2.3.0 | 2023.05.07 | 2.16.3 | 3.1.4 | 2.28.2 | 13.5.2 | 3.0.2 | 0.11.4 | 4.12.2 | 45 | | Fedora 40 | 3.6.2 | 2.3.0 | 2023.05.07 | 2.18.0 | 3.1.4 | 2.31.0 | 13.7.0 | 3.0.2 | 0.12.3 | 4.12.2 | 46 | | Fedora 41 | 3.6.2 | 2.3.0 | 2023.05.07 | 2.19.1 | 3.1.4 | 2.32.3 | 13.7.1 | 3.0.2 | 0.12.4 | 4.12.2 | 47 | | Fedora 42 | 3.6.2 | 2.3.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 | 48 | | OpenCloudOS 9.4 | 3.1.2 | :x: | 2023.7.22 | 2.18.0 | 3.1.4 | 2.32.3 | 13.5.3 | 3.0.1 | 0.12.1 | 4.7.1 | 49 | | openEuler 24.03 LTS | 3.2.2 | :x: | 2024.2.2 | 2.19.1 | 3.1.3 | 2.31.0 | 13.7.1 | 3.0.2 | 0.12.3 | 4.10.0 | 50 | | openEuler 24.03 LTS SP2 | 3.2.2 | :x: | 2024.2.2 | 2.19.1 | 3.1.3 | 2.31.0 | 13.7.1 | 3.0.2 | 0.13.2 | 4.12.2 | 51 | | openEuler 25.03 | 3.5.1 | :x: | 2025.1.31 | 2.21.1 | 3.1.3 | 2.31.0 | 13.8.0 | 3.0.2 | 0.13.2 | 4.12.2 | 52 | | openKylin 2.0 | 1.8.1 | 1.1.1 | 2023.11.17 | :x: | 3.1.2 | 2.31.0 | :x: | 2.0.1 | :x: | 4.10.0 | 53 | | Ubuntu 22.04 LTS | 3.1.4 | 1.1.1 | 2020.6.20 | 2.15.1 | 3.0.3 | 2.25.1 | 11.2.0 | 2.10.2 | 0.9.2 | 3.10.0.2 | 54 | | Ubuntu 24.04 LTS | 3.1.4 | 1.1.1 | 2023.11.17 | 2.19.0 | 3.1.2 | 2.31.0 | 13.7.1 | 2.10.2 | 0.12.4 | 4.10.0 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /ruyi/utils/toml.py: -------------------------------------------------------------------------------- 1 | from contextlib import AbstractContextManager 2 | from types import TracebackType 3 | from typing import Iterable 4 | 5 | import tomlkit 6 | from tomlkit.container import Container 7 | from tomlkit.items import Array, Comment, InlineTable, Item, Table, Trivia, Whitespace 8 | 9 | 10 | class NoneValue(Exception): 11 | """Used to indicate that a None value is to be dumped in TOML. Because TOML 12 | does not support None natively, this means special handling is needed.""" 13 | 14 | def __str__(self) -> str: 15 | return "NoneValue()" 16 | 17 | def __repr__(self) -> str: 18 | return "NoneValue()" 19 | 20 | 21 | def with_indent(item: Item, spaces: int = 2) -> Item: 22 | item.indent(spaces) 23 | return item 24 | 25 | 26 | def inline_table_with_spaces() -> "InlineTableWithSpaces": 27 | return InlineTableWithSpaces(Container(), Trivia(), new=True) 28 | 29 | 30 | class InlineTableWithSpaces(InlineTable, AbstractContextManager[InlineTable]): 31 | def __init__( 32 | self, 33 | value: Container, 34 | trivia: Trivia, 35 | new: bool = False, 36 | ) -> None: 37 | super().__init__(value, trivia, new) 38 | 39 | def __enter__(self) -> InlineTable: 40 | self.add(tomlkit.ws(" ")) 41 | return self 42 | 43 | def __exit__( 44 | self, 45 | exc_type: type[BaseException] | None, 46 | exc_value: BaseException | None, 47 | traceback: TracebackType | None, 48 | ) -> bool | None: 49 | self.add(tomlkit.ws(" ")) 50 | return None 51 | 52 | 53 | def _into_item(x: Item | str) -> Item: 54 | if isinstance(x, Item): 55 | return x 56 | return tomlkit.string(x) 57 | 58 | 59 | def str_array( 60 | args: Iterable[Item | str], 61 | *, 62 | multiline: bool = False, 63 | indent: int = 2, 64 | ) -> Array: 65 | items = [_into_item(i).indent(indent) for i in args] 66 | return Array(items, Trivia(), multiline=multiline) 67 | 68 | 69 | def sorted_table(x: dict[str, str]) -> Table: 70 | y = tomlkit.table() 71 | for k in sorted(x.keys()): 72 | y.add(k, x[k]) 73 | return y 74 | 75 | 76 | def extract_header_comments( 77 | doc: Container, 78 | ) -> list[str]: 79 | comments: list[str] = [] 80 | 81 | # ignore leading whitespaces 82 | is_skipping_leading_ws = True 83 | for _key, item in doc.body: 84 | if isinstance(item, Whitespace): 85 | if is_skipping_leading_ws: 86 | continue 87 | # this is part of the header comments 88 | comments.append(item.as_string()) 89 | elif isinstance(item, Comment): 90 | is_skipping_leading_ws = False 91 | comments.append(item.as_string()) 92 | else: 93 | # we reached the first non-comment item 94 | break 95 | return comments 96 | 97 | 98 | def extract_footer_comments( 99 | doc: Container, 100 | ) -> list[str]: 101 | comments: list[str] = [] 102 | 103 | # ignore trailing whitespaces 104 | is_skipping_trailing_ws = True 105 | for _key, item in reversed(doc.body): 106 | if isinstance(item, Whitespace): 107 | if is_skipping_trailing_ws: 108 | continue 109 | # this is part of the footer comments 110 | comments.append(item.as_string()) 111 | elif isinstance(item, Comment): 112 | is_skipping_trailing_ws = False 113 | comments.append(item.as_string()) 114 | else: 115 | # we reached the first non-comment item 116 | break 117 | 118 | # if the footer comment was preceded by a table, then the comment would be 119 | # nested inside the table and invisible in top-level doc.body, so we would 120 | # have to check the last item as well 121 | if not comments: 122 | last_elem = doc.body[-1][1].value 123 | if isinstance(last_elem, Container): 124 | return extract_footer_comments(last_elem) 125 | 126 | return list(reversed(comments)) 127 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [project] 6 | name = "ruyi" 7 | version = "0.44.0-alpha.20251218" 8 | description = "Package manager for RuyiSDK" 9 | keywords = ["ruyi", "ruyisdk"] 10 | license = { file = "LICENSE-Apache.txt" } 11 | readme = "README.md" 12 | authors = [ 13 | { name = "WANG Xuerui", email = "wangxuerui@iscas.ac.cn" } 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Environment :: Console", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: POSIX :: Linux", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | "Topic :: Software Development :: Build Tools", 27 | "Topic :: Software Development :: Embedded Systems", 28 | "Topic :: System :: Software Distribution", 29 | "Typing :: Typed", 30 | ] 31 | requires-python = ">=3.10" 32 | dependencies = [ 33 | "argcomplete>=2.0.0", 34 | "arpy", 35 | "fastjsonschema>=2.15.1", 36 | "jinja2 (>=3, <4)", 37 | "pygit2 (>=1.6, <1.19); python_version<'3.11'", 38 | "pygit2>=1.6; python_version>='3.11'", 39 | "pyyaml>=5.4", 40 | "requests (>=2, <3)", 41 | "rich>=11.2.0", 42 | "semver>=2.10", 43 | "tomlkit>=0.9", 44 | "tomli>=1.2; python_version<'3.11'", 45 | "tzdata; sys_platform=='win32'", 46 | ] 47 | 48 | [project.scripts] 49 | ruyi = "ruyi.__main__:entrypoint" 50 | 51 | [project.urls] 52 | homepage = "https://ruyisdk.org" 53 | documentation = "https://ruyisdk.org/docs/intro" 54 | download = "https://ruyisdk.org/download" 55 | github = "https://github.com/ruyisdk/ruyi" 56 | issues = "https://github.com/ruyisdk/ruyi/issues" 57 | repository = "https://github.com/ruyisdk/ruyi.git" 58 | 59 | [tool.poetry] 60 | include = ["ruyi/py.typed"] 61 | exclude = ["**/.gitignore"] 62 | 63 | [tool.poetry.group.dev.dependencies] 64 | mypy = "^1.9.0" 65 | pyright = "^1.1.389" 66 | pytest = ">=6.2.5" 67 | ruff = "^0.8.1" 68 | tomlkit-extras = "^0.2.0" 69 | typing-extensions = ">=3.10.0.2" 70 | 71 | types-cffi = "^1.16.0.20240106" 72 | types-pygit2 = "^1.14.0.20240317" 73 | types-PyYAML = "^6.0.12.20240311" 74 | types-requests = "^2.31.0.20240311" 75 | 76 | [tool.poetry.group.dist.dependencies] 77 | certifi = "*" 78 | nuitka = "^2.0" 79 | 80 | 81 | [tool.mypy] 82 | files = ["ruyi", "scripts", "tests"] 83 | exclude = [ 84 | "tests/ruyi-litester", 85 | ] 86 | show_error_codes = true 87 | strict = true 88 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 89 | 90 | # https://github.com/eyeseast/python-frontmatter/issues/112 91 | # https://github.com/python/mypy/issues/8545 92 | # have to supply the typing info until upstream releases a new version with 93 | # the py.typed marker included 94 | mypy_path = "./stubs" 95 | 96 | 97 | [tool.pylic] 98 | safe_licenses = [ 99 | "Apache Software License", 100 | "BSD License", 101 | "GPLv2 with linking exception", 102 | "MIT", # pyright spells "MIT License" differently 103 | "MIT License", 104 | "Mozilla Public License 2.0 (MPL 2.0)", # needs mention in license notices 105 | "PSF-2.0", # typing_extensions 4.13 106 | 107 | # not ruyi deps, but brought in by pylic which unfortunately cannot live 108 | # outside of the project venv in order to work. 109 | # Fortunately though, they are all permissive licenses, so inclusion of 110 | # them would not accidentally allow unsafe licenses into the project. 111 | "ISC License (ISCL)", # shellingham 112 | "BSD-2-Clause", # boolean.py 113 | "BSD-3-Clause", # click 114 | "Apache-2.0", # license-expression 115 | ] 116 | 117 | 118 | [tool.pyright] 119 | include = ["ruyi", "scripts", "tests"] 120 | exclude = ["**/__pycache__", "tests/ruyi-litester", "tmp"] 121 | stubPath = "./stubs" 122 | pythonPlatform = "Linux" 123 | 124 | 125 | [tool.pytest.ini_options] 126 | testpaths = ["tests"] 127 | 128 | 129 | [tool.ruff] 130 | extend-exclude = [ 131 | "tests/ruyi-litester", 132 | ] 133 | --------------------------------------------------------------------------------