diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5b83611 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: Offline formatter tests and package + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Offline tests (${{ matrix.os }}, Python 3.13) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: "0.13.4" + enable-cache: false + - name: Run complete offline unit suite + run: uv run --isolated --no-project --python 3.13 --with attrs==26.1.0 python -m unittest discover -v + - name: Build wheel and source distribution + run: | + uv build + test -s dist/installer_builder2-0.1.8-py3-none-any.whl + test -s dist/installer_builder2-0.1.8.tar.gz diff --git a/README.md b/README.md index da6f264..87d3aa4 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ builder.build() For more detailed instructions and configuration options, please refer to the documentation provided within the package. +The offline unit suite exercises data-file formatting and captures the exact +Nuitka command without starting a compiler, installer, signer or release: + +```bash +uv run --isolated --no-project --python 3.13 --with attrs==26.1.0 python -m unittest discover -v +uv build +``` + +CI runs all seven tests and verifies the wheel/source distribution on Windows +and macOS with Python 3.13. This checks path and command construction; it does +not validate a frozen application or claim coverage of the entire historical +Python support range. + +Use `data_directories=["assets/"]` to include a directory recursively through +Nuitka's `--include-data-dir=source=target` option. Existing explicit +`"source=target"` mappings are preserved. +`enable_deployment=True` adds Nuitka's `--deployment` flag, which disables +its non-deployment runtime helpers; it remains disabled by default. + Contributing ------------ Contributions to Installer Builder 2 are welcome! Please feel free to submit pull requests or create issues for bugs and feature requests. diff --git a/agent-notes/repair-task6-installer-builder2-nuitka-ci.md b/agent-notes/repair-task6-installer-builder2-nuitka-ci.md new file mode 100644 index 0000000..1613d85 --- /dev/null +++ b/agent-notes/repair-task6-installer-builder2-nuitka-ci.md @@ -0,0 +1,35 @@ +# Installer Builder 2 PR #3: offline Nuitka argument coverage + +## Goal + +Repair the feature's stale directory assertion and wire safe full unit/package checks. Preserve formatter and deployment behavior; no real freezer/compiler/Inno Setup/DMG/signer/release invocation. + +## State + +- Current follow-up: coordinator published reviewed55509bf after Q's exact-target approval; run39530883400 attempt1 executed all seven tests successfully on Windows/macOS3.13, then both package builds failed because the newly relocated top-level test module triggered setuptools' multiple-module flat-layout rejection. +- I placed the relocated test at repository root without declaring the existing production module. The build logs name both installer_builder2 and test_installer_builder2; master lacks the new test module. This is my feature-amendment packaging defect, not a baseline failure. Fix the cause with explicit [tool.setuptools] py-modules=["installer_builder2"], preserving all seven discovered tests and assertions. No runtime/dependency/support change. +- Published55509bf is archived as backup/task6-installer-builder2-before-packaging-repair and remains unchanged. This correction is local pending complete root diff review and exact fast-forward publication authority. +- The following original preparation state is historical. + +- Same-org origin git@github.com:accessibleapps/installer_builder2.git. Fresh master ef093977cb30ab4dba7be03fbe108de8563f0054, published PR #3 new-nuitka head 6f48f0658170207857349fd655d848d312144f89. Original preserved at backup/task6-installer-builder2-3-published-before-repair; no original local feature branch existed. Canonical master clean and untouched. +- New isolated worktree installer-builder2-nuitka-ci / repair/task6-installer-builder2-nuitka-ci directly derives from that PR. Source feature is the full original 119-added/9-deleted diff; default lacks formatter/tests/deployment flag, so this assertion defect belongs to feature, not a fake baseline prerequisite. +- Actions enabled; no hosted run exists for proposed local head. No publication, model call, settings, hook, release or old-description changes. + +## Decisions and why + +- Original five unittest methods lived inside production module and were not discoverable by normal test discovery. Move all five intact into test_installer_builder2.py, correcting only the directory expectation. Real compile_distribution sends data_directories via --include-data-dir; Nuitka's primary manual documents recursive source=target, not the test's extra =**/* (that is a separate --include-data-files form). +- Add two exact mocked-subprocess tests: builder forwards files/patterns/explicit mapping/directory/deployment; default run leaves deployment off. No test can call the real subprocess boundary in these cases. Preserve production formatter/argv behavior. +- CI runs all seven tests on Windows/macOS Python 3.13 because these are the README's actual target platforms and os.path is exercised. Only attrs is imported by offline source; install pinned attrs26.1.0 in isolated --no-project environment. Build actual setuptools wheel/sdist without resolving/invoking application toolchain. +- pyproject has no requires-python, README says >=3.6, while current downstream innosetup_builder declares >=3.8.6. This task does not invent/drop support metadata or claim historical-floor graph coverage. Real frozen-app/toolchain validation remains outside safe test scope. +- Ordinary PR/master events, read-only contents, verified pinned actions, no persisted credentials/cache, bounded timeout/concurrency; no release workflow. +- Primary sources: https://nuitka.net/user-documentation/user-manual.html#data-files and https://nuitka.net/user-documentation/common-issue-solutions.html#deployment-mode. Deployment disables runtime helpers; no compatibility benefit is inferred. + +## Verification + +- Before assertion correction, actively authored relocated test file on CPython3.13 / attrs26.1.0: 5 tests ran, 4 passed, one failed at directory expected =testdir/=**/* versus actual =testdir/. No compiler/service calls. This is actual red, not an execution of full CI. +- After assertion correction and exact command/deployment regressions, the same actively authored file: 7 tests passed in 0.002s on CPython 3.13 with attrs26.1.0. The real subprocess boundary was mocked. Full hosted unit/build gate pending; no local full CI/build. +- actionlint1.7.12 (optional shellcheck/pyflakes disabled) and zizmor1.31.1 --offline --pedantic passed, exit0, no findings. Manifest unchanged. + +## Next step + +Return the complete two-line setuptools declaration plus note diff and local head before publication. Keep all existing tests/workflow assertions unchanged. No local full CI/build; a later authorized normal hosted run must prove the repaired package build before actual final-head Claude/merge gates. diff --git a/installer_builder2.py b/installer_builder2.py index 5305497..93684d0 100644 --- a/installer_builder2.py +++ b/installer_builder2.py @@ -4,6 +4,7 @@ import subprocess import sys import zipfile +from typing import Union from attr import Factory, define, field @@ -19,6 +20,7 @@ class InstallerBuilder: author: str = field(default='', converter=str) run_at_startup: bool = field(default=False) console: bool = field(default=False) # For compiling your app in console/command line mode + enable_deployment: bool = field(default=False) # Enable deployment mode for better compatibility url: str = field(default='', converter=str) company_name: str = field(default='') include_modules: list = field(default=Factory(list), converter=list) @@ -33,7 +35,9 @@ class InstallerBuilder: def compile_distribution(self): run_nuitka(self.main_module, self.dist_path, app_name=self.app_name, app_version=self.version, company_name=self.company_name, - include_modules=self.include_modules, include_data_files=self.data_files, include_data_dirs=self.data_directories, packages_to_include=self.include_packages, data_file_packages=self.data_file_packages, ignore_imports=self.ignore_imports, console=self.console) + include_modules=self.include_modules, include_data_files=self.data_files, include_data_dirs=self.data_directories, + packages_to_include=self.include_packages, data_file_packages=self.data_file_packages, ignore_imports=self.ignore_imports, + console=self.console, enable_deployment=self.enable_deployment) def create_installer(self): import innosetup_builder @@ -103,7 +107,9 @@ def build(self): self.create_update_zip() -def run_nuitka(main_module, output_path=pathlib.Path('dist'), include_modules=None, packages_to_include=None, console=False, onefile=False, include_data_files=None, include_data_dirs=None, app_name="", company_name="", app_version="", numpy=False, data_file_packages=None, ignore_imports=None): +def run_nuitka(main_module, output_path=pathlib.Path('dist'), include_modules=None, packages_to_include=None, console=False, onefile=False, + include_data_files=None, include_data_dirs=None, app_name="", company_name="", app_version="", numpy=False, + data_file_packages=None, ignore_imports=None, enable_deployment=False): if include_modules is None: include_modules = [] include_modules = ['--include-module=' + @@ -130,6 +136,8 @@ def run_nuitka(main_module, output_path=pathlib.Path('dist'), include_modules=No ignore for ignore in ignore_imports] extra_options = ['--assume-yes-for-downloads', '--output-dir=' + str(output_path)] + if enable_deployment: + extra_options.append('--deployment') if onefile: extra_options.append('--onefile') if not console: @@ -150,15 +158,43 @@ def run_nuitka(main_module, output_path=pathlib.Path('dist'), include_modules=No extra_options.append('--macos-app-version=' + app_version) if numpy: extra_options.append('--enable-plugin=numpy') - command = [sys .executable, '-m', 'nuitka', '--standalone', *include_modules, + command = [sys.executable, '-m', 'nuitka', '--standalone', *include_modules, *include_packages, *include_data_files, *include_data_dirs, *data_file_packages, *ignore_imports, *extra_options, main_module] subprocess.check_call(command) +def format_data_file(item: Union[str, pathlib.Path]) -> str: + """Format a single data file/directory for Nuitka inclusion. + + Args: + item: Path to file or directory, or existing formatted string + + Returns: + Properly formatted Nuitka data file string + """ + item = str(item) + + # Already formatted + if '=' in item: + return item + + abs_path = os.path.abspath(item) + + # Pattern matching (e.g., *.txt) + if '*' in item: + dir_path = os.path.dirname(abs_path) + pattern = os.path.basename(item) + target_dir = os.path.dirname(item) + return f"{dir_path}/{pattern}={target_dir}/" + + # Directory + if item.endswith('/') or (os.path.exists(item) and os.path.isdir(item)): + # For directories, Nuitka expects: source_dir=target_dir + return f"{abs_path}={item}" + + # Single file + return f"{abs_path}={os.path.basename(item)}" + def _format_nuitka_datafiles(items): - # datafiles look like path/to/file=path/to/target and we will only get the part before the = - # copy the tree to the corresponding path - for item in items: - if '=-' in item: - yield item - yield item + '=' + item + """Convert a list of data file specifications into Nuitka format.""" + return [format_data_file(item) for item in items] diff --git a/pyproject.toml b/pyproject.toml index 7f71c73..eeb5282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,4 +14,7 @@ dependencies = [ 'innosetup_builder', 'nuitka', 'ordered_set', # For faster Nuitka compiles -] \ No newline at end of file +] + +[tool.setuptools] +py-modules = ["installer_builder2"] diff --git a/test_installer_builder2.py b/test_installer_builder2.py new file mode 100644 index 0000000..65a5204 --- /dev/null +++ b/test_installer_builder2.py @@ -0,0 +1,113 @@ +"""Offline tests: never invoke Nuitka, Inno Setup, signing or release tools.""" + +import os +import platform +import sys +from unittest import TestCase +from unittest.mock import patch + +from installer_builder2 import InstallerBuilder, _format_nuitka_datafiles, format_data_file, run_nuitka + +class TestDataFileFormatter(TestCase): + def test_format_data_file(self): + # Test single file + self.assertEqual( + format_data_file("file.txt"), + f"{os.path.abspath('file.txt')}=file.txt" + ) + + # Test directory + self.assertEqual( + format_data_file("testdir/"), + f"{os.path.abspath('testdir')}=testdir/" + ) + + # Test pattern + self.assertEqual( + format_data_file("dir/*.txt"), + f"{os.path.abspath('dir')}/*.txt=dir/" + ) + + # Test pre-formatted string + self.assertEqual( + format_data_file("source=target"), + "source=target" + ) + + def test_deployment_flag(self): + # Create builder with deployment enabled + builder = InstallerBuilder("TestApp", enable_deployment=True) + self.assertTrue(builder.enable_deployment) + + # Create builder with deployment disabled (default) + builder = InstallerBuilder("TestApp") + self.assertFalse(builder.enable_deployment) + + def test_installer_builder_config(self): + + builder = InstallerBuilder( + "TestApp", + version="1.2.3", + company_name="Test Company", + console=True + ) + + self.assertEqual(builder.app_name, "TestApp") + self.assertEqual(builder.version, "1.2.3") + self.assertEqual(builder.company_name, "Test Company") + self.assertTrue(builder.console) + + def test_data_file_handling(self): + + builder = InstallerBuilder("TestApp") + builder.data_files = ["test.txt", "data/*.dat"] + builder.data_directories = ["assets/"] + + # Test that data files are properly formatted + formatted_files = _format_nuitka_datafiles(builder.data_files) + self.assertTrue(any("test.txt" in f for f in formatted_files)) + self.assertTrue(any("*.dat" in f for f in formatted_files)) + + def test_platform_specific(self): + + builder = InstallerBuilder("TestApp") + + if platform.system() == "Windows": + # Test Windows-specific paths + self.assertTrue(str(builder.dist_path).endswith('dist')) + elif platform.system() == "Darwin": + # Test macOS-specific paths + self.assertTrue(str(builder.dist_path).endswith('dist')) + + def test_compile_distribution_passes_data_and_deployment_to_nuitka(self): + builder = InstallerBuilder( + "TestApp", + main_module="main.py", + console=True, + enable_deployment=True, + data_files=["test.txt", "data/*.dat", "source=target"], + data_directories=["assets/"], + ) + with patch("installer_builder2.subprocess.check_call") as execute: + builder.compile_distribution() + + execute.assert_called_once_with([ + sys.executable, "-m", "nuitka", "--standalone", + "--include-data-files=" + os.path.abspath("test.txt") + "=test.txt", + "--include-data-files=" + os.path.abspath("data") + "/*.dat=data/", + "--include-data-files=source=target", + "--include-data-dir=" + os.path.abspath("assets") + "=assets/", + "--assume-yes-for-downloads", "--output-dir=dist", "--deployment", + '--windows-product-name="TestApp"', "--macos-app-name=TestApp", + "--windows-product-version=0.0.0.0", "--macos-app-version=0.0", + "main.py", + ]) + + def test_default_run_keeps_deployment_disabled(self): + with patch("installer_builder2.subprocess.check_call") as execute: + run_nuitka("main.py", console=True) + + execute.assert_called_once_with([ + sys.executable, "-m", "nuitka", "--standalone", + "--assume-yes-for-downloads", "--output-dir=dist", "main.py", + ])