diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 247cd8f2..72371be0 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -78,7 +78,7 @@ jobs: uses: PyO3/maturin-action@v1.49.1 with: target: x86_64 - args: --release --locked --out dist + args: --release --locked --out dist -i python3 - name: "Upload wheels" uses: actions/upload-artifact@v4.6.2 with: @@ -101,10 +101,10 @@ jobs: uses: PyO3/maturin-action@v1.49.1 with: target: aarch64 - args: --release --locked --out dist + args: --release --locked --out dist -i python3 - name: "Test wheel - aarch64" run: | - pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + pip install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall ${{ env.EXECUTABLE_NAME }} --help python -m ${{ env.MODULE_NAME }} --help - name: "Upload wheels" @@ -121,10 +121,7 @@ jobs: platform: - target: x86_64-pc-windows-msvc arch: x64 - - target: aarch64-pc-windows-msvc - arch: x64 - # NOTE: Disabling this target due to poor support in PyArrow and - # friends. + # NOTE: i686 disabled due to poor support in PyArrow and friends. #- target: i686-pc-windows-msvc # arch: x86 steps: @@ -140,15 +137,11 @@ jobs: uses: PyO3/maturin-action@v1.49.1 with: target: ${{ matrix.platform.target }} - args: --release --locked --out dist - env: - # aarch64 build fails, see https://github.com/PyO3/maturin/issues/2110 - XWIN_VERSION: 16 + args: --release --locked --out dist -i python3 - name: "Test wheel" - if: ${{ !startsWith(matrix.platform.target, 'aarch64') }} shell: bash run: | - python -m pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + python -m pip install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall ${{ env.EXECUTABLE_NAME }} --help python -m ${{ env.MODULE_NAME }} --help - name: "Upload wheels" @@ -157,6 +150,34 @@ jobs: name: wheels-${{ matrix.platform.target }} path: dist + windows-aarch64: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} + runs-on: windows-11-arm + steps: + - uses: actions/checkout@v4.2.2 + with: + submodules: recursive + - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - uses: actions/setup-python@v5.5.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: "Build wheels" + uses: PyO3/maturin-action@v1.49.1 + with: + target: aarch64-pc-windows-msvc + args: --release --locked --out dist -i python3 + - name: "Test wheel" + shell: bash + run: | + python -m pip install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall + ${{ env.EXECUTABLE_NAME }} --help + python -m ${{ env.MODULE_NAME }} --help + - name: "Upload wheels" + uses: actions/upload-artifact@v4.6.2 + with: + name: wheels-aarch64-pc-windows-msvc + path: dist + linux: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} runs-on: ubuntu-latest @@ -178,7 +199,7 @@ jobs: uses: PyO3/maturin-action@v1.49.1 with: manylinux: auto - args: --release --locked --out dist + args: --release --locked --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13 before-script-linux: | # If we're running on rhel centos, install needed packages. if command -v yum &> /dev/null; then @@ -197,7 +218,7 @@ jobs: - name: "Test wheel" if: ${{ startsWith(matrix.target, 'x86_64') }} run: | - pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + pip install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall ${{ env.EXECUTABLE_NAME }} --help python -m ${{ env.MODULE_NAME }} --help - name: "Upload wheels" @@ -216,7 +237,7 @@ jobs: arch: aarch64 # see https://github.com/astral-sh/ruff/issues/3791 # and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963 - maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 + maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 -e PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 steps: - uses: actions/checkout@v4.2.2 @@ -232,7 +253,7 @@ jobs: target: ${{ matrix.platform.target }} manylinux: auto docker-options: ${{ matrix.platform.maturin_docker_options }} - args: --release --locked --out dist + args: --release --locked --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13 env: CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse # Set the CFLAGS for the aarch64 target, defining the ARM architecture @@ -244,8 +265,8 @@ jobs: if: ${{ matrix.platform.arch != 'ppc64' && matrix.platform.arch != 'ppc64le'}} name: Test wheel with: - arch: ${{ matrix.platform.arch == 'arm' && 'armv6' || matrix.platform.arch }} - distro: ${{ matrix.platform.arch == 'arm' && 'bookworm' || 'ubuntu22.04' }} + arch: ${{ matrix.platform.arch }} + distro: ubuntu22.04 githubToken: ${{ github.token }} install: | apt-get update @@ -258,7 +279,7 @@ jobs: export CARGO_HOME=/tmp/cargo-home python3 -m venv /tmp/venv . /tmp/venv/bin/activate - pip3 install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + pip3 install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall ${{ env.EXECUTABLE_NAME }} --help - name: "Upload wheels" uses: actions/upload-artifact@v4.6.2 @@ -288,7 +309,7 @@ jobs: with: target: ${{ matrix.target }} manylinux: musllinux_1_2 - args: --release --locked --out dist + args: --release --locked --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13 - name: "Test wheel" if: matrix.target == 'x86_64-unknown-linux-musl' uses: addnab/docker-run-action@v3 @@ -298,7 +319,7 @@ jobs: run: | apk add python3 python3-dev py3-pip rust python -m venv .venv - .venv/bin/pip3 install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + .venv/bin/pip3 install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall .venv/bin/${{ env.EXECUTABLE_NAME }} --help - name: "Upload wheels" uses: actions/upload-artifact@v4.6.2 @@ -326,8 +347,7 @@ jobs: with: target: ${{ matrix.platform.target }} manylinux: musllinux_1_2 - args: --release --locked --out dist - docker-options: ${{ matrix.platform.maturin_docker_options }} + args: --release --locked --out dist -i python3.9 python3.10 python3.11 python3.12 python3.13 - uses: uraimo/run-on-arch-action@v2 name: Test wheel with: @@ -338,7 +358,7 @@ jobs: apk add python3 python3-dev py3-pip rust run: | python -m venv .venv - .venv/bin/pip3 install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall + .venv/bin/pip3 install ${{ env.PACKAGE_NAME }} --find-links dist/ --force-reinstall .venv/bin/${{ env.EXECUTABLE_NAME }} --help - name: "Upload wheels" uses: actions/upload-artifact@v4.6.2 diff --git a/.gitignore b/.gitignore index 02705b29..82e88e1c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /.venv *.pyc __pycache__ +*.so # may contain sensitive data diff --git a/Cargo.lock b/Cargo.lock index d0da63a3..18bc60b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,6 +1562,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inflections" version = "1.1.1" @@ -1803,6 +1812,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1870,7 +1888,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "memoffset", + "memoffset 0.6.5", ] [[package]] @@ -2239,6 +2257,69 @@ dependencies = [ "rustyline", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset 0.9.1", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.104", +] + [[package]] name = "quinn" version = "0.11.8" @@ -3218,6 +3299,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "target-lexicon" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" + [[package]] name = "tempfile" version = "3.21.0" @@ -3524,9 +3611,12 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" name = "tower" version = "0.3.48" dependencies = [ + "config", + "pyo3", "tokio", "tower-api", "tower-cmd", + "tower-package", ] [[package]] @@ -3862,6 +3952,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 01167576..cd6f4deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ http = "1.1" indicatif = "0.17" nix = { version = "0.30", features = ["signal"] } pem = "3" +pyo3 = { version = "0.24", features = ["extension-module"] } promptly = "0.3" rand = "0.8" regex = "1" diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index a55b4967..864fc34f 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -23,6 +23,7 @@ pub use error::Error; pub struct App { session: Option, cmd: Command, + args: Option>, } impl App { @@ -38,7 +39,17 @@ impl App { Session::from_config_dir().ok() }; - Self { cmd, session } + Self { + cmd, + session, + args: None, + } + } + + pub fn new_from_args(args: Vec) -> Self { + let mut app = Self::new(); + app.args = Some(args); + app } async fn check_latest_version() -> Option { @@ -52,7 +63,10 @@ impl App { pub async fn run(self) { let mut cmd_clone = self.cmd.clone(); - let matches = self.cmd.get_matches(); + let matches = match self.args { + Some(args) => self.cmd.get_matches_from(args), + None => self.cmd.get_matches(), + }; let config = Config::from_arg_matches(&matches); diff --git a/crates/tower-runtime/tests/example-apps/03-legacy-app/requirements.txt b/crates/tower-runtime/tests/example-apps/03-legacy-app/requirements.txt index 79b039ba..f2293605 100644 --- a/crates/tower-runtime/tests/example-apps/03-legacy-app/requirements.txt +++ b/crates/tower-runtime/tests/example-apps/03-legacy-app/requirements.txt @@ -1,2 +1 @@ -dlt[snowflake,filesystem]==1.4.0 -pandas +requests diff --git a/crates/tower-runtime/tests/example-apps/03-legacy-app/task.py b/crates/tower-runtime/tests/example-apps/03-legacy-app/task.py index 1531deda..bea315c5 100644 --- a/crates/tower-runtime/tests/example-apps/03-legacy-app/task.py +++ b/crates/tower-runtime/tests/example-apps/03-legacy-app/task.py @@ -1,3 +1,3 @@ -import dlt +import requests -print(dlt.version.__version__) +print(requests.__version__) diff --git a/crates/tower/Cargo.toml b/crates/tower/Cargo.toml index 751f1616..f3f147f9 100644 --- a/crates/tower/Cargo.toml +++ b/crates/tower/Cargo.toml @@ -15,10 +15,21 @@ path-guid = "CB5E6AA5-5ACF-4A2A-963E-CB9EE1769979" license = false eula = false +[features] +pyo3 = ["dep:pyo3"] + [dependencies] tokio = { workspace = true } tower-cmd = { workspace = true } tower-api = { workspace = true } +pyo3 = { workspace = true, optional = true } +tower-package = { workspace = true } +config = { workspace = true } + +[lib] +name = "_native" +crate-type = ["cdylib"] +required-features = ["pyo3"] [[bin]] name = "tower" diff --git a/crates/tower/src/lib.rs b/crates/tower/src/lib.rs new file mode 100644 index 00000000..751f4ba6 --- /dev/null +++ b/crates/tower/src/lib.rs @@ -0,0 +1,74 @@ +#[cfg(feature = "pyo3")] +mod bindings { + use std::path::PathBuf; + + use pyo3::exceptions::PyRuntimeError; + use pyo3::prelude::*; + + use config::Towerfile; + use tower_cmd::App; + use tower_package::{Package, PackageSpec}; + + /// Build a Tower package from a directory containing a Towerfile. + /// + /// Args: + /// dir: Path to the directory containing the Towerfile. + /// output: Destination path for the built .tar.gz package. + #[pyfunction] + fn build_package(dir: &str, output: &str) -> PyResult<()> { + let towerfile_path = PathBuf::from(dir).join("Towerfile"); + + let towerfile = Towerfile::from_path(towerfile_path) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + let spec = PackageSpec::from_towerfile(&towerfile); + + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + let output = PathBuf::from(output); + + // Everything must happen inside block_on because Package holds a TmpDir + // whose Drop implementation requires an active tokio reactor. + runtime.block_on(async { + let package = Package::build(spec) + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + let src = package + .package_file_path + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("package build produced no output file"))?; + + std::fs::copy(src, &output) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + Ok(()) + }) + } + + /// Run the Tower CLI with the given arguments. + /// + /// Args: + /// args: Command line arguments (typically sys.argv). + #[pyfunction] + fn _run_cli(args: Vec) -> PyResult<()> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + + // App::new_from_args() must run inside block_on because + // Session::from_config_dir() requires an active tokio reactor. + runtime.block_on(async { + App::new_from_args(args).run().await; + }); + + Ok(()) + } + + #[pymodule] + pub fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(build_package, m)?)?; + m.add_function(wrap_pyfunction!(_run_cli, m)?)?; + Ok(()) + } +} diff --git a/pyproject.toml b/pyproject.toml index 855abd10..502d4f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ dependencies = [ "python-dateutil==2.9.0.post0", ] +[project.scripts] +tower = "tower.cli:main" + [project.optional-dependencies] ai = ["huggingface-hub>=0.34.3", "ollama>=0.5.3"] iceberg = ["polars==1.27.1", "pyarrow==19.0.1", "pyiceberg==0.9.1"] @@ -49,11 +52,12 @@ dbt = ["dbt-core>=1.9,<1.10"] # Can not use dbt 1.10+ due to dependency conflict all = ["tower[ai,iceberg,dbt]"] [tool.maturin] -bindings = "bin" +bindings = "pyo3" manifest-path = "crates/tower/Cargo.toml" -module-name = "tower" +module-name = "tower._native" python-source = "src" strip = true +features = ["pyo3"] exclude = [] include = ["rust-toolchain.toml"] diff --git a/src/tower/__init__.py b/src/tower/__init__.py index 9fa4c0fd..4261b6b2 100644 --- a/src/tower/__init__.py +++ b/src/tower/__init__.py @@ -25,6 +25,8 @@ secret, ) +from ._native import build_package + from ._features import override_get_attr, get_available_features, is_feature_enabled diff --git a/src/tower/_native.pyi b/src/tower/_native.pyi new file mode 100644 index 00000000..3349bfd7 --- /dev/null +++ b/src/tower/_native.pyi @@ -0,0 +1,2 @@ +def build_package(dir: str, output: str) -> None: ... +def _run_cli(args: list[str]) -> None: ... diff --git a/src/tower/cli.py b/src/tower/cli.py new file mode 100644 index 00000000..d846ac4a --- /dev/null +++ b/src/tower/cli.py @@ -0,0 +1,7 @@ +import sys + +from ._native import _run_cli + + +def main(): + _run_cli(sys.argv) diff --git a/tests/integration/features/environment.py b/tests/integration/features/environment.py index 1398292a..d486ea4b 100644 --- a/tests/integration/features/environment.py +++ b/tests/integration/features/environment.py @@ -59,4 +59,9 @@ def _find_tower_binary(): if release_path.exists(): return str(release_path) + # Fall back to tower on PATH (e.g. installed via maturin develop or pip) + result = subprocess.run(["which", "tower"], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip() + return None diff --git a/tests/integration/run_tests.py b/tests/integration/run_tests.py index 7ff598ee..b9612e62 100755 --- a/tests/integration/run_tests.py +++ b/tests/integration/run_tests.py @@ -68,13 +68,22 @@ def start_mock_server(): def main(): """Run the integration tests.""" - # Check prerequisites + # Check prerequisites - look for tower binary from cargo build or on PATH project_root = Path(__file__).parent.parent.parent - if not any( + has_cargo_binary = any( (project_root / "target" / build / "tower").exists() for build in ["debug", "release"] - ): - log("ERROR: Tower binary not found. Please run 'cargo build' first.") + ) + has_path_binary = ( + subprocess.run(["which", "tower"], capture_output=True).returncode == 0 + if not has_cargo_binary + else False + ) + + if not has_cargo_binary and not has_path_binary: + log( + "ERROR: Tower binary not found. Please run 'cargo build' or 'maturin develop' first." + ) return 1 try: diff --git a/tests/tower/test_build_package.py b/tests/tower/test_build_package.py new file mode 100644 index 00000000..2de9dc94 --- /dev/null +++ b/tests/tower/test_build_package.py @@ -0,0 +1,219 @@ +import gzip +import io +import json +import os +import tarfile +import tempfile + +import pytest + +_native = pytest.importorskip( + "tower._native", + reason="native extension not built (run: maturin develop --features pyo3)", +) + + +def _make_app(tmp_path, towerfile_content, files=None): + """Create a minimal app directory with a Towerfile and optional source files.""" + app_dir = tmp_path / "app" + app_dir.mkdir() + (app_dir / "Towerfile").write_text(towerfile_content) + for name, content in (files or {}).items(): + path = app_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return str(app_dir) + + +def _read_package(path): + """Read a .tar.gz package and return a dict of {entry_path: content}.""" + with tarfile.open(path, "r:gz") as tar: + entries = {} + for member in tar.getmembers(): + if member.isfile(): + f = tar.extractfile(member) + entries[member.name] = f.read().decode("utf-8") if f else "" + return entries + + +SIMPLE_TOWERFILE = """\ +[app] +name = "test-app" +script = "main.py" +source = ["*.py"] +""" + + +class TestBuildPackage: + def test_produces_tar_gz(self, tmp_path): + app_dir = _make_app(tmp_path, SIMPLE_TOWERFILE, {"main.py": "print('hello')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + assert os.path.isfile(output) + assert tarfile.is_tarfile(output) + + def test_contains_manifest(self, tmp_path): + app_dir = _make_app(tmp_path, SIMPLE_TOWERFILE, {"main.py": "print('hello')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + assert "MANIFEST" in entries + + def test_manifest_is_valid_json(self, tmp_path): + app_dir = _make_app(tmp_path, SIMPLE_TOWERFILE, {"main.py": "print('hello')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + manifest = json.loads(entries["MANIFEST"]) + assert manifest["invoke"] == "main.py" + assert manifest["version"] == 3 + + def test_contains_app_source_files(self, tmp_path): + app_dir = _make_app( + tmp_path, + SIMPLE_TOWERFILE, + { + "main.py": "print('hello')", + "utils.py": "x = 1", + }, + ) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + assert "app/main.py" in entries + assert "app/utils.py" in entries + + def test_contains_towerfile(self, tmp_path): + app_dir = _make_app(tmp_path, SIMPLE_TOWERFILE, {"main.py": "print('hello')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + assert "Towerfile" in entries + + def test_nested_source_files(self, tmp_path): + towerfile = """\ +[app] +name = "test-app" +script = "main.py" +source = ["*.py", "**/*.py"] +""" + app_dir = _make_app( + tmp_path, + towerfile, + { + "main.py": "import pkg", + "pkg/__init__.py": "", + "pkg/module.py": "y = 2", + }, + ) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + assert "app/main.py" in entries + assert "app/pkg/__init__.py" in entries + assert "app/pkg/module.py" in entries + + def test_manifest_contains_schedule(self, tmp_path): + towerfile = """\ +[app] +name = "scheduled-app" +script = "job.py" +source = ["*.py"] +schedule = "0 0 * * *" +""" + app_dir = _make_app(tmp_path, towerfile, {"job.py": "print('run')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + manifest = json.loads(entries["MANIFEST"]) + assert manifest["schedule"] == "0 0 * * *" + + def test_manifest_contains_parameters(self, tmp_path): + towerfile = """\ +[app] +name = "param-app" +script = "main.py" +source = ["*.py"] + +[[parameters]] +name = "batch_size" +description = "Number of items per batch" +default = "100" +""" + app_dir = _make_app(tmp_path, towerfile, {"main.py": "print('run')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + manifest = json.loads(entries["MANIFEST"]) + assert len(manifest["parameters"]) == 1 + assert manifest["parameters"][0]["name"] == "batch_size" + assert manifest["parameters"][0]["default"] == "100" + + def test_manifest_has_checksum(self, tmp_path): + app_dir = _make_app(tmp_path, SIMPLE_TOWERFILE, {"main.py": "print('hello')"}) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + manifest = json.loads(entries["MANIFEST"]) + assert manifest.get("checksum") + assert len(manifest["checksum"]) > 0 + + def test_excludes_pycache(self, tmp_path): + app_dir = _make_app( + tmp_path, + SIMPLE_TOWERFILE, + { + "main.py": "print('hello')", + "__pycache__/main.cpython-311.pyc": "bytecode", + }, + ) + output = str(tmp_path / "out.tar.gz") + + _native.build_package(app_dir, output) + + entries = _read_package(output) + pycache_entries = [k for k in entries if "__pycache__" in k] + assert pycache_entries == [] + + def test_error_missing_towerfile(self, tmp_path): + app_dir = str(tmp_path / "nonexistent") + output = str(tmp_path / "out.tar.gz") + + with pytest.raises(RuntimeError): + _native.build_package(app_dir, output) + + def test_error_invalid_towerfile(self, tmp_path): + app_dir = _make_app(tmp_path, "this is not valid toml [[[", {"main.py": ""}) + output = str(tmp_path / "out.tar.gz") + + with pytest.raises(RuntimeError): + _native.build_package(app_dir, output) + + def test_error_missing_app_name(self, tmp_path): + towerfile = """\ +[app] +script = "main.py" +""" + app_dir = _make_app(tmp_path, towerfile, {"main.py": ""}) + output = str(tmp_path / "out.tar.gz") + + with pytest.raises(RuntimeError): + _native.build_package(app_dir, output)