Skip to content

Commit 6841266

Browse files
authored
Merge pull request #121 from 4teamwork/docker-service
Provide a web service as a Docker container for composing documents
2 parents 9f1fe1f + 9c3440f commit 6841266

11 files changed

Lines changed: 1173 additions & 4 deletions

File tree

.dockerignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.github
2+
.venv
3+
tests

.github/workflows/build_image.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Build Docker image
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
tags:
7+
- '*.*.*'
8+
branches:
9+
- master
10+
11+
jobs:
12+
build:
13+
runs-on: self-hosted
14+
permissions:
15+
contents: read
16+
packages: write
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
with:
21+
fetch-depth: 0
22+
fetch-tags: true
23+
24+
- name: Set up QEMU
25+
uses: docker/setup-qemu-action@v3
26+
27+
- name: Set up Docker Buildx
28+
uses: docker/setup-buildx-action@v3
29+
with:
30+
driver: remote
31+
endpoint: tcp://buildkitd.buildx:1234
32+
33+
- name: Login to DockerHub
34+
uses: docker/login-action@v3
35+
with:
36+
username: ${{ secrets.DOCKERHUB_USERNAME }}
37+
password: ${{ secrets.DOCKERHUB_TOKEN }}
38+
39+
- name: Set Git commit env variables
40+
run: |
41+
echo "GIT_TAG=$(git describe --tags --candidates=0)" >> $GITHUB_ENV
42+
echo "GIT_SHA_TAG=$(git describe --tags)" >> $GITHUB_ENV
43+
echo "LATEST_TAG=$(git describe --tags --abbrev=0 master)" >> $GITHUB_ENV
44+
echo "BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_ENV
45+
46+
- name: Build and push image
47+
uses: docker/bake-action@v6
48+
with:
49+
source: .
50+
files: docker-bake.hcl
51+
push: true

Dockerfile

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
FROM python:3.14-alpine AS base
2+
3+
RUN addgroup -S -g 8080 docxcompose \
4+
&& adduser -S -D -G docxcompose -u 8080 docxcompose
5+
6+
ENV PYTHONUNBUFFERED=1
7+
WORKDIR /app
8+
9+
RUN echo "/app/lib/python3.14/site-packages/" > /usr/local/lib/python3.14/site-packages/app.pth \
10+
&& apk add --no-cache \
11+
libxml2 \
12+
libxslt
13+
14+
15+
FROM base AS builder
16+
17+
RUN apk add --no-cache \
18+
gcc \
19+
musl-dev \
20+
libxml2-dev \
21+
libxslt-dev \
22+
pipx
23+
24+
RUN pipx install poetry \
25+
&& pipx inject poetry poetry-plugin-export
26+
27+
COPY pyproject.toml poetry.lock ./
28+
29+
RUN /root/.local/bin/poetry export -f requirements.txt --extras server --output requirements.txt \
30+
&& pip install --no-cache-dir --no-warn-script-location --prefix ./ -r requirements.txt --no-binary lxml
31+
32+
COPY docxcompose docxcompose
33+
COPY README.rst .
34+
RUN pip install --no-cache-dir --prefix ./ .
35+
RUN rm -rf docxcompose pyproject.toml poetry.lock poetry.toml README.rst requirements.txt
36+
37+
38+
FROM base AS prod
39+
40+
COPY --from=builder /app /app
41+
USER docxcompose
42+
EXPOSE 8080
43+
CMD ["/app/bin/docxcompose-server"]

README.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ line, e.g.:
3131
$ docxcompose files/master.docx files/content.docx -o files/composed.docx
3232
3333
34+
Docker container
35+
----------------
36+
37+
docxcompose is also available as a Docker container allowing to compose docx
38+
documents through a web service.
39+
40+
To start the web service, run:
41+
42+
.. code:: sh
43+
44+
$ docker run -it --rm -p 8080:8080 4teamwork/docxcompose
45+
46+
To compose documents, just upload them in the desired order as a ``multipart/form-data``
47+
request to the web service and you will get back the composed document. Example with curl:
48+
49+
.. code:: sh
50+
51+
$ curl -F "first=@first.docx" -F "second=@second.docx" -o composed.docx http://localhost:8080/
52+
53+
3454
Installation for development
3555
----------------------------
3656

changes/web-service.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Provide a web service as a Docker container for composing documents. [buchi]

compose.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
services:
2+
docxcompose:
3+
build:
4+
context: .
5+
dockerfile: Dockerfile
6+
image: 4teamwork/docxcompose:latest
7+
ports:
8+
- 8080:8080

docker-bake.hcl

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
# To build locally use:
3+
GIT_TAG=$(git describe --tags --candidates=0) \
4+
GIT_SHA_TAG=$(git describe --tags) \
5+
LATEST_TAG=$(git describe --tags --abbrev=0 master) \
6+
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) \
7+
docker buildx bake -f docker-bake.hcl --load
8+
*/
9+
10+
variable "IMAGE_NAME" {
11+
default = "docker.io/4teamwork/docxcompose"
12+
}
13+
variable "GIT_TAG" {
14+
default = ""
15+
}
16+
variable "GIT_SHA_TAG" {
17+
default = ""
18+
}
19+
variable "LATEST_TAG" {
20+
default = ""
21+
}
22+
variable "BRANCH_NAME" {
23+
default = ""
24+
}
25+
26+
target "default" {
27+
dockerfile = "./Dockerfile"
28+
context = "."
29+
target = "prod"
30+
tags = [
31+
strlen(GIT_TAG) > 0 ? "${IMAGE_NAME}:${GIT_TAG}": "",
32+
equal(GIT_TAG, LATEST_TAG) ? "${IMAGE_NAME}:latest": "",
33+
equal(GIT_TAG, "") && equal(BRANCH_NAME, "master") ? "${IMAGE_NAME}:edge": "",
34+
notequal(BRANCH_NAME, "master") && strlen(GIT_TAG) < 1 && strlen(GIT_SHA_TAG) > 0 ? "${IMAGE_NAME}:${GIT_SHA_TAG}": "",
35+
]
36+
platforms = [
37+
"linux/amd64",
38+
strlen(GIT_TAG) > 0 ? "linux/arm64" : "",
39+
]
40+
}

docxcompose/server.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
try:
2+
from aiohttp import web
3+
except ImportError:
4+
raise SystemExit("Install with server extra to use this command.")
5+
import importlib.metadata
6+
import logging
7+
import os.path
8+
import tempfile
9+
10+
from docx import Document
11+
12+
from docxcompose.composer import Composer
13+
14+
15+
CHUNK_SIZE = 65536
16+
logger = logging.getLogger("docxcompose")
17+
version = importlib.metadata.version("docxcompose")
18+
19+
20+
async def compose(request):
21+
22+
documents = []
23+
temp_dir = None
24+
25+
if not request.content_type == "multipart/form-data":
26+
logger.info(
27+
"Bad request. Received content type %s instead of multipart/form-data.",
28+
request.content_type,
29+
)
30+
return web.Response(status=400, text="Multipart request required")
31+
32+
reader = await request.multipart()
33+
34+
with tempfile.TemporaryDirectory() as temp_dir:
35+
while True:
36+
part = await reader.next()
37+
38+
if part is None:
39+
break
40+
41+
if part.filename is None:
42+
continue
43+
44+
documents.append(await save_part_to_file(part, temp_dir))
45+
46+
if not documents:
47+
return web.Response(status=400, text="No documents provided")
48+
49+
composed_filename = os.path.join(temp_dir, "composed.docx")
50+
51+
try:
52+
composer = Composer(Document(documents.pop(0)))
53+
for document in documents:
54+
composer.append(Document(document))
55+
composer.save(composed_filename)
56+
except Exception:
57+
logger.exception("Failed composing documents.")
58+
return web.Response(status=500, text="Failed composing documents")
59+
60+
return await stream_file(
61+
request,
62+
composed_filename,
63+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
64+
)
65+
66+
67+
async def save_part_to_file(part, directory):
68+
filename = os.path.join(directory, f"{part.name}_{part.filename}")
69+
with open(filename, "wb") as file_:
70+
while True:
71+
chunk = await part.read_chunk(CHUNK_SIZE)
72+
if not chunk:
73+
break
74+
file_.write(chunk)
75+
return filename
76+
77+
78+
async def stream_file(request, filename, content_type):
79+
response = web.StreamResponse(
80+
status=200,
81+
reason="OK",
82+
headers={
83+
"Content-Type": content_type,
84+
"Content-Disposition": f'attachment; filename="{os.path.basename(filename)}"',
85+
},
86+
)
87+
await response.prepare(request)
88+
89+
with open(filename, "rb") as outfile:
90+
while True:
91+
data = outfile.read(CHUNK_SIZE)
92+
if not data:
93+
break
94+
await response.write(data)
95+
96+
await response.write_eof()
97+
return response
98+
99+
100+
async def healthcheck(request):
101+
return web.Response(status=200, text="OK")
102+
103+
104+
def create_app():
105+
app = web.Application()
106+
app.add_routes([web.post("/", compose)])
107+
app.add_routes([web.get("/healthcheck", healthcheck)])
108+
return app
109+
110+
111+
def main():
112+
print(f"docxcompose {version}")
113+
logging.basicConfig(
114+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
115+
level=logging.INFO,
116+
)
117+
web.run_app(create_app())
118+
119+
120+
if __name__ == "__main__":
121+
main()

0 commit comments

Comments
 (0)