diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a094b90 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.yaml,*.yml}] +indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2390d8c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/readme_example.yaml b/.github/workflows/readme_example.yaml new file mode 100644 index 0000000..26c5c5b --- /dev/null +++ b/.github/workflows/readme_example.yaml @@ -0,0 +1,74 @@ +# This workflow exists for several purposes: +# +# * Ensure that the example in the README is functional. +# * Ensure that the desired YAML formatting is enforced. +# * Ensure that the example action versions are maintained. +# +# Update PRs submitted by Dependabot should trigger pre-commit.ci, +# which will synchronize changes to this file into the README. +# + +name: "📘 README" + +on: + pull_request: + push: + branches: + - "main" + - "releases" + schedule: + - cron: "27 19 * * *" + +jobs: + readme_example: + name: "Test README code (${{ matrix.os.name }})" + strategy: + matrix: + os: + - name: "Linux" + runner: "ubuntu-latest" + - name: "macOS" + runner: "macos-latest" + - name: "Windows" + runner: "windows-latest" + fail-fast: false + + runs-on: "${{ matrix.os.runner }}" + steps: + # START_README_EXAMPLE_BLOCK + - uses: "actions/setup-python@v4" + with: + python-version: | + pypy3.10 + 3.11 + + - uses: "kurtmckee/detect-pythons@main" + + - uses: "actions/cache@v3" + id: "restore-cache" + with: + # You may need to augment the list of files to hash. + # For example, you might add 'requirements/*.txt' or 'pyproject.toml'. + key: "${{ hashFiles('.python-identifiers') }}" + path: | + .tox/ + .venv/ + + - name: "Identify .venv path" + shell: "bash" + run: "echo 'venv-path=.venv/${{ runner.os == 'Windows' && 'Scripts' || 'bin' }}' >> $GITHUB_ENV" + + - name: "Create a virtual environment" + if: "steps.restore-cache.outputs.cache-hit == false" + run: | + python -m venv .venv + ${{ env.venv-path }}/python -m pip install --upgrade pip setuptools wheel + + # You may need to customize what gets installed next. + # However, tox is able to run test suites against multiple Pythons, + # so it's a helpful tool for efficient testing. + ${{ env.venv-path }}/pip install tox + + - name: "Run the test suite against all installed Pythons" + run: "${{ env.venv-path }}/tox" + # END_README_EXAMPLE_BLOCK diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..e5057f2 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,37 @@ +name: "🔬 Test" + +on: + push: + branches: + - "main" + +jobs: + test: + name: "Test on ${{ matrix.os.name }}" + + strategy: + matrix: + os: + - name: "Linux" + runner: "ubuntu-latest" + - name: "macOS" + runner: "macos-latest" + - name: "Windows" + runner: "windows-latest" + fail-fast: false + + runs-on: "${{ matrix.os.runner }}" + steps: + - name: "Use it!" + id: "detector" + uses: "kurtmckee/detect-pythons@main" + + - name: "Print it!" + shell: "bash" + run: | + echo '${{ steps.detector.outputs.python-identifiers }}' + + - name: "Display it!" + shell: "bash" + run: | + cat .python-identifiers diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1f2c6ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,76 @@ +ci: + autoupdate_schedule: "monthly" + +repos: + - repo: "meta" + hooks: + - id: "check-hooks-apply" + - id: "check-useless-excludes" + + - repo: "https://github.com/pre-commit/pre-commit-hooks" + rev: "v4.5.0" + hooks: + - id: "trailing-whitespace" + - id: "end-of-file-fixer" + - id: "check-yaml" + - id: "check-added-large-files" + + - repo: "https://github.com/psf/black-pre-commit-mirror" + rev: "23.9.1" + hooks: + - id: "black" + language_version: "python3.8" + + - repo: "https://github.com/pycqa/isort" + rev: "5.12.0" + hooks: + - id: "isort" + + - repo: "https://github.com/pycqa/flake8" + rev: "6.1.0" + hooks: + - id: "flake8" + + - repo: "https://github.com/editorconfig-checker/editorconfig-checker.python" + rev: "2.7.3" + hooks: + - id: "editorconfig-checker" + # The README contains YAML syntax that is indented with 2 spaces. + # The .editorconfig file will continue to require 4 spaces, + # and this pre-commit hook will ignore the README. + exclude: "README.rst" + + - repo: "local" + hooks: + - id: "sync-identify-code" + name: "Synchronize identify.py source code into 'detector.sh'" + language: "python" + entry: "python src/detect_pythons/sync_identify_code.py" + files: "^src/detect_pythons/identify.py$" + + - repo: "https://github.com/shellcheck-py/shellcheck-py" + rev: "v0.9.0.6" + hooks: + - id: "shellcheck" + args: + - "--shell=bash" + + - repo: "local" + hooks: + - id: "sync-detector-code" + name: "Synchronize detector source code into 'action.yml'" + language: "python" + entry: "python src/detect_pythons/sync_detector_code.py" + files: "^src/detect_pythons/detector.*$" + - id: "sync-readme-example" + name: "Synchronize a functional example into the README" + language: "python" + entry: "python src/detect_pythons/sync_readme_example.py" + always_run: true + pass_filenames: false + + - repo: "https://github.com/python-jsonschema/check-jsonschema" + rev: "0.27.0" + hooks: + - id: "check-github-workflows" + - id: "check-dependabot" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..a8c5822 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright 2023 Kurt McKee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8cc758c --- /dev/null +++ b/README.rst @@ -0,0 +1,170 @@ +Detect Python interpreters +########################## + +*Robust cache-busting based on Python implementations, versions, and architectures.* + +---- + +Purpose +======= + +If you're caching Python virtual environments, or tox environments, +or even build artifacts that depend on a particular Python version, +you need robust cache busting to ensure that your caches are invalidated +when a new Python version is released. + +``detect-pythons`` provides that much-needed cache busting. + +It detects all Python executables in every directory on the ``$PATH`` +and identifies: + +* The Python interpreter (CPython, PyPy, or GraalPy) +* The Python version (like "3.12.0") + or the Python ABI version (like "3.12") +* The target architecture (like "x64") + +In most cases, the path to the executable already contains this information +so it's not necessary to run the Python interpreter to extract any info. +There are some exceptions, however; +these are covered in the "Implementation" section below. + + +Usage +===== + +The following example demonstrates how ``detect-pythons`` can be used +when caching a Python virtual environment stored in ``.venv/`` +and tox test environments stored in ``.tox/``. + + +.. START_EXAMPLE_YAML_BLOCK +.. code-block:: yaml + + - uses: "actions/setup-python@v4" + with: + python-version: | + pypy3.10 + 3.11 + + - uses: "kurtmckee/detect-pythons@main" + + - uses: "actions/cache@v3" + id: "restore-cache" + with: + # You may need to augment the list of files to hash. + # For example, you might add 'requirements/*.txt' or 'pyproject.toml'. + key: "${{ hashFiles('.python-identifiers') }}" + path: | + .tox/ + .venv/ + + - name: "Identify .venv path" + shell: "bash" + run: "echo 'venv-path=.venv/${{ runner.os == 'Windows' && 'Scripts' || 'bin' }}' >> $GITHUB_ENV" + + - name: "Create a virtual environment" + if: "steps.restore-cache.outputs.cache-hit == false" + run: | + python -m venv .venv + ${{ env.venv-path }}/python -m pip install --upgrade pip setuptools wheel + + # You may need to customize what gets installed next. + # However, tox is able to run test suites against multiple Pythons, + # so it's a helpful tool for efficient testing. + ${{ env.venv-path }}/pip install tox + + - name: "Run the test suite against all installed Pythons" + run: "${{ env.venv-path }}/tox" +.. END_EXAMPLE_YAML_BLOCK + + +Inputs +====== + +By default, the action writes to a file named ``.python-identifiers``, +which can then be passed to GitHub's ``hashFiles`` function +for convenient cache-busting. + +You can customize the path and filename +by modifying the input variable ``identifiers-filename``: + +.. code-block:: yaml + + - uses: "kurtmckee/detect-pythons@main" + with: + identifiers-filename: "favored_filename.txt" + +To prevent writing a file at all, +set ``identifiers-filename`` to an empty string: + +.. code-block:: yaml + + - uses: "kurtmckee/detect-pythons@main" + with: + identifiers-filename: "" + + +Outputs +======= + +In addition to writing to a file, +the action creates an output named ``python-identifiers``. +This may be useful in other contexts. + + +Implementation +============== + +The action tries to find all Python interpreters available on the ``$PATH`` +and ensure that critical information about each interpreter is included +in the action output: + +* Implementation +* Version +* Architecture + + +Cached Python interpreters +-------------------------- + +GitHub runners have common CPython and PyPy versions pre-installed. +These are installed under ``$RUNNER_TOOL_CACHE`` in informative directory paths, +so the paths are used without executing the interpreters. + +.. csv-table:: + :header: "Platform", "Sample path under ``$RUNNER_TOOL_CACHE``" + + "Linux", "``/opt/hostedtoolcache/Python/3.11.6/x64/bin``" + "macOS", "``/Users/runner/hostedtoolcache/PyPy/3.10.13/x64/bin``" + "Windows", "``C:\hostedtoolcache\windows\Python\3.11.6\x64``" + + +System CPython interpreters +--------------------------- + +GitHub's Linux and macOS runners have system CPython interpreters installed. +These are available at paths like ``/usr/bin/python``, +which contains no useful information. + +For these interpreters, the interpreter is executed +and the value of ``sysconfig.get_config_var("EXT_SUFFIX")`` is extracted. +This results in a value like the following: + +.. csv-table:: + :header: "Platform", "Sample ``EXT_SUFFIX`` value" + + "Linux", "``.cpython-310-x86_64-linux-gnu.so``" + "macOS", "``.cpython-311-darwin.so``" + + +...other +-------- + +At the time of writing, GitHub's current macOS runner has CPython 2.7 pre-installed +and available on the ``$PATH``. +It doesn't have an ``EXT_SUFFIX`` config value, so this action constructs one. + +.. csv-table:: + :header: "Platform", "Constructed ``EXT_SUFFIX`` equivalent" + + "macOS", "``.cpython-27-darwin``" diff --git a/TODO.rst b/TODO.rst new file mode 100644 index 0000000..72652c8 --- /dev/null +++ b/TODO.rst @@ -0,0 +1,5 @@ +* Expand README +* Create ``releases`` branch for tagging and such +* Add pre-commit hook to check README against latest tagged SHA +* Add test suite that can run locally +* Add test suite that verifies expected outputs in CI diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b725ed0 --- /dev/null +++ b/action.yml @@ -0,0 +1,172 @@ +author: "Kurt McKee" +name: "Detect installed Python interpreters" +description: | + Detect installed Python interpreters and output them as "python-identifiers". + This can be useful for cache busting of tox, virtual environments, + and other items that are sensitive to changes in Python implementations and versions. + + Only Python interpreters named "python" in the $PATH will be found. + +inputs: + identifiers-filename: + description: | + A filename to write the ``python-identifiers`` output to. + Suitable for use in with the ``hashFiles`` function. + + To prevent any file from being written, use a blank string. + required: false + default: ".python-identifiers" + +outputs: + python-identifiers: + description: | + A string of sorted Python identifiers. + + In most cases the identifiers will be paths, + but for system Pythons, 'sysconfig.get_config_var("EXT_SUFFIX")' will be included. + + The string will be separated by OS-specific PATH separators; + ":" is used on Linux and macOS, and ";" is used on Windows. + value: "${{ steps.final-step.outputs.python-identifiers }}" + +runs: + using: "composite" + steps: + - name: "Detect Pythons on Linux / macOS" + id: "linux" + if: "runner.os != 'windows'" + shell: "bash" + # Do not edit the 'run' code below. + # It is copied from 'detector.sh' by a pre-commit hook. + # START: detector.sh + run: | + true > /dev/null # No-op to prevent YAML syntax errors. + # Do not modify the Python code below. + # It is copied from 'identify.py' by a pre-commit hook. + python_code=$( + cat <<'identify.py_SOURCE_CODE' + from __future__ import print_function + + import sysconfig + + + def main(): + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + if ext_suffix is not None: + print(ext_suffix) + else: + # Python 2.7 on GitHub macOS runners + import platform + + print( + "." + platform.python_implementation().lower(), + sysconfig.get_config_var("py_version_nodot"), + sysconfig.get_config_var("MACHDEP"), + sep="-", + ) + + + if __name__ == "__main__": + main() + identify.py_SOURCE_CODE + ) + + # Search paths in $PATH for Python interpreters. + IFS=: read -r -a all_paths <<< "$PATH" + + # Detect Python interpreters. + paths=() + for path in "${all_paths[@]}"; do + # Interpreters in RUNNER_TOOL_CACHE have directory names that include: + # + # * The implementation (like "Python" or "PyPy") + # * A version (like "3.10.12") + # * The architecture (like "x64") + # + # In such cases, the path can be used as the identifier. + if [[ "${path/#${RUNNER_TOOL_CACHE}/}" != "${path}" ]]; then + # Check for bin/python first; + # this results in duplicate paths which are later removed. + if [[ -x "${path}/bin/python" ]]; then + paths+=("${path}/bin") + elif [[ -x "${path}/python" ]]; then + paths+=("${path}") + fi + else + # System Pythons (e.g. /usr/bin/python) have nothing unique in the path. + # In such cases, it's necessary to run the executable to get something unique. + if [[ -x "${path}/python" ]]; then + paths+=("${path}") + paths+=("$(echo "${python_code}" | "${path}/python" -)") + fi + fi + done + + # Sort the paths, ensure each path is unique, and create the output result. + result="$( + echo "${paths[*]}" \ + | tr ' ' '\n' \ + | sort \ + | uniq \ + | tr '\n' ':' + )" + + # Trim trailing colons. + result="${result%:}" + + # Output path information. + # This must be the final line because it will be automatically transformed to: + # + # echo "python-identifiers=${result}" > "$GITHUB_OUTPUT" + # + echo "python-identifiers=${result}" > "$GITHUB_OUTPUT" + # END: detector.sh + + - name: "Detect Pythons on Windows" + id: "windows" + if: "runner.os == 'windows'" + shell: "powershell" + # Do not edit the 'run' code below. + # It is copied from 'detector.ps1' by a pre-commit hook. + # START: detector.ps1 + run: | + $true | Out-Null # No-op to prevent YAML syntax errors. + # Search paths in $PATH for Python interpreters. + $all_paths = $env:PATH -split ";" + + # Detect Python interpreters. + $paths = @() + foreach ($path in $all_paths) { + # Only consider paths in RUNNER_TOOL_CACHE. + if ($path.StartsWith($env:RUNNER_TOOL_CACHE)) { + if (Test-Path "$path\python.exe") { + $paths += $path + } + } + } + + # Sort the paths, ensure each path is unique, and create the output result. + $result = ( + $paths ` + | Sort-Object ` + | Get-Unique + ) -join ";" + + # Output path information. + # This must be the final line because it will be automatically transformed to: + # + # Write-Output "python-identifiers=$result" > "$env:GITHUB_OUTPUT" + # + Write-Output "python-identifiers=$result" > "$env:GITHUB_OUTPUT" + # END: detector.ps1 + + - name: "Output" + id: "final-step" + shell: "bash" + run: | + COMBINED='${{ steps.linux.outputs.python-identifiers }}${{ steps.windows.outputs.python-identifiers }}' + echo "python-identifiers=${COMBINED}" > "$GITHUB_OUTPUT" + if [ ! -z '${{ inputs.identifiers-filename }}' ]; then + echo "Writing Python identifiers to '${{ inputs.identifiers-filename }}'" + echo "${COMBINED}" > '${{ inputs.identifiers-filename }}' + fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..885838a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "detect_pythons" +version = "1.0.0" +description = "A GitHub action to detect installed Pythons. Suitable for cache-busting." +authors = ["Kurt McKee "] +license = "MIT" +readme = "README.rst" + + +[tool.poetry.scripts] +identify = "detect_pythons.identify:main" +sync-detector-code = "detect_pythons.sync_detector_code:main" +sync-identify-code = "detect_pythons.sync_identify_code:main" + + +[tool.poetry.dependencies] +python = ">=3.8" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.isort] +profile = "black" diff --git a/src/detect_pythons/__init__.py b/src/detect_pythons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/detect_pythons/detector.ps1 b/src/detect_pythons/detector.ps1 new file mode 100644 index 0000000..d6ee438 --- /dev/null +++ b/src/detect_pythons/detector.ps1 @@ -0,0 +1,27 @@ +# Search paths in $PATH for Python interpreters. +$all_paths = $env:PATH -split ";" + +# Detect Python interpreters. +$paths = @() +foreach ($path in $all_paths) { + # Only consider paths in RUNNER_TOOL_CACHE. + if ($path.StartsWith($env:RUNNER_TOOL_CACHE)) { + if (Test-Path "$path\python.exe") { + $paths += $path + } + } +} + +# Sort the paths, ensure each path is unique, and create the output result. +$result = ( + $paths ` + | Sort-Object ` + | Get-Unique +) -join ";" + +# Output path information. +# This must be the final line because it will be automatically transformed to: +# +# Write-Output "python-identifiers=$result" > "$env:GITHUB_OUTPUT" +# +Write-Output "python-identifiers=$result" diff --git a/src/detect_pythons/detector.sh b/src/detect_pythons/detector.sh new file mode 100644 index 0000000..243ac26 --- /dev/null +++ b/src/detect_pythons/detector.sh @@ -0,0 +1,79 @@ +# Do not modify the Python code below. +# It is copied from 'identify.py' by a pre-commit hook. +python_code=$( +cat <<'identify.py_SOURCE_CODE' +from __future__ import print_function + +import sysconfig + + +def main(): + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + if ext_suffix is not None: + print(ext_suffix) + else: + # Python 2.7 on GitHub macOS runners + import platform + + print( + "." + platform.python_implementation().lower(), + sysconfig.get_config_var("py_version_nodot"), + sysconfig.get_config_var("MACHDEP"), + sep="-", + ) + + +if __name__ == "__main__": + main() +identify.py_SOURCE_CODE +) + +# Search paths in $PATH for Python interpreters. +IFS=: read -r -a all_paths <<< "$PATH" + +# Detect Python interpreters. +paths=() +for path in "${all_paths[@]}"; do + # Interpreters in RUNNER_TOOL_CACHE have directory names that include: + # + # * The implementation (like "Python" or "PyPy") + # * A version (like "3.10.12") + # * The architecture (like "x64") + # + # In such cases, the path can be used as the identifier. + if [[ "${path/#${RUNNER_TOOL_CACHE}/}" != "${path}" ]]; then + # Check for bin/python first; + # this results in duplicate paths which are later removed. + if [[ -x "${path}/bin/python" ]]; then + paths+=("${path}/bin") + elif [[ -x "${path}/python" ]]; then + paths+=("${path}") + fi + else + # System Pythons (e.g. /usr/bin/python) have nothing unique in the path. + # In such cases, it's necessary to run the executable to get something unique. + if [[ -x "${path}/python" ]]; then + paths+=("${path}") + paths+=("$(echo "${python_code}" | "${path}/python" -)") + fi + fi +done + +# Sort the paths, ensure each path is unique, and create the output result. +result="$( + echo "${paths[*]}" \ + | tr ' ' '\n' \ + | sort \ + | uniq \ + | tr '\n' ':' +)" + +# Trim trailing colons. +result="${result%:}" + +# Output path information. +# This must be the final line because it will be automatically transformed to: +# +# echo "python-identifiers=${result}" > "$GITHUB_OUTPUT" +# +echo "python-identifiers=${result}" diff --git a/src/detect_pythons/identify.py b/src/detect_pythons/identify.py new file mode 100644 index 0000000..ec0541d --- /dev/null +++ b/src/detect_pythons/identify.py @@ -0,0 +1,23 @@ +from __future__ import print_function + +import sysconfig + + +def main(): + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + if ext_suffix is not None: + print(ext_suffix) + else: + # Python 2.7 on GitHub macOS runners + import platform + + print( + "." + platform.python_implementation().lower(), + sysconfig.get_config_var("py_version_nodot"), + sysconfig.get_config_var("MACHDEP"), + sep="-", + ) + + +if __name__ == "__main__": + main() diff --git a/src/detect_pythons/sync_detector_code.py b/src/detect_pythons/sync_detector_code.py new file mode 100644 index 0000000..bd49107 --- /dev/null +++ b/src/detect_pythons/sync_detector_code.py @@ -0,0 +1,67 @@ +import pathlib +import sys +import textwrap + +FILES_NOT_MODIFIED = 0 +FILES_MODIFIED = 1 + +DETECTOR_SH = (pathlib.Path(__file__).parent / "detector.sh").resolve() +DETECTOR_PS1 = (pathlib.Path(__file__).parent / "detector.ps1").resolve() +ACTION_YML = (pathlib.Path(__file__).parent / "../../action.yml").resolve() + + +def sync(path: pathlib.Path) -> int: + # Verify the paths are acceptable. + if path != DETECTOR_SH and path != DETECTOR_PS1: + print(f"{path} is not an acceptable file path.") + return FILES_NOT_MODIFIED + + code = path.read_text().strip() + + # Prevent YAML syntax errors caused by shared comment syntax. + if code.lstrip().startswith("#"): + if path == DETECTOR_SH: + code = "true > /dev/null # No-op to prevent YAML syntax errors.\n" + code + else: # path == DETECTOR_PS1 + code = "$true | Out-Null # No-op to prevent YAML syntax errors.\n" + code + + # Redirect outputs to GITHUB_OUTPUT. + if path == DETECTOR_SH: + code += ' > "$GITHUB_OUTPUT"' + else: # path == DETECTOR_PS1 + code += ' > "$env:GITHUB_OUTPUT"' + + # Determine the text boundaries of the source code in 'action.yml'. + yaml = ACTION_YML.read_text() + start_line = f"# START: {path.name}\n" + end_line = f"# END: {path.name}" + start = yaml.find(start_line) + len(start_line) + end = yaml.rfind("\n", start, yaml.find(end_line, start)) + indent = yaml.find("run:", start) - start + + # Prepare the code for injection. + block = textwrap.indent("run: |\n" + textwrap.indent(code, " " * 2), " " * indent) + + # Inject the source code and determine if 'action.yml' needs to be overwritten. + new_yaml = yaml[:start] + block + yaml[end:] + if new_yaml != yaml: + ACTION_YML.write_text(new_yaml, newline="\n") + return FILES_MODIFIED + + return FILES_NOT_MODIFIED + + +def main(): + if len(sys.argv) < 2: + print("The path to 'detector.sh' or 'detector.ps1' must be provided.") + sys.exit(FILES_NOT_MODIFIED) + + rc = FILES_NOT_MODIFIED + for argument in sys.argv[1:]: + rc |= sync(pathlib.Path(argument).resolve()) + + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src/detect_pythons/sync_identify_code.py b/src/detect_pythons/sync_identify_code.py new file mode 100644 index 0000000..5b8ee82 --- /dev/null +++ b/src/detect_pythons/sync_identify_code.py @@ -0,0 +1,44 @@ +import pathlib +import sys + +FILES_NOT_MODIFIED = 0 +FILES_MODIFIED = 1 + +IDENTIFY_PY = (pathlib.Path(__file__).parent / "identify.py").resolve() +DETECTOR_SH = (pathlib.Path(__file__).parent / "detector.sh").resolve() + + +def sync(path: pathlib.Path) -> int: + # Verify the paths are acceptable. + if path != IDENTIFY_PY: + print(f"{path} is not an acceptable file path.") + return FILES_NOT_MODIFIED + + code = path.read_text().strip() + + # Determine the text boundaries of the source code in 'action.yml'. + sh = DETECTOR_SH.read_text() + tag = f"{IDENTIFY_PY.name}_SOURCE_CODE" + start = sh.find("\n", sh.find(tag)) + 1 + end = sh.rfind("\n", start, sh.find(tag, start)) + + # Inject the source code and determine if 'action.yml' needs to be overwritten. + new_sh = sh[:start] + code + sh[end:] + if new_sh != sh: + DETECTOR_SH.write_text(new_sh, newline="\n") + return FILES_MODIFIED + + return FILES_NOT_MODIFIED + + +def main(): + if len(sys.argv) < 2: + print(f"The path to a modified '{IDENTIFY_PY.name}' must be provided.") + sys.exit(FILES_NOT_MODIFIED) + + rc = sync(pathlib.Path(sys.argv[-1]).resolve()) + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src/detect_pythons/sync_readme_example.py b/src/detect_pythons/sync_readme_example.py new file mode 100644 index 0000000..d3e558c --- /dev/null +++ b/src/detect_pythons/sync_readme_example.py @@ -0,0 +1,46 @@ +import pathlib +import sys +import textwrap + +FILES_NOT_MODIFIED = False +FILES_MODIFIED = True + +ROOT = pathlib.Path(__file__).parent.parent.parent +EXAMPLE_YAML = (ROOT / ".github/workflows/readme_example.yaml").resolve() +README_RST = (ROOT / "README.rst").resolve() + + +def sync() -> bool: + # Extract the YAML example to embed in the README. + yaml = EXAMPLE_YAML.read_text(encoding="utf-8") + start_line = "# START_README_EXAMPLE_BLOCK\n" + end_line = "# END_README_EXAMPLE_BLOCK" + start = yaml.find(start_line) + len(start_line) + end = yaml.rfind("\n", start, yaml.find(end_line, start)) + example = textwrap.dedent(yaml[start:end]) + + # Prepare the example for injection. + block = ".. code-block:: yaml\n\n" + textwrap.indent(example, " " * 4) + + # Determine the text boundaries of the source code in the README. + rst = README_RST.read_text() + start_line = ".. START_EXAMPLE_YAML_BLOCK\n" + end_line = ".. END_EXAMPLE_YAML_BLOCK" + start = rst.find(start_line) + len(start_line) + end = rst.rfind("\n", start, rst.find(end_line, start)) + + # Inject the example and determine if the README needs to be overwritten. + new_rst = rst[:start] + block + rst[end:] + if new_rst != rst: + README_RST.write_text(new_rst, newline="\n") + return FILES_MODIFIED + + return FILES_NOT_MODIFIED + + +def main(): + sys.exit(sync()) + + +if __name__ == "__main__": + main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8dd399a --- /dev/null +++ b/tox.ini @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203