bh-pixelfuse 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. bh_pixelfuse-0.0.4/.env.example +9 -0
  2. bh_pixelfuse-0.0.4/.flake8 +3 -0
  3. bh_pixelfuse-0.0.4/.github/dependabot.yml +11 -0
  4. bh_pixelfuse-0.0.4/.github/workflows/ci.yml +53 -0
  5. bh_pixelfuse-0.0.4/.github/workflows/release.yml +91 -0
  6. bh_pixelfuse-0.0.4/.gitignore +35 -0
  7. bh_pixelfuse-0.0.4/Dockerfile +32 -0
  8. bh_pixelfuse-0.0.4/PKG-INFO +82 -0
  9. bh_pixelfuse-0.0.4/README.md +62 -0
  10. bh_pixelfuse-0.0.4/docker-compose.yml +13 -0
  11. bh_pixelfuse-0.0.4/docs/001_overview.md +26 -0
  12. bh_pixelfuse-0.0.4/docs/002_api.md +46 -0
  13. bh_pixelfuse-0.0.4/docs/003_configuration.md +15 -0
  14. bh_pixelfuse-0.0.4/docs/004_development.md +51 -0
  15. bh_pixelfuse-0.0.4/docs/005_deployment.md +22 -0
  16. bh_pixelfuse-0.0.4/pyproject.toml +67 -0
  17. bh_pixelfuse-0.0.4/render.yaml +15 -0
  18. bh_pixelfuse-0.0.4/setup.cfg +4 -0
  19. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/PKG-INFO +82 -0
  20. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/SOURCES.txt +36 -0
  21. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/dependency_links.txt +1 -0
  22. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/entry_points.txt +2 -0
  23. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/requires.txt +14 -0
  24. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/scm_file_list.json +32 -0
  25. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/scm_version.json +8 -0
  26. bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/top_level.txt +1 -0
  27. bh_pixelfuse-0.0.4/src/pixelfuse/__init__.py +1 -0
  28. bh_pixelfuse-0.0.4/src/pixelfuse/api/__init__.py +0 -0
  29. bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/__init__.py +0 -0
  30. bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/convert.py +50 -0
  31. bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/extract.py +52 -0
  32. bh_pixelfuse-0.0.4/src/pixelfuse/cli.py +47 -0
  33. bh_pixelfuse-0.0.4/src/pixelfuse/config.py +25 -0
  34. bh_pixelfuse-0.0.4/src/pixelfuse/main.py +41 -0
  35. bh_pixelfuse-0.0.4/tests/__init__.py +0 -0
  36. bh_pixelfuse-0.0.4/tests/conftest.py +19 -0
  37. bh_pixelfuse-0.0.4/tests/test_convert.py +34 -0
  38. bh_pixelfuse-0.0.4/tests/test_extract.py +33 -0
@@ -0,0 +1,9 @@
1
+ # Copy to .env and adjust values. All keys prefixed PIXELFUSE_ override defaults.
2
+
3
+ PIXELFUSE_HOST=0.0.0.0
4
+ PIXELFUSE_PORT=8000
5
+ PIXELFUSE_LOG_LEVEL=info
6
+ PIXELFUSE_MAX_UPLOAD_FILES=10
7
+
8
+ # Required: JSON array of allowed CORS origins — no default, must be set explicitly
9
+ PIXELFUSE_ALLOWED_ORIGINS=["https://your-frontend-domain.com"]
@@ -0,0 +1,3 @@
1
+ [flake8]
2
+ max-line-length = 88
3
+ extend-ignore = E203, W503
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ lint:
18
+ name: Lint
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v6
24
+ with:
25
+ python-version: "3.11"
26
+ cache: pip
27
+
28
+ - name: Install tox
29
+ run: pip install tox==4.23.2
30
+
31
+ - name: Run lint
32
+ run: tox -e lint
33
+
34
+ test:
35
+ name: Test (${{ matrix.python-version }})
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ fail-fast: false
39
+ matrix:
40
+ python-version: ["3.11", "3.12", "3.13"]
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+
44
+ - uses: actions/setup-python@v6
45
+ with:
46
+ python-version: ${{ matrix.python-version }}
47
+ cache: pip
48
+
49
+ - name: Install tox
50
+ run: pip install tox==4.23.2
51
+
52
+ - name: Run tests
53
+ run: tox -e test
@@ -0,0 +1,91 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: Test
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: "3.11"
21
+ cache: pip
22
+
23
+ - name: Install tox
24
+ run: pip install tox==4.23.2
25
+
26
+ - name: Run tests
27
+ run: tox -e test
28
+
29
+ docker:
30
+ name: Push to DockerHub
31
+ runs-on: ubuntu-latest
32
+ needs: [test]
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ with:
36
+ fetch-depth: 0
37
+
38
+ - name: Log in to DockerHub
39
+ uses: docker/login-action@v4
40
+ with:
41
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
42
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
43
+
44
+ - name: Extract image metadata
45
+ id: meta
46
+ uses: docker/metadata-action@v6
47
+ with:
48
+ images: ${{ secrets.DOCKERHUB_USERNAME }}/pixelfuse
49
+ tags: |
50
+ type=semver,pattern={{version}}
51
+ type=semver,pattern={{major}}.{{minor}}
52
+ type=raw,value=latest
53
+
54
+ - name: Set up Docker Buildx
55
+ uses: docker/setup-buildx-action@v4
56
+
57
+ - name: Build and push
58
+ uses: docker/build-push-action@v7
59
+ with:
60
+ context: .
61
+ push: true
62
+ tags: ${{ steps.meta.outputs.tags }}
63
+ labels: ${{ steps.meta.outputs.labels }}
64
+ build-args: |
65
+ SETUPTOOLS_SCM_PRETEND_VERSION=${{ steps.meta.outputs.version }}
66
+ cache-from: type=gha
67
+ cache-to: type=gha,mode=max
68
+
69
+ pypi:
70
+ name: Publish to PyPI
71
+ runs-on: ubuntu-latest
72
+ needs: [test]
73
+ environment: release
74
+ permissions:
75
+ id-token: write
76
+ steps:
77
+ - uses: actions/checkout@v4
78
+ with:
79
+ fetch-depth: 0
80
+
81
+ - uses: actions/setup-python@v6
82
+ with:
83
+ python-version: "3.11"
84
+
85
+ - name: Build package
86
+ run: |
87
+ pip install build
88
+ python -m build
89
+
90
+ - name: Publish to PyPI
91
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ venv/
12
+ .venv/
13
+ env/
14
+
15
+ # Package / install
16
+ *.egg
17
+ site-packages/
18
+
19
+ # Test & coverage
20
+ .pytest_cache/
21
+ .coverage
22
+ htmlcov/
23
+ .mypy_cache/
24
+ .ruff_cache/
25
+
26
+ # Environment
27
+ .env
28
+
29
+ # OS
30
+ .DS_Store
31
+
32
+ # Render / output artefacts
33
+ extracted_images/
34
+ *.txt
35
+ !.env.example
@@ -0,0 +1,32 @@
1
+ FROM python:3.11-slim AS builder
2
+
3
+ WORKDIR /build
4
+
5
+ RUN python -m venv /opt/venv
6
+ ENV PATH="/opt/venv/bin:$PATH"
7
+
8
+ COPY pyproject.toml README.md ./
9
+ COPY src/ src/
10
+
11
+ ARG SETUPTOOLS_SCM_PRETEND_VERSION
12
+ RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
13
+ && SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} pip install --no-cache-dir .
14
+
15
+
16
+ FROM python:3.11-slim
17
+
18
+ RUN useradd --create-home --shell /bin/bash app
19
+
20
+ COPY --from=builder /opt/venv /opt/venv
21
+
22
+ ENV PATH="/opt/venv/bin:$PATH" \
23
+ PIXELFUSE_HOST=0.0.0.0 \
24
+ PIXELFUSE_PORT=8000 \
25
+ PIXELFUSE_LOG_LEVEL=info
26
+
27
+ WORKDIR /app
28
+ USER app
29
+
30
+ EXPOSE 8000
31
+
32
+ CMD ["uvicorn", "pixelfuse.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: bh_pixelfuse
3
+ Version: 0.0.4
4
+ Summary: Embed images into portable text files and extract them back
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: uvicorn[standard]>=0.31.0
9
+ Requires-Dist: Pillow>=10.0.0
10
+ Requires-Dist: python-multipart>=0.0.12
11
+ Requires-Dist: pydantic>=2.9.0
12
+ Requires-Dist: pydantic-settings>=2.6.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
16
+ Requires-Dist: httpx>=0.27; extra == "dev"
17
+ Requires-Dist: flake8>=7.0; extra == "dev"
18
+ Requires-Dist: mypy>=1.11; extra == "dev"
19
+ Requires-Dist: tox>=4.0; extra == "dev"
20
+
21
+ # PixelFuse Backend
22
+
23
+ FastAPI service that embeds images into portable text files and extracts them back.
24
+
25
+ ## Docs
26
+
27
+ | # | File | Description |
28
+ |---|------|-------------|
29
+ | 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
30
+ | 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
31
+ | 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
32
+ | 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
33
+ | 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
34
+
35
+ ## Endpoints
36
+
37
+ | Method | Path | Description |
38
+ |--------|------|-------------|
39
+ | POST | `/convert-embed/` | Upload images → download base64 text file |
40
+ | POST | `/extract-images/` | Upload text file → download ZIP of images |
41
+
42
+ Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
43
+
44
+ **No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
45
+
46
+ ## Quickstart
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ pixelfuse serve --reload
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ All settings are overridable via environment variables prefixed `PIXELFUSE_`:
56
+
57
+ | Variable | Default | Description |
58
+ |----------|---------|-------------|
59
+ | `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
60
+ | `PIXELFUSE_PORT` | `8000` | Bind port |
61
+ | `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
62
+ | `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
63
+ | `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
64
+
65
+ Copy `.env.example` to `.env` for local overrides.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ # Lint
71
+ flake8 src tests
72
+
73
+ # Type check
74
+ mypy src
75
+
76
+ # All environments
77
+ tox
78
+ ```
79
+
80
+ ## Deployment
81
+
82
+ Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
@@ -0,0 +1,62 @@
1
+ # PixelFuse Backend
2
+
3
+ FastAPI service that embeds images into portable text files and extracts them back.
4
+
5
+ ## Docs
6
+
7
+ | # | File | Description |
8
+ |---|------|-------------|
9
+ | 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
10
+ | 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
11
+ | 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
12
+ | 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
13
+ | 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
14
+
15
+ ## Endpoints
16
+
17
+ | Method | Path | Description |
18
+ |--------|------|-------------|
19
+ | POST | `/convert-embed/` | Upload images → download base64 text file |
20
+ | POST | `/extract-images/` | Upload text file → download ZIP of images |
21
+
22
+ Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
23
+
24
+ **No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
25
+
26
+ ## Quickstart
27
+
28
+ ```bash
29
+ pip install -e ".[dev]"
30
+ pixelfuse serve --reload
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ All settings are overridable via environment variables prefixed `PIXELFUSE_`:
36
+
37
+ | Variable | Default | Description |
38
+ |----------|---------|-------------|
39
+ | `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
40
+ | `PIXELFUSE_PORT` | `8000` | Bind port |
41
+ | `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
42
+ | `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
43
+ | `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
44
+
45
+ Copy `.env.example` to `.env` for local overrides.
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ # Lint
51
+ flake8 src tests
52
+
53
+ # Type check
54
+ mypy src
55
+
56
+ # All environments
57
+ tox
58
+ ```
59
+
60
+ ## Deployment
61
+
62
+ Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
@@ -0,0 +1,13 @@
1
+ services:
2
+ api:
3
+ build: .
4
+ ports:
5
+ - "8000:8000"
6
+ env_file:
7
+ - .env
8
+ restart: unless-stopped
9
+ read_only: true
10
+ tmpfs:
11
+ - /tmp
12
+ security_opt:
13
+ - no-new-privileges:true
@@ -0,0 +1,26 @@
1
+ # Overview
2
+
3
+ PixelFuse is a FastAPI service that converts images into a portable plain-text file and reverses the process.
4
+
5
+ **Embed flow:** Upload images → get a `.txt` file with base64-encoded image blocks.
6
+ **Extract flow:** Upload that `.txt` file → get a `.zip` of the original images.
7
+
8
+ Supported formats: JPEG, PNG, HEIC.
9
+
10
+ ## Text file format
11
+
12
+ ```
13
+ Filename: my-export
14
+
15
+ Image 1 (photo.jpg):
16
+ <base64 data>
17
+
18
+ Image 2 (shot.png):
19
+ <base64 data>
20
+ ```
21
+
22
+ Each block is separated by a blank line. The `Filename:` header line carries the export name.
23
+
24
+ ## Lossless guarantee
25
+
26
+ Raw image bytes are base64-encoded directly — no re-encoding at any stage. Embed → extract produces bit-for-bit identical files.
@@ -0,0 +1,46 @@
1
+ # API Reference
2
+
3
+ Base URL: `http://localhost:8000`
4
+
5
+ ---
6
+
7
+ ## POST `/convert-embed/`
8
+
9
+ Embeds images into a plain-text file.
10
+
11
+ **Request** — `multipart/form-data`
12
+
13
+ | Field | Type | Description |
14
+ |-------|------|-------------|
15
+ | `files` | file[] | Images to embed (JPEG, PNG, HEIC). Max 10. |
16
+ | `output_file_name` | string | Name for the output `.txt` file (no extension). |
17
+
18
+ **Response** — `text/plain` download (`<output_file_name>.txt`)
19
+
20
+ **Errors**
21
+
22
+ | Status | Reason |
23
+ |--------|--------|
24
+ | 400 | More than `max_upload_files` files sent |
25
+ | 400 | File cannot be opened as an image |
26
+
27
+ ---
28
+
29
+ ## POST `/extract-images/`
30
+
31
+ Extracts images from a PixelFuse text file.
32
+
33
+ **Request** — `multipart/form-data`
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `file` | file | A `.txt` file produced by `/convert-embed/` |
38
+
39
+ **Response** — `application/zip` download (`extracted_images.zip`)
40
+
41
+ **Errors**
42
+
43
+ | Status | Reason |
44
+ |--------|--------|
45
+ | 400 | No valid image blocks found |
46
+ | 400 | A block could not be decoded |
@@ -0,0 +1,15 @@
1
+ # Configuration
2
+
3
+ All settings are read from environment variables prefixed `PIXELFUSE_`. A `.env` file in the project root is loaded automatically.
4
+
5
+ | Variable | Default | Description |
6
+ |----------|---------|-------------|
7
+ | `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
8
+ | `PIXELFUSE_PORT` | `8000` | Bind port |
9
+ | `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
10
+ | `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per `/convert-embed/` request |
11
+ | `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | JSON array of CORS origins, e.g. `["http://localhost:3000"]` |
12
+
13
+ Copy `.env.example` to `.env` and fill in `PIXELFUSE_ALLOWED_ORIGINS` before running locally.
14
+
15
+ CLI flags (`--host`, `--port`, `--log-level`) override env vars when passed to `pixelfuse serve`.
@@ -0,0 +1,51 @@
1
+ # Development
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ pip install -e ".[dev]"
7
+ cp .env.example .env # fill in PIXELFUSE_ALLOWED_ORIGINS
8
+ pixelfuse serve --reload
9
+ ```
10
+
11
+ ## Running tests
12
+
13
+ ```bash
14
+ pytest
15
+ ```
16
+
17
+ ## Lint & type check
18
+
19
+ ```bash
20
+ flake8 src tests
21
+ mypy src
22
+ ```
23
+
24
+ Run all environments at once:
25
+
26
+ ```bash
27
+ tox
28
+ ```
29
+
30
+ ## Docker
31
+
32
+ ```bash
33
+ docker compose up --build
34
+ ```
35
+
36
+ The compose file mounts `.env` automatically. The server is available at `http://localhost:8000`.
37
+
38
+ ## Project layout
39
+
40
+ ```
41
+ src/pixelfuse/
42
+ api/routes/
43
+ convert.py # POST /convert-embed/
44
+ extract.py # POST /extract-images/
45
+ cli.py # pixelfuse serve entry point
46
+ config.py # pydantic-settings config
47
+ main.py # FastAPI app factory
48
+ tests/
49
+ test_convert.py
50
+ test_extract.py
51
+ ```
@@ -0,0 +1,22 @@
1
+ # Deployment
2
+
3
+ ## Render
4
+
5
+ The service is configured for [Render](https://render.com) via `render.yaml`. On every push to `main`:
6
+
7
+ 1. Render builds the Docker image from `Dockerfile`.
8
+ 2. The container starts with `pixelfuse serve`.
9
+ 3. Set `PIXELFUSE_ALLOWED_ORIGINS` in the Render environment dashboard.
10
+
11
+ ## CI
12
+
13
+ GitHub Actions runs lint, type check, and tests on every push and pull request to `main`. See `.github/workflows/ci.yml`.
14
+
15
+ ## Docker (self-hosted)
16
+
17
+ ```bash
18
+ docker build -t pixelfuse .
19
+ docker run -p 8000:8000 \
20
+ -e PIXELFUSE_ALLOWED_ORIGINS='["https://yourdomain.com"]' \
21
+ pixelfuse
22
+ ```
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel", "setuptools-scm"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bh_pixelfuse"
7
+ dynamic = ["version"]
8
+ description = "Embed images into portable text files and extract them back"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "fastapi>=0.115.0",
13
+ "uvicorn[standard]>=0.31.0",
14
+ "Pillow>=10.0.0",
15
+ "python-multipart>=0.0.12",
16
+ "pydantic>=2.9.0",
17
+ "pydantic-settings>=2.6.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "pytest-asyncio>=0.24",
24
+ "httpx>=0.27",
25
+ "flake8>=7.0",
26
+ "mypy>=1.11",
27
+ "tox>=4.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ pixelfuse = "pixelfuse.cli:cli"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools_scm]
37
+ version_scheme = "post-release"
38
+ fallback_version = "0.0.0"
39
+
40
+
41
+ [tool.mypy]
42
+ python_version = "3.11"
43
+ strict = true
44
+ ignore_missing_imports = true
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ asyncio_mode = "auto"
49
+
50
+ [tool.tox]
51
+ legacy_tox_ini = """
52
+ [tox]
53
+ envlist = lint, typecheck, test
54
+ isolated_build = true
55
+
56
+ [testenv:lint]
57
+ deps = flake8>=7.0
58
+ commands = flake8 src tests
59
+
60
+ [testenv:typecheck]
61
+ deps = mypy>=1.11
62
+ commands = mypy src
63
+
64
+ [testenv:test]
65
+ extras = dev
66
+ commands = pytest --tb=short -q
67
+ """
@@ -0,0 +1,15 @@
1
+ services:
2
+ - type: web
3
+ name: pixelfuse-backend
4
+ runtime: python
5
+ buildCommand: pip install -e .
6
+ startCommand: pixelfuse serve
7
+ envVars:
8
+ - key: PIXELFUSE_HOST
9
+ value: 0.0.0.0
10
+ - key: PIXELFUSE_PORT
11
+ value: 10000
12
+ - key: PIXELFUSE_LOG_LEVEL
13
+ value: info
14
+ - key: PIXELFUSE_ALLOWED_ORIGINS
15
+ sync: false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: bh_pixelfuse
3
+ Version: 0.0.4
4
+ Summary: Embed images into portable text files and extract them back
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: uvicorn[standard]>=0.31.0
9
+ Requires-Dist: Pillow>=10.0.0
10
+ Requires-Dist: python-multipart>=0.0.12
11
+ Requires-Dist: pydantic>=2.9.0
12
+ Requires-Dist: pydantic-settings>=2.6.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
16
+ Requires-Dist: httpx>=0.27; extra == "dev"
17
+ Requires-Dist: flake8>=7.0; extra == "dev"
18
+ Requires-Dist: mypy>=1.11; extra == "dev"
19
+ Requires-Dist: tox>=4.0; extra == "dev"
20
+
21
+ # PixelFuse Backend
22
+
23
+ FastAPI service that embeds images into portable text files and extracts them back.
24
+
25
+ ## Docs
26
+
27
+ | # | File | Description |
28
+ |---|------|-------------|
29
+ | 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
30
+ | 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
31
+ | 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
32
+ | 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
33
+ | 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
34
+
35
+ ## Endpoints
36
+
37
+ | Method | Path | Description |
38
+ |--------|------|-------------|
39
+ | POST | `/convert-embed/` | Upload images → download base64 text file |
40
+ | POST | `/extract-images/` | Upload text file → download ZIP of images |
41
+
42
+ Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
43
+
44
+ **No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
45
+
46
+ ## Quickstart
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ pixelfuse serve --reload
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ All settings are overridable via environment variables prefixed `PIXELFUSE_`:
56
+
57
+ | Variable | Default | Description |
58
+ |----------|---------|-------------|
59
+ | `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
60
+ | `PIXELFUSE_PORT` | `8000` | Bind port |
61
+ | `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
62
+ | `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
63
+ | `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
64
+
65
+ Copy `.env.example` to `.env` for local overrides.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ # Lint
71
+ flake8 src tests
72
+
73
+ # Type check
74
+ mypy src
75
+
76
+ # All environments
77
+ tox
78
+ ```
79
+
80
+ ## Deployment
81
+
82
+ Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
@@ -0,0 +1,36 @@
1
+ .env.example
2
+ .flake8
3
+ .gitignore
4
+ Dockerfile
5
+ README.md
6
+ docker-compose.yml
7
+ pyproject.toml
8
+ render.yaml
9
+ .github/dependabot.yml
10
+ .github/workflows/ci.yml
11
+ .github/workflows/release.yml
12
+ docs/001_overview.md
13
+ docs/002_api.md
14
+ docs/003_configuration.md
15
+ docs/004_development.md
16
+ docs/005_deployment.md
17
+ src/bh_pixelfuse.egg-info/PKG-INFO
18
+ src/bh_pixelfuse.egg-info/SOURCES.txt
19
+ src/bh_pixelfuse.egg-info/dependency_links.txt
20
+ src/bh_pixelfuse.egg-info/entry_points.txt
21
+ src/bh_pixelfuse.egg-info/requires.txt
22
+ src/bh_pixelfuse.egg-info/scm_file_list.json
23
+ src/bh_pixelfuse.egg-info/scm_version.json
24
+ src/bh_pixelfuse.egg-info/top_level.txt
25
+ src/pixelfuse/__init__.py
26
+ src/pixelfuse/cli.py
27
+ src/pixelfuse/config.py
28
+ src/pixelfuse/main.py
29
+ src/pixelfuse/api/__init__.py
30
+ src/pixelfuse/api/routes/__init__.py
31
+ src/pixelfuse/api/routes/convert.py
32
+ src/pixelfuse/api/routes/extract.py
33
+ tests/__init__.py
34
+ tests/conftest.py
35
+ tests/test_convert.py
36
+ tests/test_extract.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pixelfuse = pixelfuse.cli:cli
@@ -0,0 +1,14 @@
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.31.0
3
+ Pillow>=10.0.0
4
+ python-multipart>=0.0.12
5
+ pydantic>=2.9.0
6
+ pydantic-settings>=2.6.0
7
+
8
+ [dev]
9
+ pytest>=8.0
10
+ pytest-asyncio>=0.24
11
+ httpx>=0.27
12
+ flake8>=7.0
13
+ mypy>=1.11
14
+ tox>=4.0
@@ -0,0 +1,32 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "Dockerfile",
5
+ "pyproject.toml",
6
+ ".env.example",
7
+ ".gitignore",
8
+ "docker-compose.yml",
9
+ "render.yaml",
10
+ ".flake8",
11
+ "docs/001_overview.md",
12
+ "docs/004_development.md",
13
+ "docs/005_deployment.md",
14
+ "docs/002_api.md",
15
+ "docs/003_configuration.md",
16
+ "src/pixelfuse/__init__.py",
17
+ "src/pixelfuse/config.py",
18
+ "src/pixelfuse/main.py",
19
+ "src/pixelfuse/cli.py",
20
+ "src/pixelfuse/api/__init__.py",
21
+ "src/pixelfuse/api/routes/__init__.py",
22
+ "src/pixelfuse/api/routes/extract.py",
23
+ "src/pixelfuse/api/routes/convert.py",
24
+ "tests/__init__.py",
25
+ "tests/test_convert.py",
26
+ "tests/test_extract.py",
27
+ "tests/conftest.py",
28
+ ".github/dependabot.yml",
29
+ ".github/workflows/release.yml",
30
+ ".github/workflows/ci.yml"
31
+ ]
32
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.0.4",
3
+ "distance": 0,
4
+ "node": "g0249107368e1ddfa2497f3d01ab7e9c83c2b8eca",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-22"
8
+ }
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,50 @@
1
+ import base64
2
+ import io
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
6
+ from fastapi.responses import Response
7
+ from PIL import Image
8
+
9
+ from pixelfuse.config import get_settings
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/convert-embed/")
15
+ async def convert_embed_images(
16
+ files: Annotated[list[UploadFile], File()],
17
+ output_file_name: Annotated[str, Form()],
18
+ ) -> Response:
19
+ settings = get_settings()
20
+ if len(files) > settings.max_upload_files:
21
+ raise HTTPException(
22
+ status_code=400,
23
+ detail=f"Maximum {settings.max_upload_files} files allowed.",
24
+ )
25
+
26
+ blocks: list[str] = [f"Filename: {output_file_name}"]
27
+
28
+ for idx, file in enumerate(files):
29
+ content = await file.read()
30
+ filename = file.filename or f"image_{idx + 1}"
31
+ try:
32
+ if filename.lower().endswith(".heic") or file.content_type == "image/heic":
33
+ encoded = base64.b64encode(content).decode()
34
+ else:
35
+ Image.open(io.BytesIO(content)).verify()
36
+ encoded = base64.b64encode(content).decode()
37
+ blocks.append(f"Image {idx + 1} ({filename}):\n{encoded}")
38
+ except Exception as exc:
39
+ raise HTTPException(
40
+ status_code=400,
41
+ detail=f"Cannot process {filename}: {exc}",
42
+ ) from exc
43
+
44
+ return Response(
45
+ content="\n\n".join(blocks),
46
+ media_type="text/plain",
47
+ headers={
48
+ "Content-Disposition": f'attachment; filename="{output_file_name}.txt"'
49
+ },
50
+ )
@@ -0,0 +1,52 @@
1
+ import base64
2
+ import io
3
+ import zipfile
4
+
5
+ from fastapi import APIRouter, File, HTTPException, UploadFile
6
+ from fastapi.responses import StreamingResponse
7
+ from PIL import Image
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.post("/extract-images/")
13
+ async def extract_images_from_text(file: UploadFile = File(...)) -> StreamingResponse:
14
+ content = await file.read()
15
+ blocks = content.decode("utf-8").split("\n\n")
16
+
17
+ zip_buffer = io.BytesIO()
18
+ count = 0
19
+
20
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
21
+ for idx, block in enumerate(blocks):
22
+ if not block.startswith("Image"):
23
+ continue
24
+ try:
25
+ header, encoded = block.split(":", 1)
26
+ img_data = base64.b64decode(encoded.strip())
27
+
28
+ if "heic" in header.lower():
29
+ zf.writestr(f"image_{idx + 1}.heic", img_data)
30
+ else:
31
+ img = Image.open(io.BytesIO(img_data))
32
+ fmt = (img.format or "PNG").lower()
33
+ zf.writestr(f"image_{idx + 1}.{fmt}", img_data)
34
+
35
+ count += 1
36
+ except Exception as exc:
37
+ raise HTTPException(
38
+ status_code=400,
39
+ detail=f"Error processing block {idx + 1}: {exc}",
40
+ ) from exc
41
+
42
+ if count == 0:
43
+ raise HTTPException(status_code=400, detail="No images found in file.")
44
+
45
+ zip_buffer.seek(0)
46
+ return StreamingResponse(
47
+ zip_buffer,
48
+ media_type="application/zip",
49
+ headers={
50
+ "Content-Disposition": 'attachment; filename="extracted_images.zip"'
51
+ },
52
+ )
@@ -0,0 +1,47 @@
1
+ import argparse
2
+
3
+ import uvicorn
4
+
5
+ from pixelfuse.config import get_settings
6
+
7
+
8
+ def serve(args: argparse.Namespace) -> None:
9
+ cfg = get_settings()
10
+ uvicorn.run(
11
+ "pixelfuse.main:app",
12
+ host=args.host or cfg.host,
13
+ port=args.port or cfg.port,
14
+ log_level=args.log_level or cfg.log_level,
15
+ reload=args.reload,
16
+ )
17
+
18
+
19
+ def cli() -> None:
20
+ parser = argparse.ArgumentParser(
21
+ prog="pixelfuse",
22
+ description="PixelFuse — image embedding API server.",
23
+ )
24
+ sub = parser.add_subparsers(dest="command", required=True)
25
+
26
+ serve_cmd = sub.add_parser("serve", help="Start the API server.")
27
+ serve_cmd.add_argument(
28
+ "--host", default=None, help="Bind host (overrides PIXELFUSE_HOST)"
29
+ )
30
+ serve_cmd.add_argument(
31
+ "--port", type=int, default=None, help="Bind port (overrides PIXELFUSE_PORT)"
32
+ )
33
+ serve_cmd.add_argument(
34
+ "--reload", action="store_true", help="Enable auto-reload (dev only)"
35
+ )
36
+ serve_cmd.add_argument(
37
+ "--log-level", dest="log_level", default=None,
38
+ help="Log level (overrides PIXELFUSE_LOG_LEVEL)",
39
+ )
40
+
41
+ args = parser.parse_args()
42
+ if args.command == "serve":
43
+ serve(args)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ cli()
@@ -0,0 +1,25 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ host: str = "0.0.0.0"
6
+ port: int = 8000
7
+ allowed_origins: list[str] = []
8
+ max_upload_files: int = 10
9
+ log_level: str = "info"
10
+
11
+ model_config = SettingsConfigDict(
12
+ env_file=".env",
13
+ env_prefix="PIXELFUSE_",
14
+ env_file_encoding="utf-8",
15
+ )
16
+
17
+
18
+ _settings: Settings | None = None
19
+
20
+
21
+ def get_settings() -> Settings:
22
+ global _settings
23
+ if _settings is None:
24
+ _settings = Settings()
25
+ return _settings
@@ -0,0 +1,41 @@
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+
5
+ from pixelfuse.api.routes import convert, extract
6
+ from pixelfuse.config import Settings, get_settings
7
+
8
+ MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
9
+
10
+
11
+ def create_app(settings: Settings | None = None) -> FastAPI:
12
+ cfg = settings or get_settings()
13
+ app = FastAPI(
14
+ title="PixelFuse API",
15
+ description="Embed images into portable text files and extract them back.",
16
+ version="1.0.0",
17
+ )
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=cfg.allowed_origins,
21
+ allow_credentials=False,
22
+ allow_methods=["POST"],
23
+ allow_headers=["Content-Type"],
24
+ )
25
+
26
+ @app.middleware("http")
27
+ async def limit_upload_size(request: Request, call_next: object) -> object:
28
+ content_length = request.headers.get("content-length")
29
+ if content_length and int(content_length) > MAX_UPLOAD_BYTES:
30
+ return JSONResponse(
31
+ status_code=413,
32
+ content={"detail": "Upload exceeds 20 MB limit."},
33
+ )
34
+ return await call_next(request) # type: ignore[operator]
35
+
36
+ app.include_router(convert.router)
37
+ app.include_router(extract.router)
38
+ return app
39
+
40
+
41
+ app = create_app()
File without changes
@@ -0,0 +1,19 @@
1
+ import pytest
2
+ from httpx import ASGITransport, AsyncClient
3
+
4
+ from pixelfuse.config import Settings
5
+ from pixelfuse.main import create_app
6
+
7
+
8
+ @pytest.fixture
9
+ def settings() -> Settings:
10
+ return Settings(allowed_origins=["*"])
11
+
12
+
13
+ @pytest.fixture
14
+ async def client(settings: Settings) -> AsyncClient:
15
+ app = create_app(settings)
16
+ async with AsyncClient(
17
+ transport=ASGITransport(app=app), base_url="http://test"
18
+ ) as ac:
19
+ yield ac # type: ignore[misc]
@@ -0,0 +1,34 @@
1
+ import io
2
+
3
+ from httpx import AsyncClient
4
+ from PIL import Image
5
+
6
+
7
+ def _make_png() -> bytes:
8
+ buf = io.BytesIO()
9
+ Image.new("RGB", (10, 10), color="red").save(buf, format="PNG")
10
+ return buf.getvalue()
11
+
12
+
13
+ async def test_convert_embed_returns_text(client: AsyncClient) -> None:
14
+ response = await client.post(
15
+ "/convert-embed/",
16
+ files=[("files", ("test.png", _make_png(), "image/png"))],
17
+ data={"output_file_name": "output"},
18
+ )
19
+ assert response.status_code == 200
20
+ assert "Image 1 (test.png)" in response.text
21
+ assert response.headers["content-disposition"] == (
22
+ 'attachment; filename="output.txt"'
23
+ )
24
+
25
+
26
+ async def test_convert_embed_too_many_files(client: AsyncClient) -> None:
27
+ files = [("files", (f"img{i}.png", _make_png(), "image/png")) for i in range(11)]
28
+ response = await client.post(
29
+ "/convert-embed/",
30
+ files=files,
31
+ data={"output_file_name": "output"},
32
+ )
33
+ assert response.status_code == 400
34
+ assert "Maximum" in response.json()["detail"]
@@ -0,0 +1,33 @@
1
+ import base64
2
+ import io
3
+ import zipfile
4
+
5
+ from httpx import AsyncClient
6
+ from PIL import Image
7
+
8
+
9
+ def _make_embed_text(filename: str = "test.png") -> bytes:
10
+ buf = io.BytesIO()
11
+ Image.new("RGB", (10, 10), color="blue").save(buf, format="PNG")
12
+ encoded = base64.b64encode(buf.getvalue()).decode()
13
+ return f"Filename: test\n\nImage 1 ({filename}):\n{encoded}".encode()
14
+
15
+
16
+ async def test_extract_images_returns_zip(client: AsyncClient) -> None:
17
+ response = await client.post(
18
+ "/extract-images/",
19
+ files=[("file", ("embed.txt", _make_embed_text(), "text/plain"))],
20
+ )
21
+ assert response.status_code == 200
22
+ assert response.headers["content-type"] == "application/zip"
23
+ zf = zipfile.ZipFile(io.BytesIO(response.content))
24
+ assert len(zf.namelist()) == 1
25
+
26
+
27
+ async def test_extract_no_images_returns_400(client: AsyncClient) -> None:
28
+ response = await client.post(
29
+ "/extract-images/",
30
+ files=[("file", ("empty.txt", b"no images here", "text/plain"))],
31
+ )
32
+ assert response.status_code == 400
33
+ assert "No images found" in response.json()["detail"]