Skip to content
Merged
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
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ poetry bundle venv .build/.venv --without dev
package-python-function .build/.venv --output-dir .build/lambda
```

The output will be a .zip file with the same name as your project from your `pyproject.toml` file (with dashes replaced
with underscores).
The output will be a .zip file named after your project, as described in [Output file name](#output-file-name).

## Installation
Use [pipx](https://github.com/pypa/pipx) to install:
Expand All @@ -27,16 +26,57 @@ pipx install package-python-function

## Usage / Arguments
```shell
package-python-function venv_dir [--project PROJECT] [--output-dir OUTPUT_DIR] [--output OUTPUT]
package-python-function venv_dir [--project PROJECT] [--output-dir OUTPUT_DIR] [--output OUTPUT] [--report REPORT]
```

- `venv_dir` [Required]: The path to the virtual environment to package.
- `--project` [Optional]: Path to the `pyproject.toml` file. Omit to use the `pyproject.toml` file in the current working directory.
- `--report` [Optional]: Path to write a JSON [report file](#report-file) to. Omit to write no report.

One of the following must be specified:
`--output` and `--output-dir` cannot be used together. If neither is given, the zip is written to the current working
directory.
- `--output`: The full output path of the final zip file.
- `--output-dir`: The output directory for the final zip file. The name of the zip file will be based on the project's
name in the `pyproject.toml` file (with dashes replaced with underscores).
- `--output-dir`: The output directory for the final zip file. The name of the zip file is described in
[Output file name](#output-file-name).

## Output file name

Unless `--output` gives an exact path, the file written is `<output-dir>/<distribution_name>.zip`.

`distribution_name` is the project's name — `[project].name`, or `[tool.poetry].name` if that is absent — with each run
of characters outside `A-Z a-z 0-9 _ .` replaced by a single underscore, following the
[PyPA escaping rules](https://peps.python.org/pep-0427/#escaping-and-unicode).

**Case is preserved.** A project named `My-App` produces `My_App.zip`, not `my_app.zip`. Note that this differs from the
wheel your build tool produces for the same project, whose filename is lowercased — so a wheel's name is not a safe way
to predict the name of this file.

## Report file

Pass `--report <path>` to have the tool write a JSON description of what it produced, so a calling script does not have
to re-derive the output path or re-measure the package.

```json
{
"output_file": "/abs/path/lambda/my_app.zip",
"distribution_name": "my_app",
"output_bytes": 3460000,
"uncompressed_bytes": 412000000,
"compressed_bytes": 3456789,
"nested_zip": false
}
```

| Field | Meaning |
| --- | --- |
| `output_file` | Absolute path of the zip that was written. |
| `distribution_name` | The normalized project name, as described in [Output file name](#output-file-name). |
| `output_bytes` | Size of the file at `output_file`. This is the artifact you deploy. |
| `uncompressed_bytes` | Total size of the packaged files before compression. This is the figure compared against the AWS Lambda 250 MiB unzipped limit. |
| `compressed_bytes` | Size of the dependencies zip. Equal to `output_bytes` unless the nested-zip strategy was used, in which case the outer zip also holds the loader. |
| `nested_zip` | Whether the nested-zip strategy was used. |

The report is written only when packaging succeeds, so its presence is a reliable signal that the zip is really there.

## Notes on Reproducibility

Expand Down
15 changes: 12 additions & 3 deletions package_python_function/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,33 @@
import sys

from .packager import Packager
from .reproducible_zipfile import date_time


def main() -> None:
logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s")

args = parse_args()

# Validate $SOURCE_DATE_EPOCH here, so that a bad value fails before any packaging work rather than partway
# through writing the zip.
date_time()

project_path = Path(args.project).resolve()
venv_path = Path(args.venv_dir).resolve()
output_dir_path = Path(args.output_dir).resolve()
output_file_path = Path(args.output).resolve() if args.output else None
packager = Packager(venv_path, project_path, output_dir_path, output_file_path)
report_file_path = Path(args.report).resolve() if args.report else None
packager = Packager(venv_path, project_path, output_dir_path, output_file_path, report_file_path)
packager.package()


def parse_args() -> argparse.Namespace:
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("venv_dir", type=str, help="The directory path to the virtual environment to package into a zip file")
arg_parser.add_argument("--project", type=str, default='pyproject.toml', help="The path to the project's pyproject.toml file. Omit to use pyproject.toml in the current working directory.")
arg_parser.add_argument("--output-dir", type=str, default='.', help="The directory path to save the output zip file. Default is the current working directory.")
arg_parser.add_argument("--output", type=str, default='', help="The full file path for the output file. Use this instead of --output-dir if you want total control of the output file path.")
output_group = arg_parser.add_mutually_exclusive_group()
output_group.add_argument("--output-dir", type=str, default='.', help="The directory path to save the output zip file. Default is the current working directory.")
output_group.add_argument("--output", type=str, default='', help="The full file path for the output file. Use this instead of --output-dir if you want total control of the output file path.")
arg_parser.add_argument("--report", type=str, default='', help="The file path to write a JSON report of the packaging result to. Omit to write no report.")
return arg_parser.parse_args()
59 changes: 55 additions & 4 deletions package_python_function/packager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import logging
import shutil
from pathlib import Path
Expand All @@ -11,26 +12,52 @@

logger = logging.getLogger(__name__)

class PackageTooLargeError(Exception):
"""Raise when the content is too large for AWS Lambda, both uncompressed and compressed"""

def __init__(self, uncompressed_bytes: int, compressed_bytes: int, limit_bytes: int):
self.uncompressed_bytes = uncompressed_bytes
self.compressed_bytes = compressed_bytes
self.limit_bytes = limit_bytes
super().__init__(
f"The uncompressed size ({uncompressed_bytes:,} bytes) is too large for AWS Lambda, and the compressed "
f"size ({compressed_bytes:,} bytes) also exceeds the limit of {limit_bytes:,} bytes, so the nested-zip "
"strategy cannot be used either. No package was written."
)

class Packager:
AWS_LAMBDA_MAX_UNZIP_SIZE = 262_144_000
DIRS_TO_EXCLUDE = ["__pycache__"]
DIST_INFO_FILES_TO_EXCLUDE = ["RECORD", "direct_url.json"]
EXTENSIONS_TO_EXCLUDE = [".pyc", ".pyo"]

def __init__(self, venv_path: Path, project_path: Path, output_dir: Path, output_file: Path | None):
def __init__(
self,
venv_path: Path,
project_path: Path,
output_dir: Path,
output_file: Path | None,
report_file: Path | None = None,
):
self.project = PythonProject(project_path)
self.venv_path = venv_path

self.output_dir = output_file.parent if output_file else output_dir
self.output_file = output_file if output_file else output_dir / f'{self.project.distribution_name}.zip'
self.report_file = report_file

self._uncompressed_bytes = 0
self._compressed_bytes = 0
self._nested_zip = False

@property
def input_path(self) -> Path:
python_paths = list((self.venv_path / 'lib').glob('python*'))
if not python_paths:
raise Exception("input_path")
raise FileNotFoundError(
f"No 'lib/python*' directory was found in '{self.venv_path}'. Check that this path points at a "
"virtual environment with the function's dependencies installed into it."
)
return python_paths[0] / 'site-packages'

def package(self) -> None:
Expand All @@ -41,6 +68,27 @@ def package(self) -> None:
with NamedTemporaryFile(suffix=".zip") as dependencies_zip:
self.zip_all_dependencies(Path(dependencies_zip.name))

if self.report_file:
self.write_report()

def write_report(self) -> None:
"""
Write a JSON report describing the package that was just produced, so that a calling script does not have to
re-derive the output path or re-measure the sizes.
"""
report = {
"output_file": str(self.output_file),
"distribution_name": self.project.distribution_name,
"output_bytes": self.output_file.stat().st_size,
"uncompressed_bytes": self._uncompressed_bytes,
"compressed_bytes": self._compressed_bytes,
"nested_zip": self._nested_zip,
}

logger.info(f"Writing report to '{self.report_file}'...")
self.report_file.parent.mkdir(parents=True, exist_ok=True)
self.report_file.write_text(json.dumps(report, indent=2) + "\n")

def zip_all_dependencies(self, target_path: Path) -> None:
logger.info(f"Zipping to {target_path}...")

Expand All @@ -64,17 +112,20 @@ def zip_dir(path: Path) -> None:

zip_dir(self.input_path)

compressed_bytes = target_path.stat().st_size
compressed_bytes = self._compressed_bytes = target_path.stat().st_size

logger.info(f"Uncompressed size: {self._uncompressed_bytes:,} bytes. Compressed size: {compressed_bytes:,} bytes.")

if self._uncompressed_bytes > self.AWS_LAMBDA_MAX_UNZIP_SIZE:
logger.info(f"The uncompressed size of the ZIP file is greater than the AWS Lambda limit of {self.AWS_LAMBDA_MAX_UNZIP_SIZE:,} bytes.")
if(compressed_bytes < self.AWS_LAMBDA_MAX_UNZIP_SIZE):
logger.info(f"The compressed size ({compressed_bytes:,}) is less than the AWS limit, so the nested-zip strategy will be used.")
self._nested_zip = True
self.generate_nested_zip(target_path)
else:
print("TODO Error. The unzipped size it too large for AWS Lambda.")
raise PackageTooLargeError(
self._uncompressed_bytes, compressed_bytes, self.AWS_LAMBDA_MAX_UNZIP_SIZE
)
else:
logger.info(f"Copying '{target_path}' to '{self.output_file}'")
shutil.copy(str(target_path), str(self.output_file))
Expand Down
5 changes: 3 additions & 2 deletions package_python_function/python_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def name(self) -> str:
"""
@cached_property
def distribution_name(self) -> str:
return re.sub("[^\w\d.]+", "_", self.name, re.UNICODE)
return re.sub(r"[^\w\d.]+", "_", self.name)

@cached_property
def entrypoint_package_name(self) -> str:
Expand All @@ -41,7 +41,8 @@ def find_value(self, paths: tuple[tuple[str]]) -> str:
value = self.get_value(path)
if value is not None:
return value
raise Exception("TODO Exception find_value")
searched = ", ".join(".".join(path) for path in paths)
raise ValueError(f"None of the following were found in '{self.path}': {searched}.")

def get_value(self, path: tuple[str]) -> Optional[str]:
node = self.toml
Expand Down
9 changes: 8 additions & 1 deletion package_python_function/reproducible_zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ def date_time() -> Tuple[int, int, int, int, int, int]:
"""
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH", None)
if source_date_epoch is not None:
dt = time.gmtime(int(source_date_epoch))[:6]
try:
seconds_since_epoch = int(source_date_epoch)
except ValueError as error:
raise SourceDateEpochError(
f"$SOURCE_DATE_EPOCH must be an integer number of seconds since the Epoch, but was "
f"'{source_date_epoch}'."
) from error
dt = time.gmtime(seconds_since_epoch)[:6]
if dt[0] < 1980:
raise SourceDateEpochError(
"$SOURCE_DATE_EPOCH must be >= 315532800, since ZIP files need MS-DOS date/time format, which can be 1/1/1980, at minimum."
Expand Down
Loading
Loading