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.
- bh_pixelfuse-0.0.4/.env.example +9 -0
- bh_pixelfuse-0.0.4/.flake8 +3 -0
- bh_pixelfuse-0.0.4/.github/dependabot.yml +11 -0
- bh_pixelfuse-0.0.4/.github/workflows/ci.yml +53 -0
- bh_pixelfuse-0.0.4/.github/workflows/release.yml +91 -0
- bh_pixelfuse-0.0.4/.gitignore +35 -0
- bh_pixelfuse-0.0.4/Dockerfile +32 -0
- bh_pixelfuse-0.0.4/PKG-INFO +82 -0
- bh_pixelfuse-0.0.4/README.md +62 -0
- bh_pixelfuse-0.0.4/docker-compose.yml +13 -0
- bh_pixelfuse-0.0.4/docs/001_overview.md +26 -0
- bh_pixelfuse-0.0.4/docs/002_api.md +46 -0
- bh_pixelfuse-0.0.4/docs/003_configuration.md +15 -0
- bh_pixelfuse-0.0.4/docs/004_development.md +51 -0
- bh_pixelfuse-0.0.4/docs/005_deployment.md +22 -0
- bh_pixelfuse-0.0.4/pyproject.toml +67 -0
- bh_pixelfuse-0.0.4/render.yaml +15 -0
- bh_pixelfuse-0.0.4/setup.cfg +4 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/PKG-INFO +82 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/SOURCES.txt +36 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/dependency_links.txt +1 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/entry_points.txt +2 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/requires.txt +14 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/scm_file_list.json +32 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/scm_version.json +8 -0
- bh_pixelfuse-0.0.4/src/bh_pixelfuse.egg-info/top_level.txt +1 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/__init__.py +1 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/api/__init__.py +0 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/__init__.py +0 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/convert.py +50 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/api/routes/extract.py +52 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/cli.py +47 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/config.py +25 -0
- bh_pixelfuse-0.0.4/src/pixelfuse/main.py +41 -0
- bh_pixelfuse-0.0.4/tests/__init__.py +0 -0
- bh_pixelfuse-0.0.4/tests/conftest.py +19 -0
- bh_pixelfuse-0.0.4/tests/test_convert.py +34 -0
- 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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
pixelfuse
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
File without changes
|
|
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"]
|