bh-pixelfuse 1.0.0__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.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: bh_pixelfuse
3
+ Version: 1.0.0
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,63 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bh_pixelfuse"
7
+ version = "1.0.0"
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
+
37
+ [tool.mypy]
38
+ python_version = "3.11"
39
+ strict = true
40
+ ignore_missing_imports = true
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ asyncio_mode = "auto"
45
+
46
+ [tool.tox]
47
+ legacy_tox_ini = """
48
+ [tox]
49
+ envlist = lint, typecheck, test
50
+ isolated_build = true
51
+
52
+ [testenv:lint]
53
+ deps = flake8>=7.0
54
+ commands = flake8 src tests
55
+
56
+ [testenv:typecheck]
57
+ deps = mypy>=1.11
58
+ commands = mypy src
59
+
60
+ [testenv:test]
61
+ extras = dev
62
+ commands = pytest --tb=short -q
63
+ """
@@ -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: 1.0.0
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,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/bh_pixelfuse.egg-info/PKG-INFO
4
+ src/bh_pixelfuse.egg-info/SOURCES.txt
5
+ src/bh_pixelfuse.egg-info/dependency_links.txt
6
+ src/bh_pixelfuse.egg-info/entry_points.txt
7
+ src/bh_pixelfuse.egg-info/requires.txt
8
+ src/bh_pixelfuse.egg-info/top_level.txt
9
+ src/pixelfuse/__init__.py
10
+ src/pixelfuse/cli.py
11
+ src/pixelfuse/config.py
12
+ src/pixelfuse/main.py
13
+ src/pixelfuse/api/__init__.py
14
+ src/pixelfuse/api/routes/__init__.py
15
+ src/pixelfuse/api/routes/convert.py
16
+ src/pixelfuse/api/routes/extract.py
17
+ tests/test_convert.py
18
+ 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 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,49 @@
1
+ import base64
2
+ import io
3
+
4
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
5
+ from fastapi.responses import Response
6
+ from PIL import Image
7
+
8
+ from pixelfuse.config import get_settings
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/convert-embed/")
14
+ async def convert_embed_images(
15
+ files: list[UploadFile] = File(...),
16
+ output_file_name: str = Form(...),
17
+ ) -> Response:
18
+ settings = get_settings()
19
+ if len(files) > settings.max_upload_files:
20
+ raise HTTPException(
21
+ status_code=400,
22
+ detail=f"Maximum {settings.max_upload_files} files allowed.",
23
+ )
24
+
25
+ blocks: list[str] = [f"Filename: {output_file_name}"]
26
+
27
+ for idx, file in enumerate(files):
28
+ content = await file.read()
29
+ filename = file.filename or f"image_{idx + 1}"
30
+ try:
31
+ if filename.lower().endswith(".heic") or file.content_type == "image/heic":
32
+ encoded = base64.b64encode(content).decode()
33
+ else:
34
+ Image.open(io.BytesIO(content)).verify()
35
+ encoded = base64.b64encode(content).decode()
36
+ blocks.append(f"Image {idx + 1} ({filename}):\n{encoded}")
37
+ except Exception as exc:
38
+ raise HTTPException(
39
+ status_code=400,
40
+ detail=f"Cannot process {filename}: {exc}",
41
+ ) from exc
42
+
43
+ return Response(
44
+ content="\n\n".join(blocks),
45
+ media_type="text/plain",
46
+ headers={
47
+ "Content-Disposition": f'attachment; filename="{output_file_name}.txt"'
48
+ },
49
+ )
@@ -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()
@@ -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"]