Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions agent-notes/repair-task6-installer-builder2-nuitka-ci.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 45 additions & 9 deletions installer_builder2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import subprocess
import sys
import zipfile
from typing import Union

from attr import Factory, define, field

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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=' +
Expand All @@ -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:
Expand All @@ -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]
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ dependencies = [
'innosetup_builder',
'nuitka',
'ordered_set', # For faster Nuitka compiles
]
]

[tool.setuptools]
py-modules = ["installer_builder2"]
113 changes: 113 additions & 0 deletions test_installer_builder2.py
Original file line number Diff line number Diff line change
@@ -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",
])
Loading