diff --git a/.github/workflows/pr-format.yml b/.github/workflows/pr-format.yml index df75e8848..49bbdc9bf 100644 --- a/.github/workflows/pr-format.yml +++ b/.github/workflows/pr-format.yml @@ -26,18 +26,19 @@ jobs: # submodule: 'recursive' fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 - id: setup_python + - name: Get uv version + id: get_uv_version + if: github.event_name != 'pull_request' || github.event.action != 'closed' + run: echo "uv_version=$(sed -e 's/uv==//g' requirements.txt)" >> "$GITHUB_OUTPUT" + - name: Set up uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 if: github.event_name != 'pull_request' || github.event.action != 'closed' with: - python-version-file: .python-version - cache: pipenv - - if: github.event_name != 'pull_request' || github.event.action != 'closed' - run: sed -i -e "s/python_version = \".*\"/python_version = \"$(echo ${{ steps.setup_python.outputs.python-version }} | sed -e 's/\([0-9]*\.[0-9]*\).*/\1/g')\"/g" Pipfile + version: ${{steps.get_uv_version.outputs.uv_version}} + enable-cache: true - name: Install dependencies if: github.event_name != 'pull_request' || github.event.action != 'closed' - run: bash "${GITHUB_WORKSPACE}/scripts/pipenv_install.sh" + run: bash "${GITHUB_WORKSPACE}/scripts/uv_install.sh" # formatする # --exit-codeをつけることで、autopep8内でエラーが起きれば1、差分があれば2のエラーステータスコードが返ってくる。正常時は0が返る - name: Format files diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d6945256e..6b10ff0e2 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -14,13 +14,16 @@ jobs: with: submodules: "recursive" fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - name: Get uv version + id: get_uv_version + run: echo "uv_version=$(sed -e 's/uv==//g' requirements.txt)" >> "$GITHUB_OUTPUT" + - name: Set up uv + uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: - python-version-file: .python-version - cache: pipenv - - name: Install pipenv - run: bash "${GITHUB_WORKSPACE}/scripts/pipenv_install.sh" + version: ${{steps.get_uv_version.outputs.uv_version}} + enable-cache: true + - name: Install uv + run: bash "${GITHUB_WORKSPACE}/scripts/uv_install.sh" - name: Set venv path env: DEST_PATH: "/home/runner/work/_temp/_github_workflow/.venv" diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 8f4dc5cfc..000000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -Pipfile.lock diff --git a/.python-version b/.python-version deleted file mode 100644 index c10780c62..000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13.1 diff --git a/Pipfile b/Pipfile index 483f1a8a8..839e1ad19 100644 --- a/Pipfile +++ b/Pipfile @@ -15,7 +15,7 @@ setuptools = "==75.6.0" [packages] pyperclip = ">=1.5.27" -click = "==8.1.7" + [requires] python_version = "3.13" diff --git a/requirements.txt b/requirements.txt index 7ca1918f0..14b158370 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pipenv==2024.4.0 +uv==0.5.9 diff --git a/scripts/pr_format/pr_format/fix_pyproject.py b/scripts/pr_format/pr_format/fix_pyproject.py new file mode 100644 index 000000000..f66c072c0 --- /dev/null +++ b/scripts/pr_format/pr_format/fix_pyproject.py @@ -0,0 +1,267 @@ +""" +pyproject.tomlに対して以下の修正を行う。 + +* pyproject.tomlでのバージョン指定がないパッケージについて、バージョン指定を実際にインストールされるものに修正する +* プロジェクト内のPythonファイルでimportされているがpyproject.toml内には存在しないパッケージをpyproject.tomlの「project.dependencies」セクションに追加する +""" + +import importlib.util +import re +import sys +from pathlib import Path +from typing import NoReturn, TypeGuard + +import importlib_metadata +import toml + +# pyproject.tomlのproject.dependenciesやdependency-groups.devのデータ型 +PyProjectDependencies = list[str] + +# pyproject.tomlのデータ型 +PyProject = dict[str, dict[str, PyProjectDependencies]] + +version_operator_pattern = re.compile(r"[\\[<=>@]") + + +def is_pyproject_dependencies( + pyproject_dependencies, +) -> TypeGuard[PyProjectDependencies]: + """ + データがPyProjectDependencies型であるかを判定する + :param pyproject_dependencies: 判定対象のデータ + :return: PyProjectDependencies型であるか + """ + if not isinstance(pyproject_dependencies, list): + return False + + for v in pyproject_dependencies: + if not isinstance(v, str): + return False + + return True + + +def get_package_version(package_name: str) -> str: + """ + メタデータからパッケージのバージョンを取得する + :param package_name: 取得対象のパッケージ名 + :return: パッケージのバージョン (「package_name==X.Y.Z」形式)。バージョンを取得できなかった場合はpackage_nameを返す。 + """ + try: + package_data = version_operator_pattern.split(package_name) + dist = importlib_metadata.distribution(package_data[0]) + except importlib_metadata.PackageNotFoundError: + return package_name + + return f"{package_name}=={dist.version}" + + +def fix_package_version(packages: PyProjectDependencies) -> PyProjectDependencies: + """ + project.tomlでのバージョン指定がないパッケージについて、バージョン指定を実際にインストールされるものに修正する + :param packages: パッケージ一覧 + :return: パッケージ一覧 + """ + for i in range(len(packages)): + if not re.findall(r"[<=>@]", packages[i]): + packages[i] = get_package_version(packages[i]) + + return packages + + +def is_std_or_local_lib(project_root: Path, package_name: str) -> bool: + """ + 与えられたパッケージが標準パッケージ or 独自に定義したものであるかを判定する + :param project_root: プロジェクトのルートディレクトリのパス + :param package_name: 判定対象のパッケージ名 + :return: 与えられたパッケージが標準パッケージ or 独自に定義したものであるか + """ + # 与えられたパッケージがビルドインのモジュールならば標準パッケージと判定する + if package_name in sys.builtin_module_names: + return True + + package_spec = None + + # Finderを使ってパッケージ情報 (Spec) を取得する + for finder in sys.meta_path: + try: + package_spec = finder.find_spec(package_name, ".") + except (AttributeError, ValueError, ModuleNotFoundError): + pass + + if package_spec: + break + + if package_spec is None: + try: + package_spec = importlib.util.find_spec(package_name) + except (AttributeError, ValueError, ModuleNotFoundError): + pass + + # パッケージ情報がないならばpyproject.tomlによってインストールされたものと判定する + if package_spec is None: + return False + + # パッケージのファイルパス + package_origin = package_spec.origin + + # 次のいずれかならばpyproject.tomlによってインストールされたものと判定する + # * パッケージのファイルパスが取得できない + # * パッケージのファイルパス内に「.venv」を含む + if not package_origin or ".venv" in package_origin: + return False + + # パッケージのファイルパスがプロジェクト内のものであれば独自に定義したものと判定する + if project_root.resolve() in Path(package_origin).resolve().parents: + return True + + # パッケージのファイルパスがPythonのシステムのパスと一致するならば標準パッケージと判定する + if package_origin.startswith(sys.base_prefix): + return True + + return False + + +def get_imported_packages(project_root: Path) -> set[str]: + """ + プロジェクト内のPythonファイルからimportされているパッケージの一覧 (標準パッケージや独自に定義したものを除く) を取得する + :param project_root: プロジェクトのルートディレクトリのパス + :return: プロジェクト内のPythonファイル内でimportされているパッケージの一覧 (標準パッケージや独自に定義したものを除く) + """ + imported_packages: set[str] = set() + + for file in project_root.glob("**/*.py"): + if ( + str(file).endswith("setup.py") + or "node_modules" in str(file) + or ".venv" in str(file) + ): + continue + + with open(str(file), "r") as python_file: + for imported_package in re.findall( + r"^(?:import|from)\s+(\w+)", python_file.read(), re.MULTILINE + ): + if imported_package != "sudden_death" and not is_std_or_local_lib( + project_root, imported_package + ): + imported_packages.add(imported_package) + + return imported_packages + + +def get_pyproject_packages(pyproject: PyProject) -> set[str] | NoReturn: + """ + pyproject.tomlからパッケージ一覧を取得する + :param pyproject: pyproject.tomlの中身 + :return: pyproject.toml内のパッケージ一覧 + """ + pyproject_packages: set[str] = set() + + for pyproject_dependencies in [ + pyproject["project"]["dependencies"], + pyproject["dependency-groups"]["dev"], + ]: + if not is_pyproject_dependencies(pyproject_dependencies): + raise TypeError( + "Failed to cast to PyProjectDependencies: " + + str(pyproject_dependencies) + ) + + for pyproject_dependency in pyproject_dependencies: + package_data = version_operator_pattern.split(pyproject_dependency) + pyproject_packages.add(package_data[0].strip().lower().replace("_", "-")) + + return pyproject_packages + + +def exist_package_in_pyproject( + packages: list[str], pyproject_packages: set[str] +) -> bool: + """ + 与えられたパッケージ群のいずれかがpyproject.toml内に存在するかを判定する + :param packages: パッケージ群 + :param pyproject_packages: pyproject.toml内のパッケージ一覧 + :return: 与えられたパッケージ群のいずれかがpyproject.toml内に存在するか + """ + for package_name in packages: + if package_name.lower().replace("_", "-") in pyproject_packages: + return True + + return False + + +def get_missing_packages( + imported_packages: set[str], pyproject_packages: set[str] +) -> PyProjectDependencies | NoReturn: + """ + プロジェクト内のPythonファイルでimportされているがpyproject.toml内には存在しないパッケージ一覧を取得する + :param imported_packages: プロジェクト内のPythonファイルからimportされているパッケージ一覧 + :param pyproject_packages: pyproject.toml内のパッケージ一覧 + :return: プロジェクト内のPythonファイルでimportされているがpyproject.toml内には存在しないパッケージ一覧。pyproject.toml内でのパッケージ名がkey、バージョンがvalueになっている。 + """ + # import時のパッケージ名とpyproject.toml内でのパッケージ名の対応表 + distributions = importlib_metadata.packages_distributions() + + missing_packages: PyProjectDependencies = list() + + for imported_package in imported_packages: + if imported_package not in distributions: + raise ModuleNotFoundError( + f"Package {imported_package} is not found. It maybe not installed." + ) + + packages = distributions[imported_package] + + if len(packages) == 0: + raise ModuleNotFoundError( + f"Package {imported_package} is not found. It maybe not installed." + ) + + if not exist_package_in_pyproject(packages, pyproject_packages): + package_name: str = packages[0] + missing_packages.append(get_package_version(package_name)) + + return missing_packages + + +def main(): + project_root = Path.cwd() + pyproject_path = project_root / "pyproject.toml" + + if not pyproject_path.exists(): + raise FileNotFoundError("pyproject.toml not found.") + + pyproject = toml.load(pyproject_path) + + if not is_pyproject_dependencies(pyproject["project"]["dependencies"]): + raise TypeError( + "Failed to cast to PyProjectDependencies: " + + str(pyproject["project"]["dependencies"]) + ) + + pyproject["project"]["dependencies"] = fix_package_version( + pyproject["project"]["dependencies"] + ) + + # プロジェクト内のPythonファイルでimportされているがpyproject.toml内には存在しないパッケージをpyproject.tomlの「project.dependencies」セクションに追加する + pyproject["project"]["dependencies"] += get_missing_packages( + get_imported_packages(project_root), get_pyproject_packages(pyproject) + ) + + if not is_pyproject_dependencies(pyproject["dependency-groups"]["dev"]): + raise TypeError( + "Failed to cast to PyProjectDependencies: " + + str(pyproject["dependency-groups"]["dev"]) + ) + + pyproject["dependency-groups"]["dev"] = fix_package_version( + pyproject["dependency-groups"]["dev"] + ) + + with open(pyproject_path, "w") as f: + toml.dump(pyproject, f) + + +if __name__ == "__main__": + main() diff --git a/scripts/pr_format/pr_format/format.sh b/scripts/pr_format/pr_format/format.sh index 20245491d..b9ee5b0b0 100755 --- a/scripts/pr_format/pr_format/format.sh +++ b/scripts/pr_format/pr_format/format.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -pipenv run python "${GITHUB_WORKSPACE}/scripts/pr_format/pr_format/fix_pipfile.py" +pipenv run python "${GITHUB_WORKSPACE}/scripts/pr_format/pr_format/fix_pyproject.py" tag_name="$(yq '.jobs.pr-super-lint.steps[-1].uses' .github/workflows/pr-test.yml | sed -e 's;/slim@.*;:slim;g')" tag_version="$(yq '.jobs.pr-super-lint.steps[-1].uses | line_comment' .github/workflows/pr-test.yml)" pyink_version="$(docker run --rm --entrypoint '' "ghcr.io/${tag_name}-${tag_version}" /bin/sh -c 'pyink --version' | grep pyink | awk '{ print $2 }')" -sed -i -e "s/pyink = .*/pyink = \"==${pyink_version}\"/g" Pipfile -pipenv install --dev -pipenv run autopep8 --exit-code --in-place --recursive . -pipenv run pyink --config .python-black . -pipenv run isort --sp .isort.cfg . +sed -i -e "s/pyink==.*\"/pyink==${pyink_version}\"/g" pyproject.toml +uv sync --dev +uv tool run autopep8 --exit-code --in-place --recursive . +uv tool run pyink --config .python-black . +uv tool run isort --sp .isort.cfg . diff --git a/scripts/pr_test/pr_super_lint/get_python_version.py b/scripts/pr_test/pr_super_lint/get_python_version.py new file mode 100644 index 000000000..32342308a --- /dev/null +++ b/scripts/pr_test/pr_super_lint/get_python_version.py @@ -0,0 +1,3 @@ +import sys + +print(".".join(map(str, sys.version_info[0:2]))) diff --git a/scripts/pr_test/pr_super_lint/npm_ci.sh b/scripts/pr_test/pr_super_lint/npm_ci.sh index d1dad548e..425cca6e6 100755 --- a/scripts/pr_test/pr_super_lint/npm_ci.sh +++ b/scripts/pr_test/pr_super_lint/npm_ci.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash bash "${GITHUB_WORKSPACE}/scripts/npm_ci.sh" -echo "PYTHONPATH=/github/workspace/:/github/workflow/.venv/lib/python$(echo 'import sys; print(".".join(map(str, sys.version_info[0:2])))' | python)/site-packages" >>"${GITHUB_ENV}" +echo "PYTHONPATH=/github/workspace/:/github/workflow/.venv/lib/python$(uv run "${GITHUB_WORKSPACE}/scripts/pr_test/pr_super_lint/get_python_version.py")/site-packages" >>"${GITHUB_ENV}" action="$(yq '.jobs.pr-super-lint.steps[-1].uses | line_comment' .github/workflows/pr-test.yml)" PATH="$(docker run --rm --entrypoint '' "ghcr.io/super-linter/super-linter:slim-${action}" /bin/sh -c 'echo $PATH')" echo "PATH=/github/workspace/node_modules/.bin:${PATH}" >>"$GITHUB_ENV" diff --git a/scripts/pr_test/pr_super_lint/set_venv_path.sh b/scripts/pr_test/pr_super_lint/set_venv_path.sh index 6c08a1cec..04f5406b7 100755 --- a/scripts/pr_test/pr_super_lint/set_venv_path.sh +++ b/scripts/pr_test/pr_super_lint/set_venv_path.sh @@ -2,9 +2,7 @@ # 環境ファイルを使ってenvにsetしている # 参考URL: https://bit.ly/2KJhjqk -venv_path=$(pipenv --venv) -echo "${venv_path}" -VENV_PATH="${venv_path}" +VENV_PATH=".venv" # https://github.com/github/super-linter/issues/157#issuecomment-648850330 # -v "/home/runner/work/_temp/_github_workflow":"/github/workflow" diff --git a/scripts/uv_install.sh b/scripts/uv_install.sh new file mode 100755 index 000000000..2c9f5c666 --- /dev/null +++ b/scripts/uv_install.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +uv --version +uv python install +uv sync --dev