Skip to content

Commit 1d68a27

Browse files
committed
feat: Add smart base image detection from pyproject.toml
- Auto-detect Python version from requires-python field - Base images now always required (ensures Python runtime) - Default to python:X.Y-slim based on detected version - Users can still override with --base-image flag - Update all documentation and examples - Bump version to 0.3.0 Breaking change: Base images are no longer optional, but auto-detection maintains backward compatibility for projects specifying requires-python.
1 parent 86b7517 commit 1d68a27

File tree

11 files changed

+88
-34
lines changed

11 files changed

+88
-34
lines changed

.github/copilot-instructions.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ cli.py (entry point)
2020

2121
### Key Architectural Decisions
2222

23-
1. **No Base Image Layering (Yet)**: Currently only creates application layers, doesn't pull/layer base images like `python:3.11-slim`. The `base_image` field in `BuildConfig` is defined but not used—this is a known limitation.
23+
1. **Smart Base Image Selection**: Auto-detects Python version from `requires-python` in `pyproject.toml` and constructs base image name (e.g., `>=3.11``python:3.11-slim`). Users can override with `--base-image` flag. Base images are always required—every build pulls and layers on a Python runtime.
2424

25-
2. **Single Layer Output**: Creates one tar layer with all app files, writes to `dist/image/blobs/sha256/<digest>`. The OCI manifest + config are written to `dist/image/`.
25+
2. **Multi-Layer Output**: Creates base image layers + optional dependency layer + application layer. All layers are written to `dist/image/blobs/sha256/<digest>`. The OCI manifest + config are written to `dist/image/`.
2626

2727
3. **Entrypoint Auto-Detection**: `project.py` reads `pyproject.toml` → looks for `[project.scripts]` → converts first script to Python module invocation. Falls back to `python -m app` if nothing found.
2828

@@ -117,12 +117,11 @@ if child.is_file() and not any(child.match(p) for p in exclude):
117117

118118
## Known Limitations (Do Not "Fix" Without Discussion)
119119

120-
1. **No base image support**: Doesn't download/layer Python runtime. Output is app-only.
121-
2. **No registry push**: No code to push to Docker Hub/GitHub Container Registry.
122-
3. **No dependency installation**: Doesn't run `pip install`. Expects dependencies already in context.
123-
4. **Single architecture**: Hard-coded to `amd64`/`linux` in `builder.py`.
120+
1. **Single architecture support**: Metadata supports multi-arch but no actual cross-compilation yet.
121+
2. **Limited framework detection**: Supports FastAPI, Flask, Django only (easy to extend).
122+
3. **SBOM scope**: Python packages only; doesn't parse OS packages from base images.
124123

125-
These are intentional scope limitations for the experiment phase.
124+
These are intentional scope limitations for the current phase.
126125

127126
## Dependencies
128127

ARCHITECTURE.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,31 @@ class ImageBuilder:
166166

167167
### 3. Project Layer (`project.py`)
168168

169-
**Purpose**: Introspect Python projects to extract metadata, entry points, and structure.
169+
**Purpose**: Introspect Python projects to extract metadata, entry points, structure, and Python version.
170170

171171
**Key Functions**:
172172

173173
```python
174+
def detect_python_version(context_dir) -> str:
175+
"""
176+
Detect Python version from pyproject.toml requires-python field.
177+
178+
Extracts version from patterns like:
179+
- ">=3.11" → "3.11"
180+
- "^3.12" → "3.12"
181+
- "~=3.10" → "3.10"
182+
183+
Returns:
184+
Python version string (e.g., "3.11"), defaults to "3.11" if not found
185+
"""
186+
pyproject = parse_pyproject_toml(context_dir / "pyproject.toml")
187+
requires_py = pyproject.get("project", {}).get("requires-python")
188+
if requires_py:
189+
match = re.search(r'(\d+\.\d+)', requires_py)
190+
if match:
191+
return match.group(1)
192+
return "3.11"
193+
174194
def discover_project(context_path: Path) -> ProjectMetadata:
175195
"""
176196
Discover Python project structure and metadata.
@@ -484,7 +504,7 @@ class BuildConfig:
484504
workdir: str = "/app"
485505
env: dict[str, str] = field(default_factory=dict)
486506
include_paths: list[str] = field(default_factory=list)
487-
base_image: str | None = None # Phase 2
507+
base_image: str = "python:3.11-slim" # Auto-detected from requires-python
488508
registry: str | None = None
489509
use_cache: bool = True
490510

@@ -659,11 +679,17 @@ def discover_project(context_path: Path,
659679

660680
**Approach**: Implement OCI spec directly using Python stdlib + HTTP requests.
661681

662-
### Why Single Layer (Phase 0)?
682+
### Why Smart Base Image Detection?
683+
684+
**Rationale**: Simplify user experience by automatically selecting the correct Python base image from project metadata.
663685

664-
**Rationale**: Simplify initial implementation, prove feasibility.
686+
**Approach**: Parse `requires-python` from `pyproject.toml` and construct base image name (e.g., `>=3.11``python:3.11-slim`).
665687

666-
**Future**: Phase 2 adds multi-layer support (base + deps + app).
688+
**Benefits**:
689+
- Zero configuration for common cases
690+
- Always includes Python runtime (no invalid app-only images)
691+
- Respects project's Python version requirements
692+
- Users can still override with `--base-image` flag
667693

668694
### Why Dataclasses Over Dicts?
669695

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ pip install -e .
3838
### Build Your First Image
3939

4040
```bash
41-
# Simple build (auto-detects everything)
41+
# Simple build (auto-detects Python version and base image)
4242
pycontainer build --tag myapp:latest
4343

44-
# Build on a base image with dependencies
44+
# Build with custom base image and dependencies
4545
pycontainer build \
4646
--tag myapp:v1 \
47-
--base-image python:3.11-slim \
47+
--base-image python:3.12-slim \
4848
--include-deps
4949

5050
# Build FastAPI app (auto-detected, entrypoint configured)
@@ -102,7 +102,8 @@ dist/image/
102102
-**Fast incremental builds** — Reuses unchanged layers from cache
103103

104104
**Base Images & Dependencies** (Phase 2):
105-
-**Base image support** — Build on top of `python:3.11-slim`, distroless, etc.
105+
-**Smart base image detection** — Auto-selects Python base image from `requires-python` in pyproject.toml
106+
-**Base image support** — Build on top of `python:3.11-slim`, `python:3.12-slim`, distroless, etc.
106107
-**Layer merging** — Combines base image layers with application layers
107108
-**Config inheritance** — Merges env vars, labels, working dir from base images
108109
-**Dependency packaging** — Include pip packages from venv or requirements.txt
@@ -235,6 +236,7 @@ Install from VS Code Marketplace or command palette:
235236
236237
By default, `pycontainer` auto-detects:
237238

239+
- **Base image**: Python version from `requires-python` in `pyproject.toml` (e.g., `>=3.11` → `python:3.11-slim`)
238240
- **Entry point**: First `[project.scripts]` entry in `pyproject.toml`
239241
- **Include paths**: `src/`, `app/`, or `<package>/` dirs + `pyproject.toml`, `requirements.txt`
240242
- **Working directory**: `/app/`
@@ -261,7 +263,7 @@ pycontainer build \
261263
```
262264

263265
**Base Image & Dependencies**:
264-
- `--base-image IMAGE` — Base image to build on (e.g., `python:3.11-slim`)
266+
- `--base-image IMAGE` — Base image to build on (auto-detected from `requires-python` if not specified, e.g., `python:3.11-slim`)
265267
- `--include-deps` — Package dependencies from venv or requirements.txt
266268

267269
**Caching Options**:
@@ -290,7 +292,7 @@ from pycontainer.builder import ImageBuilder
290292
config = BuildConfig(
291293
tag="myapp:latest",
292294
context_path=".",
293-
base_image="python:3.11-slim",
295+
base_image="python:3.11-slim", # Optional: auto-detected if omitted
294296
include_deps=True,
295297
workdir="/app",
296298
env={"DEBUG": "false", "ENV": "production"},

docs/azd-integration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ services:
5555
run: |
5656
pycontainer build \
5757
--tag ${SERVICE_IMAGE_NAME}:${SERVICE_IMAGE_TAG} \
58-
--base-image python:3.11-slim \
5958
--include-deps \
6059
--context ${SERVICE_PATH} \
6160
--push
6261
```
6362
63+
> **Note**: The `--base-image` flag is optional. pycontainer-build will auto-detect the Python version from your `pyproject.toml` (`requires-python` field) and use the appropriate base image (e.g., `python:3.11-slim`). You can override this by explicitly setting `--base-image python:3.12-slim` or any custom base image.
64+
6465
### 3. Deploy
6566

6667
```bash

docs/local-development.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,15 @@ By default, images are created at:
7272
### With Base Image & Dependencies
7373

7474
```bash
75-
# Build on a Python base image
75+
# Build with auto-detected Python base image
7676
pycontainer build \
7777
--tag myapp:latest \
78-
--base-image python:3.11-slim \
78+
--include-deps
79+
80+
# Or explicitly specify a base image
81+
pycontainer build \
82+
--tag myapp:latest \
83+
--base-image python:3.12-slim \
7984
--include-deps
8085

8186
# This will:

examples/fastapi-app/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ This is a sample FastAPI application demonstrating all pycontainer-build integra
88

99
```bash
1010
cd examples/fastapi-app
11+
# Base image auto-detected from pyproject.toml (requires-python: ">=3.11")
1112
pycontainer build --tag fastapi-demo:latest --include-deps
13+
14+
# Or with custom base image
15+
pycontainer build --tag fastapi-demo:latest --base-image python:3.12-slim --include-deps
1216
```
1317

1418
### Build with Poetry Plugin
@@ -124,12 +128,13 @@ This example includes configuration for all integrations:
124128

125129
## Features Demonstrated
126130

127-
- ✅ Framework auto-detection (FastAPI automatically configured)
128-
- ✅ Entry point auto-detection from pyproject.toml
129-
- ✅ Dependency packaging with `--include-deps`
130-
- ✅ Environment variable configuration
131-
- ✅ OCI label metadata
132-
- ✅ Multiple integration methods
131+
-**Base image auto-detection** - Python version detected from `requires-python` in pyproject.toml
132+
-**Framework auto-detection** - FastAPI automatically configured with proper entrypoint
133+
-**Entry point auto-detection** - Reads `[project.scripts]` from pyproject.toml
134+
-**Dependency packaging** - Include pip packages with `--include-deps`
135+
-**Environment variable configuration** - Custom env vars for production
136+
-**OCI label metadata** - Maintainer, description, and custom labels
137+
-**Multiple integration methods** - CLI, Poetry, Hatch, VS Code, GitHub Actions, azd
133138

134139
## Learn More
135140

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pycontainer-build"
3-
version = "0.2.1"
3+
version = "0.3.0"
44
description = "Experimental .NET-style native OCI image builder for Python projects (no Docker daemon, no Dockerfile)."
55
readme = "README.md"
66
requires-python = ">=3.11"

src/pycontainer/builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def build(self):
3434
layers_dir=ensure_dir(blobs/'sha256')
3535
refs_dir=ensure_dir(output/'refs'/'tags')
3636

37-
base_layers, base_config = self._pull_base_image(layers_dir) if self.config.base_image else ([], None)
37+
base_layers, base_config = self._pull_base_image(layers_dir)
3838

3939
entry = self.config.entrypoint or detect_entrypoint(self.config.context_dir)
4040
include = self.config.include_paths or default_include_paths(self.config.context_dir)
@@ -157,7 +157,7 @@ def push(self, registry_url: Optional[str]=None, auth_token: Optional[str]=None,
157157
def _show_build_plan(self):
158158
"""Display build plan for dry-run mode."""
159159
print(f"Build Plan for {self.config.tag}:")
160-
print(f" Base Image: {self.config.base_image or 'scratch'}")
160+
print(f" Base Image: {self.config.base_image}")
161161
print(f" Context: {self.config.context_dir}")
162162
print(f" Working Dir: {self.config.workdir}")
163163
print(f" Entrypoint: {' '.join(self.config.entrypoint or ['<auto-detect>'])}")

src/pycontainer/cli.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from .config import BuildConfig
44
from .builder import ImageBuilder
55
from .config_loader import config_from_file
6+
from .project import detect_python_version
67

78
def main():
89
parser=argparse.ArgumentParser()
910
sub=parser.add_subparsers(dest="cmd",required=True)
1011
b=sub.add_parser("build")
1112
b.add_argument("--config","-c",help="Path to pycontainer.toml config file")
1213
b.add_argument("--tag")
13-
b.add_argument("--base-image",help="Base image to layer on (e.g., python:3.11-slim)")
14+
b.add_argument("--base-image",help="Base image to layer on (auto-detected from requires-python if not specified)")
1415
b.add_argument("--context",default=".")
1516
b.add_argument("--push",action="store_true",help="Push image to registry after build")
1617
b.add_argument("--registry",help="Override registry from tag (e.g., ghcr.io/user/repo:v1)")
@@ -52,9 +53,13 @@ def main():
5253
cfg=config_from_file(Path(args.config), cli_overrides)
5354
else:
5455
tag=args.tag or "local/test:latest"
56+
base_img=args.base_image
57+
if not base_img:
58+
py_ver=detect_python_version(args.context)
59+
base_img=f"python:{py_ver}-slim"
5560
cfg=BuildConfig(
5661
tag=tag,
57-
base_image=args.base_image,
62+
base_image=base_img,
5863
context_dir=args.context,
5964
use_cache=not args.no_cache,
6065
cache_dir=args.cache_dir,

src/pycontainer/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
@dataclass
55
class BuildConfig:
66
tag: str = "local/test:latest"
7-
base_image: Optional[str] = None
7+
base_image: str = "python:3.11-slim"
88
context_dir: str = "."
99
workdir: str = "/app"
1010
entrypoint: Optional[List[str]] = None

0 commit comments

Comments
 (0)