Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.github
.venv
tests
51 changes: 51 additions & 0 deletions .github/workflows/build_image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Build Docker image

on:
workflow_dispatch:
push:
tags:
- '*.*.*'
branches:
- master

jobs:
build:
runs-on: self-hosted
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: tcp://buildkitd.buildx:1234

- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Set Git commit env variables
run: |
echo "GIT_TAG=$(git describe --tags --candidates=0)" >> $GITHUB_ENV
echo "GIT_SHA_TAG=$(git describe --tags)" >> $GITHUB_ENV
echo "LATEST_TAG=$(git describe --tags --abbrev=0 master)" >> $GITHUB_ENV
echo "BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_ENV

- name: Build and push image
uses: docker/bake-action@v6
with:
source: .
files: docker-bake.hcl
push: true
43 changes: 43 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
FROM python:3.14-alpine AS base

RUN addgroup -S -g 8080 docxcompose \
&& adduser -S -D -G docxcompose -u 8080 docxcompose

ENV PYTHONUNBUFFERED=1
WORKDIR /app

RUN echo "/app/lib/python3.14/site-packages/" > /usr/local/lib/python3.14/site-packages/app.pth \
&& apk add --no-cache \
libxml2 \
libxslt


FROM base AS builder

RUN apk add --no-cache \
gcc \
musl-dev \
libxml2-dev \
libxslt-dev \
pipx

RUN pipx install poetry \
&& pipx inject poetry poetry-plugin-export

COPY pyproject.toml poetry.lock ./

RUN /root/.local/bin/poetry export -f requirements.txt --extras server --output requirements.txt \
&& pip install --no-cache-dir --no-warn-script-location --prefix ./ -r requirements.txt --no-binary lxml

COPY docxcompose docxcompose
COPY README.rst .
RUN pip install --no-cache-dir --prefix ./ .
RUN rm -rf docxcompose pyproject.toml poetry.lock poetry.toml README.rst requirements.txt


FROM base AS prod

COPY --from=builder /app /app
USER docxcompose
EXPOSE 8080
CMD ["/app/bin/docxcompose-server"]
20 changes: 20 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ line, e.g.:
$ docxcompose files/master.docx files/content.docx -o files/composed.docx


Docker container
----------------

docxcompose is also available as a Docker container allowing to compose docx
documents through a web service.

To start the web service, run:

.. code:: sh

$ docker run -it --rm -p 8080:8080 4teamwork/docxcompose

To compose documents, just upload them in the desired order as a ``multipart/form-data``
request to the web service and you will get back the composed document. Example with curl:

.. code:: sh

$ curl -F "[email protected]" -F "[email protected]" -o composed.docx http://localhost:8080/


Installation for development
----------------------------

Expand Down
1 change: 1 addition & 0 deletions changes/web-service.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Provide a web service as a Docker container for composing documents. [buchi]
8 changes: 8 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
services:
docxcompose:
build:
context: .
dockerfile: Dockerfile
image: 4teamwork/docxcompose:latest
ports:
- 8080:8080
40 changes: 40 additions & 0 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
# To build locally use:
GIT_TAG=$(git describe --tags --candidates=0) \
GIT_SHA_TAG=$(git describe --tags) \
LATEST_TAG=$(git describe --tags --abbrev=0 master) \
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) \
docker buildx bake -f docker-bake.hcl --load
*/

variable "IMAGE_NAME" {
default = "docker.io/4teamwork/docxcompose"
}
variable "GIT_TAG" {
default = ""
}
variable "GIT_SHA_TAG" {
default = ""
}
variable "LATEST_TAG" {
default = ""
}
variable "BRANCH_NAME" {
default = ""
}

target "default" {
dockerfile = "./Dockerfile"
context = "."
target = "prod"
tags = [
strlen(GIT_TAG) > 0 ? "${IMAGE_NAME}:${GIT_TAG}": "",
equal(GIT_TAG, LATEST_TAG) ? "${IMAGE_NAME}:latest": "",
equal(GIT_TAG, "") && equal(BRANCH_NAME, "master") ? "${IMAGE_NAME}:edge": "",
notequal(BRANCH_NAME, "master") && strlen(GIT_TAG) < 1 && strlen(GIT_SHA_TAG) > 0 ? "${IMAGE_NAME}:${GIT_SHA_TAG}": "",
]
platforms = [
"linux/amd64",
strlen(GIT_TAG) > 0 ? "linux/arm64" : "",
]
}
121 changes: 121 additions & 0 deletions docxcompose/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
try:
from aiohttp import web
except ImportError:
raise SystemExit("Install with server extra to use this command.")
import importlib.metadata
import logging
import os.path
import tempfile

from docx import Document

from docxcompose.composer import Composer


CHUNK_SIZE = 65536
logger = logging.getLogger("docxcompose")
version = importlib.metadata.version("docxcompose")


async def compose(request):

documents = []
temp_dir = None

if not request.content_type == "multipart/form-data":
logger.info(
"Bad request. Received content type %s instead of multipart/form-data.",
request.content_type,
)
return web.Response(status=400, text="Multipart request required")

reader = await request.multipart()

with tempfile.TemporaryDirectory() as temp_dir:
while True:
part = await reader.next()

if part is None:
break

if part.filename is None:
continue

documents.append(await save_part_to_file(part, temp_dir))

if not documents:
return web.Response(status=400, text="No documents provided")

composed_filename = os.path.join(temp_dir, "composed.docx")

try:
composer = Composer(Document(documents.pop(0)))
for document in documents:
composer.append(Document(document))
composer.save(composed_filename)
except Exception:
logger.exception("Failed composing documents.")
return web.Response(status=500, text="Failed composing documents")

return await stream_file(
request,
composed_filename,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)


async def save_part_to_file(part, directory):
filename = os.path.join(directory, f"{part.name}_{part.filename}")
with open(filename, "wb") as file_:
while True:
chunk = await part.read_chunk(CHUNK_SIZE)
if not chunk:
break
file_.write(chunk)
return filename


async def stream_file(request, filename, content_type):
response = web.StreamResponse(
status=200,
reason="OK",
headers={
"Content-Type": content_type,
"Content-Disposition": f'attachment; filename="{os.path.basename(filename)}"',
},
)
await response.prepare(request)

with open(filename, "rb") as outfile:
while True:
data = outfile.read(CHUNK_SIZE)
if not data:
break
await response.write(data)

await response.write_eof()
return response


async def healthcheck(request):
return web.Response(status=200, text="OK")


def create_app():
app = web.Application()
app.add_routes([web.post("/", compose)])
app.add_routes([web.get("/healthcheck", healthcheck)])
return app


def main():
print(f"docxcompose {version}")
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s %(message)s",
level=logging.INFO,
)
web.run_app(create_app())


if __name__ == "__main__":
main()
Loading
Loading