diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ca944768..949f1e11 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,9 @@ FROM mcr.microsoft.com/devcontainers/python:3.13-bullseye@sha256:e6d1214434cb015 # Install dependencies # pv is required for asciicasts RUN apt-get update && apt-get install --no-install-recommends -y \ + ccache=4.2-1 \ pv=1.6.6-1+b1 \ + patchelf=0.12-1 \ subversion=1.14.1-3+deb11u2 && \ rm -rf /var/lib/apt/lists/* @@ -20,8 +22,8 @@ ENV PYTHONUSERBASE="/home/dev/.local" COPY --chown=dev:dev . . -RUN pip install --no-cache-dir --root-user-action=ignore --upgrade pip==25.1.1 \ - && pip install --no-cache-dir --root-user-action=ignore -e .[development,docs,test,casts] \ +RUN pip install --no-cache-dir --root-user-action=ignore --upgrade pip==25.2 \ + && pip install --no-cache-dir --root-user-action=ignore -e .[development,docs,test,casts,build] \ && pre-commit install --install-hooks # Set bash as the default shell diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..07e4a520 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,108 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + + build: + strategy: + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + permissions: + contents: read + security-events: write + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 + + - name: Setup Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.13' + + - name: ccache + uses: hendrikmuhs/ccache-action@30ae3502c7f2d3200209bf2f90eccef2357896cf # v1.2 + with: + key: ${{ github.job }}-${{ matrix.platform }} + verbose: 1 + create-symlink: ${{ matrix.platform != 'windows-latest' }} + + - name: Create binary + env: + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_NOHASHDIR: true + NUITKA_CACHE_DIR_CCACHE: $HOME/.ccache + NUITKA_CCACHE_BINARY: /usr/bin/ccache + run: | + pip install .[build] + python script/build.py + + - name: Store the distribution packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: binary-distribution-${{ matrix.platform }} + path: build/dfetch-* + + test-binary: + name: test binary + needs: + - build + strategy: + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + + steps: + - name: Download the binary artifact + uses: actions/download-artifact@4a24838f3d5601fd639834081e118c2995d51e1c # v5 + with: + name: binary-distribution-${{ matrix.platform }} + path: . + + - name: Prepare binary + if: matrix.platform == 'ubuntu-latest' + run: | + binary=$(ls dfetch-*-x86_64) + ln -sf "$binary" dfetch + chmod +x dfetch + ls -la . + shell: bash + + - name: Prepare binary + if: matrix.platform == 'macos-latest' + run: | + binary=$(ls dfetch-*-osx) + ln -sf "$binary" dfetch + chmod +x dfetch + ls -la . + shell: bash + + - name: Prepare binary on Windows + if: matrix.platform == 'windows-latest' + run: | + $binary = Get-ChildItem dfetch-*.exe | Select-Object -First 1 + Copy-Item $binary -Destination dfetch.exe -Force + Get-ChildItem + shell: pwsh + + - run: ./dfetch init + - run: ./dfetch environment + - run: ./dfetch validate + - run: ./dfetch check + - run: ./dfetch update + - run: ./dfetch update + - run: ./dfetch report -t sbom diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index a197ff10..fffc8b31 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -39,7 +39,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-package-distributions path: dist/ @@ -59,14 +59,15 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v5 + uses: actions/download-artifact@4a24838f3d5601fd639834081e118c2995d51e1c # v5 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ab69e431e9c9f48a3310be0a56527c679f56e04d # v1 with: repository-url: https://test.pypi.org/legacy/ + skip-existing: true - name: Test install from TestPyPI run: | @@ -86,9 +87,9 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v5 + uses: actions/download-artifact@4a24838f3d5601fd639834081e118c2995d51e1c # v5 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ab69e431e9c9f48a3310be0a56527c679f56e04d # v1 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 635ecdde..c35d573a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Release 0.11.0 (unreleased) * Add evidence to sbom report (#788) * Let action work outside of dfetch repo (#816) * Handle SVN tags with special characters (#811) +* Don't return non-zero exit code if tool not found during environment (#701) +* Create standalone binaries for Linux, Mac & Windows (#705) Release 0.10.0 (released 2025-03-12) ==================================== diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index db905030..eebe88aa 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -344,7 +344,9 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation: raise FileNotFoundError("No manifest text available") for line_nr, line in enumerate(self.__text.splitlines(), start=1): - match = re.search(rf"^\s+-\s*name:\s*(?P{re.escape(name)})\s*$", line) + match = re.search( + rf"^\s+-\s*name:\s*(?P{re.escape(name)})\s*#?.*$", line + ) if match: return ManifestEntryLocation( diff --git a/dfetch/project/git.py b/dfetch/project/git.py index 49ed8284..b5563600 100644 --- a/dfetch/project/git.py +++ b/dfetch/project/git.py @@ -62,9 +62,14 @@ def revision_is_enough() -> bool: @staticmethod def list_tool_info() -> None: """Print out version information.""" - tool, version = get_git_version() - - VCS._log_tool(tool, version) + try: + tool, version = get_git_version() + VCS._log_tool(tool, version) + except RuntimeError as exc: + logger.debug( + f"Something went wrong trying to get the version of git: {exc}" + ) + VCS._log_tool("git", "") def _fetch_impl(self, version: Version) -> Version: """Get the revision of the remote and place it at the local path.""" diff --git a/dfetch/project/svn.py b/dfetch/project/svn.py index d81cfb5d..7b874676 100644 --- a/dfetch/project/svn.py +++ b/dfetch/project/svn.py @@ -173,7 +173,14 @@ def _list_of_tags(self) -> List[str]: @staticmethod def list_tool_info() -> None: """Print out version information.""" - result = run_on_cmdline(logger, "svn --version") + try: + result = run_on_cmdline(logger, "svn --version") + except RuntimeError as exc: + logger.debug( + f"Something went wrong trying to get the version of svn: {exc}" + ) + VCS._log_tool("svn", "") + return first_line = result.stdout.decode().split("\n")[0] tool, version = first_line.replace(",", "").split("version", maxsplit=1) diff --git a/pyproject.toml b/pyproject.toml index 5bc3adae..e388453f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,10 @@ docs = [ ] test = ['pytest==8.4.2', 'pytest-cov==7.0.0', 'behave==1.3.3'] casts = ['asciinema==2.4.0'] - +build = [ + 'nuitka==2.7.16', + "tomli; python_version < '3.11'", # Tomllib is default in 3.11, required for letting codespell read the pyproject.toml] +] [project.scripts] dfetch = "dfetch.__main__:main" @@ -167,3 +170,23 @@ standard = ["dfetch", "features"] reportMissingImports = false reportMissingModuleSource = false pythonVersion = "3.9" + +[tool.nuitka] +mode = "onefile" # Switch this between standalone and onefile as needed +# jobs = "4" # Can be used to reduce memory usage, in case of compilation issues +# Enable below for debugging +# show-progress = true +assume-yes-for-downloads = true + +include-package-data="dfetch,infer_license" +include-module="infer_license.licenses" + +# python-flag = ["-OO"] # Cannot optimize (yet) commands rely on __doc__ being present + +output-dir = "build" +output-filename-win = "dfetch-{VERSION}.exe" +output-filename-linux = "dfetch-{VERSION}-x86_64" +output-filename-macos = "dfetch-{VERSION}-osx" + +# windows-icon-from-ico = "static/favicon.ico" +# windows-company-name = "dfetch-org" diff --git a/script/build.py b/script/build.py new file mode 100644 index 00000000..2010abdc --- /dev/null +++ b/script/build.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""This script builds the dfetch executable using Nuitka.""" +import subprocess # nosec +import sys +import tomllib as toml +from typing import Union + +from dfetch import __version__ + + +def parse_option( + option_name: str, option_value: Union[bool, str, list, dict] +) -> list[str]: + """ + Convert a config value to Nuitka CLI arguments. + + Handles booleans (--flag), strings (--flag=value), lists (multiple --flag=value), + and nested dicts (--flag=key1=val1=key2=val2). + + Returns: + list[str]: Nuitka CLI arguments in the format ['--flag', '--key=value'] + """ + args = [] + cli_key = f"--{option_name.replace('_','-')}" + + if isinstance(option_value, bool): + if option_value: + args.append(cli_key) + elif isinstance(option_value, str): + args.append(f"{cli_key}={option_value}".replace("{VERSION}", __version__)) + elif isinstance(option_value, list): + for v in option_value: + if isinstance(v, dict): + parts = [f"{v[k]}" for k in v] + args.append(f"{cli_key}={'='.join(parts)}") + else: + args.append(f"{cli_key}={v}") + else: + args.append(f"{cli_key}={option_value}") + + return args + + +# Load pyproject.toml +with open("pyproject.toml", "rb") as pyproject_file: + pyproject = toml.load(pyproject_file) +nuitka_opts = pyproject.get("tool", {}).get("nuitka", {}) + + +if sys.platform.startswith("win"): + nuitka_opts["output-filename"] = nuitka_opts["output-filename-win"] +elif sys.platform.startswith("linux"): + nuitka_opts["output-filename"] = nuitka_opts["output-filename-linux"] +elif sys.platform.startswith("darwin"): + nuitka_opts["output-filename"] = nuitka_opts["output-filename-macos"] + + +nuitka_opts = { + k: v + for k, v in nuitka_opts.items() + if k + not in {"output-filename-win", "output-filename-linux", "output-filename-macos"} +} + +command = [sys.executable, "-m", "nuitka"] +for key, value in nuitka_opts.items(): + command.extend(parse_option(key, value)) + +command.append("dfetch") + +print(command) +subprocess.check_call(command) # nosec diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7f2a8093..4726fd7f 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -12,6 +12,7 @@ from dfetch.manifest.manifest import ( Manifest, ManifestDict, + ManifestEntryLocation, RequestedProjectNotFoundError, find_manifest, get_childmanifests, @@ -177,3 +178,43 @@ def test_single_suggestion_not_found() -> None: exception = RequestedProjectNotFoundError(["irst", "1234"], ["first", "other"]) assert ["first"] == exception._guess_project(["irst", "1234"]) + + +@pytest.mark.parametrize( + "name, manifest, project_name, result", + [ + ( + "match", + " - name: foo", + "foo", + ManifestEntryLocation(line_number=1, start=10, end=12), + ), + ( + "no match", + " - name: foo", + "baz", + RuntimeError, + ), + ( + "with comment", + " - name: foo # some comment", + "foo", + ManifestEntryLocation(line_number=1, start=10, end=12), + ), + ( + "no spaces", + " -name:foo #some comment", + "foo", + ManifestEntryLocation(line_number=1, start=8, end=10), + ), + ], +) +def test_get_manifest_location(name, manifest, project_name, result) -> None: + + manifest = Manifest(DICTIONARY_MANIFEST, text=manifest) + + if result == RuntimeError: + with pytest.raises(RuntimeError): + manifest.find_name_in_manifest(project_name) + else: + assert manifest.find_name_in_manifest(project_name) == result