diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4cef966e..892f893a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,13 +14,13 @@ ## Tech Stack - **Python:** 3.11, 3.12, 3.13 (multi-version support) -- **Core Dependencies:** pandas (>=2.0), numpy, polars (1.33.0), pyyaml +- **Core Dependencies:** pandas (>=2.0,<3.0), numpy, polars (1.33.0), pyyaml - **Database Connectors:** sqlalchemy, pymssql, psycopg2-binary, pymysql, pymongo - **Cloud/External:** boto3 (AWS S3), simple-salesforce, fabric (SFTP) - **Data Formats:** openpyxl (Excel), xlsxwriter - **AI/ML:** OpenAI integration, Hugging Face models - **Testing:** pytest (9.0.2), pytest-mock, lorem (test data generation) -- **Containerization:** Production Docker image uses Python 3.11-slim-bookworm; development container uses Python 3.13-bookworm +- **Containerization:** The WranglesPY CI test image uses Python 3.13-slim-bookworm with pandas 2.3.3 and NumPy 2.4.6; the development container uses Python 3.13-bookworm. The deployed recipe runtime image is owned by `wrangleworks/Lambda-Recipes`. ## Project Structure @@ -50,8 +50,8 @@ WranglesPY/ │ └── recipe_base_schema.json ├── setup.py # Package setup and release version ├── requirements.txt # Production dependencies -├── dockerfile # Multi-stage Docker build -├── main.py # Container entry point +├── dockerfile # Multi-stage CI test-image build +├── main.py # CI test-image entry point └── .github/workflows/ # CI/CD pipelines ``` @@ -125,14 +125,18 @@ pip install . wrangles.recipe tests/samples/generate-data.wrgl.yml ``` -### Docker Build -The Dockerfile uses multi-stage builds for optimization: -1. Compile stage: Installs build dependencies and packages +### CI Test Image Build +The repository-root Dockerfile builds a GHCR test image used to validate the +installable package and recipes. It is not the deployed production runtime; +`wrangleworks/Lambda-Recipes` owns the AWS Lambda image and its Python version. + +The test-image Dockerfile uses multi-stage builds for optimization: +1. Dependency stage: Installs binary wheels under the tracked test-container constraint; no compiler toolchain is installed 2. Build stage: Copies only necessary files (~400MB final image) 3. Special optimizations: Removes unused botocore AWS service definitions, pandas test data ```bash -docker build -t wrangles:latest . +docker build -t wrangles:test . ``` ## Coding Guidelines @@ -249,12 +253,12 @@ deployments from an explicit `main` SHA and removing the remaining legacy paths. ### Merging never releases anything Merging to `main` runs CI and, under the current transitional workflow, -publishes a tested container image. It does not publish a Python package and +publishes a tested GHCR CI image. It does not publish a Python package and does not deploy to any environment. | Action | Result | | --- | --- | -| Merge to `main` | tests, then `:latest` image promoted | +| Merge to `main` | tests, then GHCR test image `:latest` promoted | | Manually dispatch `deploy-dev.yml` from `main` | `rcN` to CodeArtifact, then DEV deploy in Lambda-Recipes | | Push a matching `v*` tag from `main` | `:` image, then CodeArtifact, then PyPI | @@ -280,7 +284,8 @@ to define. - Pytest on Ubuntu + Windows across Python 3.11 + 3.13 for `main` PRs - Test pip installation - Generate and test JSON schema - - Build the Docker image, pushed on merges to `main` under the new policy + - Build the CI test image and, on PRs, run smoke checks and local recipes against that exact image + - Push the image on merges to `main` under the new policy - Run container tests, then promote the mutable tag - **deploy-dev.yml** (*Deploy Dev*)**:** manually dispatch from `main`. The workflow still accepts `dev` temporarily; do not use that path for new work. @@ -293,14 +298,15 @@ to define. 1. **pytest:** Run test suite across OS/Python matrix 2. **test-pip-install:** Verify package installs correctly 3. **test-generate-schema:** Generate JSON schema from code -4. **build:** Create Docker image and push to GitHub Container Registry -5. **test-container:** Validate Docker image with full test suite +4. **build:** Create the CI test image and push it to GitHub Container Registry +5. **test-container:** Validate the CI test image with the full test suite 6. **promote-image:** Retag the tested image; `dev` support is transitional and `latest` handling will be hardened under issue #1117 -Mutable tags are only moved after `test-container` passes, and package -publication is gated on the container, so the wheel and the image cannot -diverge. See `docs/release-lifecycle.md` for the current release direction. +Mutable GHCR test-image tags are only moved after `test-container` passes, and +package publication is gated on that validation. Production Lambda image +publication and deployment remain the responsibility of `Lambda-Recipes`. See +`docs/release-lifecycle.md` for the current release direction. ## Known Issues & Workarounds @@ -322,9 +328,11 @@ brew install freetds ### Pandas Performance Warnings Performance warnings from pandas are suppressed in `recipe.py` as they appear during recipe execution without actual performance impact. This is a known issue being monitored. -### Docker Image Size Optimization +### CI Test Image Size Optimization - Botocore data reduced to S3-only (removes ~300MB) - Pandas test data removed from final image +- Test-image data stack constrained to pandas 2.3.3 and NumPy 2.4.6 while the reusable package continues to allow pandas 2.x +- All test-image dependencies install from binary wheels; compiler tools are absent from the runtime image - Uses slim Debian base image for minimal footprint ## Common Commands @@ -342,8 +350,8 @@ pytest -v tests/test_wrangles.py # Generate schema cd schema && python generate_recipe_schema.py -# Build Docker image -docker build -t wrangles:latest . +# Build CI test image +docker build -t wrangles:test . # Run recipe locally wrangles.recipe tests/samples/recipe-basic.wrgl.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68bed975..1661fab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,9 +61,9 @@ jobs: fi fi - # Pushes land on an immutable per-commit tag. The mutable release tag + # Pushes land on an immutable per-commit tag. The mutable test-image tag # (dev / latest) is only moved onto that image once test-container - # passes, so consumers never resolve it to an untested build. + # passes, so validation jobs never resolve it to an untested build. if [[ "$PUSH_IMAGE" == "true" ]]; then BUILD_TAG="sha-${GITHUB_SHA}" else @@ -188,17 +188,35 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # Build and push Docker image with Buildx (build only, never push, on PRs) + # Build and push the CI test image with Buildx (build only, never push, on PRs) # https://github.com/docker/build-push-action - - name: Build and push Docker image + - name: Build and push CI test image id: build uses: docker/build-push-action@v7 with: context: . + load: ${{ needs.config.outputs.push_image != 'true' }} push: ${{ needs.config.outputs.push_image == 'true' }} tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.config.outputs.build_tag }} labels: ${{ steps.meta.outputs.labels }} + - name: Validate pull request test image + if: needs.config.outputs.push_image != 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.config.outputs.build_tag }} + run: | + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + "$IMAGE" \ + python /workspace/scripts/container_smoke.py + docker run --rm "$IMAGE" python -m pip check + docker run --rm "$IMAGE" python -m pip freeze + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --workdir /workspace/tests/samples \ + "$IMAGE" \ + sh -c 'wrangles.recipe recipe-basic.wrgl.yml && wrangles.recipe recipe_custom_function.wrgl.yml -f custom_functions.py' + test-container: runs-on: ubuntu-latest if: needs.config.outputs.push_image == 'true' @@ -221,6 +239,12 @@ jobs: - name: Remove wrangles folder run: rm -r wrangles + - name: Validate CI test runtime + run: | + python scripts/container_smoke.py + python -m pip check + python -m pip freeze + - name: Install Test Dependencies run: | python -m pip install --upgrade pip @@ -238,11 +262,11 @@ jobs: && wrangles.recipe recipe-basic.wrgl.yml && wrangles.recipe recipe_custom_function.wrgl.yml -f custom_functions.py - # Moves :dev / :latest onto the image that test-container just validated. + # Moves :dev / :latest onto the CI test image that test-container validated. # Retagging by digest guarantees the promoted image is the tested one and not # a rebuild, so no consumer of a mutable tag ever sees an untested image. promote-image: - name: Promote Image + name: Promote CI Test Image runs-on: ubuntu-latest if: needs.config.outputs.push_image == 'true' needs: [config, build, test-container] diff --git a/AGENTS.md b/AGENTS.md index cec01a26..accbb60d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,16 @@ After every legacy change is recovered, superseded, or intentionally abandoned, archive or delete the legacy branch. +## Container ownership + +- The repository-root `dockerfile` builds the WranglesPY CI test image published + to `ghcr.io/wrangleworks/wrangles`. Do not describe it as the production + runtime image. +- The deployed `execute-recipe` AWS Lambda image is built and deployed from the + `wrangleworks/Lambda-Recipes` repository. Its `dockerfile` controls the + production Python version; verify that repository before making production + runtime claims. + ## Code Review Rules ### Make the required action explicit diff --git a/README.md b/README.md index 4d6a9f39..479aa756 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ the intentionally live database, AWS, WrangleWorks, AI, and search-provider checks. The complete credentialed suite remains a CI validation and a local dependency/import pass does not claim live-service validation. +## Container and deployment ownership + +The repository-root `dockerfile` builds the WranglesPY **CI test image** published +to `ghcr.io/wrangleworks/wrangles`. It validates the installable package and +recipe behavior in a slim Linux environment; it is not the deployed recipe +runtime. + +The production `execute-recipe` AWS Lambda image is built, published to ECR, and +deployed from the +[`wrangleworks/Lambda-Recipes`](https://github.com/wrangleworks/Lambda-Recipes) +repository. Its `dockerfile` is the source of truth for the production Python +version. Changing the Python version in this repository's test image does not +migrate the production Lambda runtime. + ## What are Wrangles? Wrangles are a set of modular transformations for data cleaning and enrichment. Each Wrangle is optimized for a particular job, many of which are backed by sophisticated machine learning models. diff --git a/constraints/test-container-python313.txt b/constraints/test-container-python313.txt new file mode 100644 index 00000000..f86fff10 --- /dev/null +++ b/constraints/test-container-python313.txt @@ -0,0 +1,5 @@ +# Reproducible data-stack pins for the Python 3.13 CI test image. +# These exact test-runtime pins do not replace the reusable package +# compatibility ranges declared in requirements.txt. +numpy==2.4.6 +pandas==2.3.3 diff --git a/dockerfile b/dockerfile index 9bf6d006..3f2c0553 100644 --- a/dockerfile +++ b/dockerfile @@ -1,36 +1,60 @@ -FROM python:3.11-slim-bookworm AS compile-image +# syntax=docker/dockerfile:1 + +FROM python:3.13-slim-bookworm AS dependency-image # Copy package COPY . /pkg -# Install compile requirements -RUN apt-get update \ - && apt-get install -y build-essential gcc \ - gfortran python3-dev \ - --no-install-recommends - # Create a virtual env RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# Install package + dependencies -RUN pip install --no-cache-dir wheel -# Special install for numpy to reduce size -RUN CFLAGS="-g0 -Wl,--strip-all" pip install --no-cache-dir --compile --global-option=build_ext numpy==1.24.3 -# Regular install (without cache) for everything else -RUN pip install --no-cache-dir /pkg +# Install the package with the CI test-image data-stack constraint. Every +# dependency must resolve to a wheel because the image has no compiler. +RUN python -m pip install \ + --no-cache-dir \ + --only-binary=:all: \ + --constraint /pkg/constraints/test-container-python313.txt \ + /pkg packaging + +# Retain only the Botocore data used by the S3 connector and remove Pandas test +# data. Resolve installed-package locations instead of embedding a Python minor. +RUN python - <<'PY' +from pathlib import Path +import shutil + +import botocore +import pandas + +botocore_data = Path(botocore.__file__).resolve().parent / "data" +keep = { + "s3", + "_retry.json", + "endpoints.json", + "partitions.json", + "sdk-default-configuration.json", +} +for path in botocore_data.iterdir(): + if path.name in keep: + continue + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() -# Botocore contains lots of definitions for all AWS services. We are only using S3. Remove all other files to save space -RUN cd /opt/venv/lib/python3.11/site-packages/botocore/data && cp -r s3 _retry.json endpoints.json partitions.json sdk-default-configuration.json /tmp/ -RUN rm -r /opt/venv/lib/python3.11/site-packages/botocore/data/* -RUN cp -r /tmp/s3 /tmp/_retry.json /tmp/endpoints.json /tmp/partitions.json /tmp/sdk-default-configuration.json /opt/venv/lib/python3.11/site-packages/botocore/data +pandas_tests = Path(pandas.__file__).resolve().parent / "tests" +if pandas_tests.exists(): + shutil.rmtree(pandas_tests) +PY -# Pandas contains a lot of unnecessary test data that we won't use -RUN rm -r /opt/venv/lib/python3.11/site-packages/pandas/tests/* +# Fail the build before the runtime stage if versions, metadata, package-data +# trimming, imports, or the credential-free data/S3 checks are incorrect. +RUN python -m pip check \ + && python /pkg/scripts/container_smoke.py # Create build image -FROM python:3.11-slim-bookworm AS build-image -COPY --from=compile-image /opt/venv /opt/venv +FROM python:3.13-slim-bookworm AS build-image +COPY --from=dependency-image /opt/venv /opt/venv LABEL maintainer="WrangleWorks" ENV PATH="/opt/venv/bin:$PATH" @@ -39,4 +63,4 @@ RUN mkdir /app COPY main.py /app/ WORKDIR /app/ -CMD python main.py \ No newline at end of file +CMD python main.py diff --git a/pytest-local.ini b/pytest-local.ini index 02f160ad..11c64814 100644 --- a/pytest-local.ini +++ b/pytest-local.ini @@ -2,6 +2,7 @@ testpaths = tests/test_ai_cache.py tests/test_ai_definition.py + tests/test_container_smoke.py tests/test_data.py tests/test_dataframe.py tests/test_openai_extract_ai.py diff --git a/scripts/container_smoke.py b/scripts/container_smoke.py new file mode 100644 index 00000000..d92673cd --- /dev/null +++ b/scripts/container_smoke.py @@ -0,0 +1,210 @@ +"""Credential-free validation for the WranglesPY CI test image.""" + +from importlib import metadata +from pathlib import Path +import os +import shutil +import sys + +from packaging.requirements import Requirement + + +EXPECTED_PYTHON = (3, 13) +TEST_CONTAINER_CONSTRAINTS = ( + Path(__file__).resolve().parents[1] + / "constraints" + / "test-container-python313.txt" +) +RETAINED_BOTOCORE_DATA = frozenset( + { + "s3", + "_retry.json", + "endpoints.json", + "partitions.json", + "sdk-default-configuration.json", + } +) + + +def _require(condition, message): + if not condition: + raise RuntimeError(message) + + +def load_exact_constraints(path=TEST_CONTAINER_CONSTRAINTS): + """Load exact package versions from the test-image constraint file.""" + path = Path(path) + _require(path.is_file(), f"Test-container constraints not found: {path}") + + versions = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.partition("#")[0].strip() + if not line: + continue + requirement = Requirement(line) + specifiers = list(requirement.specifier) + exact_versions = [ + specifier.version + for specifier in specifiers + if specifier.operator == "==" + ] + _require( + len(specifiers) == 1 and len(exact_versions) == 1, + f"Expected one exact version in test-container constraint: {line}", + ) + name = requirement.name.lower() + _require(name not in versions, f"Duplicate test-container constraint: {name}") + versions[name] = exact_versions[0] + + for name in ("numpy", "pandas"): + _require(name in versions, f"Missing test-container constraint: {name}") + return versions + + +TEST_CONTAINER_VERSIONS = load_exact_constraints() +EXPECTED_NUMPY = TEST_CONTAINER_VERSIONS["numpy"] +EXPECTED_PANDAS = TEST_CONTAINER_VERSIONS["pandas"] + + +def validate_runtime_versions(python_version, numpy_version, pandas_version): + """Validate the exact interpreter and constrained data-stack versions.""" + _require( + tuple(python_version[:2]) == EXPECTED_PYTHON, + f"Expected Python 3.13, found {python_version[0]}.{python_version[1]}", + ) + _require( + numpy_version == EXPECTED_NUMPY, + f"Expected NumPy {EXPECTED_NUMPY}, found {numpy_version}", + ) + _require( + pandas_version == EXPECTED_PANDAS, + f"Expected Pandas {EXPECTED_PANDAS}, found {pandas_version}", + ) + _require( + int(pandas_version.split(".", maxsplit=1)[0]) < 3, + f"Pandas 3.x is not supported by this image: {pandas_version}", + ) + + +def validate_wrangles_pandas_requirement(requirements): + """Prove the installed package metadata still excludes Pandas 3.""" + parsed_requirements = [Requirement(requirement) for requirement in requirements] + pandas_requirements = [ + requirement + for requirement in parsed_requirements + if requirement.name.lower() == "pandas" + ] + _require( + len(pandas_requirements) == 1, + "Expected one Pandas package requirement, found " + f"{[str(requirement) for requirement in pandas_requirements]}", + ) + pandas_specifier = pandas_requirements[0].specifier + _require( + pandas_specifier.contains("2.999", prereleases=True) + and not pandas_specifier.contains("3.0", prereleases=True), + "Wrangles metadata must support Pandas 2.x and exclude Pandas 3: " + f"{pandas_requirements[0]}", + ) + + +def validate_trimmed_package_data(botocore_data, pandas_package): + """Validate the two package-data reductions used to control image size.""" + botocore_data = Path(botocore_data) + _require( + botocore_data.exists(), + f"Botocore data directory not found: {botocore_data}", + ) + actual_botocore_data = {path.name for path in botocore_data.iterdir()} + _require( + actual_botocore_data == RETAINED_BOTOCORE_DATA, + "Unexpected Botocore data after trimming: " + f"expected {sorted(RETAINED_BOTOCORE_DATA)}, " + f"found {sorted(actual_botocore_data)}", + ) + pandas_tests = Path(pandas_package) / "tests" + _require(not pandas_tests.exists(), f"Pandas tests were not removed: {pandas_tests}") + + +def validate_runtime_toolchain(which=shutil.which): + """Ensure compiler tools from the former source build are absent.""" + present = [tool for tool in ("gcc", "gfortran") if which(tool)] + _require(not present, f"Build-only compiler tools found in runtime image: {present}") + + +def validate_data_round_trip(): + """Exercise NumPy, Pandas, and PyArrow together without external data.""" + import numpy + import pandas + import pyarrow + + original = pandas.DataFrame( + { + "id": numpy.array([1, 2], dtype=numpy.int64), + "description": ["alpha", None], + } + ) + table = pyarrow.Table.from_pandas(original, preserve_index=False) + restored = table.to_pandas() + pandas.testing.assert_frame_equal(restored, original) + + +def validate_s3_model(): + """Load the retained S3 model without making a network request.""" + import boto3 + + os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true") + client = boto3.client( + "s3", + region_name="us-east-1", + aws_access_key_id="container-smoke-test", + aws_secret_access_key="container-smoke-test", + endpoint_url="https://example.invalid", + ) + try: + _require( + client.meta.service_model.service_name == "s3", + "The retained Botocore S3 service model did not load", + ) + finally: + client.close() + + +def main(): + import boto3 + import botocore + import numexpr + import numpy + import pandas + import polars + import pyarrow + import wrangles # noqa: F401 - import itself is part of the smoke check + + validate_runtime_versions(sys.version_info, numpy.__version__, pandas.__version__) + validate_wrangles_pandas_requirement(metadata.requires("wrangles") or ()) + validate_trimmed_package_data( + Path(botocore.__file__).resolve().parent / "data", + Path(pandas.__file__).resolve().parent, + ) + validate_runtime_toolchain() + validate_data_round_trip() + validate_s3_model() + + versions = { + "Python": sys.version.split()[0], + "Wrangles": metadata.version("wrangles"), + "NumPy": numpy.__version__, + "Pandas": pandas.__version__, + "PyArrow": pyarrow.__version__, + "Polars": polars.__version__, + "NumExpr": numexpr.__version__, + "Boto3": boto3.__version__, + "Botocore": botocore.__version__, + } + print("CI test-image smoke checks passed:") + for name, version in versions.items(): + print(f" {name}={version}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_container_smoke.py b/tests/test_container_smoke.py new file mode 100644 index 00000000..13153649 --- /dev/null +++ b/tests/test_container_smoke.py @@ -0,0 +1,124 @@ +from pathlib import Path + +import pytest + +from scripts import container_smoke + + +def test_runtime_versions_accept_migration_baseline(): + container_smoke.validate_runtime_versions( + (3, 13, 9), + container_smoke.EXPECTED_NUMPY, + container_smoke.EXPECTED_PANDAS, + ) + + +@pytest.mark.parametrize( + ("python_version", "numpy_version", "pandas_version"), + [ + ((3, 12, 9), "2.4.6", "2.3.3"), + ((3, 13, 9), "2.5.2", "2.3.3"), + ((3, 13, 9), "2.4.6", "3.0.0"), + ], +) +def test_runtime_versions_reject_unapproved_versions( + python_version, + numpy_version, + pandas_version, +): + with pytest.raises(RuntimeError): + container_smoke.validate_runtime_versions( + python_version, + numpy_version, + pandas_version, + ) + + +def test_wrangles_metadata_requires_pandas_below_three(): + container_smoke.validate_wrangles_pandas_requirement( + [ + "numpy", + "pandas[performance]<3.0,>=2.0; python_version >= '3.11'", + "requests", + ] + ) + + +@pytest.mark.parametrize( + "requirement", + ["pandas>=2.0", "pandas>=3.0,<4.0"], +) +def test_wrangles_metadata_rejects_pandas_three(requirement): + with pytest.raises(RuntimeError): + container_smoke.validate_wrangles_pandas_requirement([requirement]) + + +def test_trimmed_package_data_accepts_only_s3(tmp_path): + botocore_data = tmp_path / "botocore-data" + botocore_data.mkdir() + for name in container_smoke.RETAINED_BOTOCORE_DATA: + path = botocore_data / name + if name == "s3": + path.mkdir() + else: + path.touch() + + pandas_package = tmp_path / "pandas" + pandas_package.mkdir() + + container_smoke.validate_trimmed_package_data(botocore_data, pandas_package) + + +def test_trimmed_package_data_rejects_other_services(tmp_path): + botocore_data = tmp_path / "botocore-data" + botocore_data.mkdir() + for name in container_smoke.RETAINED_BOTOCORE_DATA | {"ec2"}: + path = botocore_data / name + if "." in name: + path.touch() + else: + path.mkdir() + + with pytest.raises(RuntimeError): + container_smoke.validate_trimmed_package_data(botocore_data, tmp_path / "pandas") + + +def test_trimmed_package_data_rejects_missing_botocore_data(tmp_path): + botocore_data = tmp_path / "missing-botocore-data" + + with pytest.raises(RuntimeError, match="Botocore data directory not found"): + container_smoke.validate_trimmed_package_data(botocore_data, tmp_path / "pandas") + + +def test_runtime_toolchain_rejects_compiler(): + with pytest.raises(RuntimeError): + container_smoke.validate_runtime_toolchain( + lambda tool: Path("/usr/bin") / tool if tool == "gcc" else None + ) + + +def test_data_round_trip(): + container_smoke.validate_data_round_trip() + + +def test_data_round_trip_propagates_comparison_failure(mocker): + comparison = mocker.patch( + "pandas.testing.assert_frame_equal", + side_effect=AssertionError("round trip changed the frame"), + ) + + with pytest.raises(AssertionError, match="round trip changed the frame"): + container_smoke.validate_data_round_trip() + + comparison.assert_called_once() + + +def test_s3_model_loads_without_network(mocker): + create_connection = mocker.patch( + "socket.create_connection", + side_effect=AssertionError("network access attempted"), + ) + + container_smoke.validate_s3_model() + + create_connection.assert_not_called()