Skip to content

Commit 4e67f1a

Browse files
Merge pull request #757 from VWS-Python/dockerfiles
Add Dockerfiles to run the Flask application
2 parents 9774c16 + f34de90 commit 4e67f1a

File tree

13 files changed

+247
-19
lines changed

13 files changed

+247
-19
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jobs:
6464
- test_update_target.py::TestWidth
6565
- test_update_target.py::TestInactiveProject
6666
- test_usage.py
67+
- test_docker.py
6768

6869
steps:
6970
# We share Vuforia credentials and therefore Vuforia databases across

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ autoflake==1.4
77
black==20.8b1
88
check-manifest==0.43
99
doc8==0.8.1
10+
docker==4.3.1
1011
dodgy==0.2.1 # Look for uploaded secrets
1112
flake8-commas==2.0.0 # Require silicon valley commas
1213
flake8-quotes==3.2.0 # Require single quotes

docs/source/differences-to-vws.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,8 @@ These are:
101101
* ``TargetQuotaReached``
102102
* ``ProjectSuspended``
103103
* ``ProjectHasNoAPIAccess``
104+
105+
``Content-Length`` headers
106+
--------------------------
107+
108+
When the given ``Content-Length`` header does not match the length of the given data, the mock server (written with Flask) will not behave as the real Vuforia Web Services behaves.

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ ignore =
1919
CONTRIBUTING.rst
2020
LICENSE
2121
Makefile
22+
src/mock_vws/_flask_server/dockerfiles/base/Dockerfile
23+
src/mock_vws/_flask_server/dockerfiles/storage/Dockerfile
24+
src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile
25+
src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile
2226
ci
2327
ci/**
2428
codecov.yaml

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ def _get_dependencies(requirements_file: Path) -> List[str]:
3131
)
3232

3333
setup(
34-
use_scm_version=True,
34+
# We use a dictionary with a fallback version rather than "True"
35+
# like https://github.com/pypa/setuptools_scm/issues/77 so that we do not
36+
# error in Docker.
37+
use_scm_version={'fallback_version': 'FALLBACK_VERSION'},
3538
setup_requires=SETUP_REQUIRES,
3639
install_requires=INSTALL_REQUIRES,
3740
extras_require={'dev': DEV_REQUIRES},
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM python:3.8-slim-buster
2+
COPY . /app
3+
WORKDIR /app
4+
RUN pip install .
5+
EXPOSE 5000
6+
ENTRYPOINT ["python"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM vws-mock:base
2+
CMD ["src/mock_vws/_flask_server/storage.py"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM vws-mock:base
2+
CMD ["src/mock_vws/_flask_server/vwq.py"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM vws-mock:base
2+
CMD ["src/mock_vws/_flask_server/vws.py"]

src/mock_vws/_flask_server/vwq.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query
66
"""
77

8-
import copy
98
import email.utils
109
from http import HTTPStatus
1110
from typing import Final, Set
@@ -27,7 +26,7 @@
2726

2827
CLOUDRECO_FLASK_APP = Flask(import_name=__name__)
2928
CLOUDRECO_FLASK_APP.config['PROPAGATE_EXCEPTIONS'] = True
30-
STORAGE_BASE_URL: Final[str] = 'http://todo.com'
29+
STORAGE_BASE_URL: Final[str] = 'http://vws-mock-storage:5000'
3130

3231

3332
def get_all_databases() -> Set[VuforiaDatabase]:
@@ -42,21 +41,25 @@ def get_all_databases() -> Set[VuforiaDatabase]:
4241

4342

4443
@CLOUDRECO_FLASK_APP.before_request
45-
def validate_request() -> None:
44+
def set_terminate_wsgi_input() -> None:
4645
"""
47-
Run validators on the request.
46+
We set ``wsgi.input_terminated`` to ``True`` when going through
47+
``requests``, so that requests have the given ``Content-Length`` headers
48+
and the given data in ``request.headers`` and ``request.data``.
49+
50+
We set this to ``False`` when running an application as standalone.
51+
This is because when running the Flask application, if this is set,
52+
reading ``request.data`` hangs.
53+
54+
Therefore, when running the real Flask application, the behavior is not the
55+
same as the real Vuforia.
56+
This is documented as a difference in the documentation for this package.
4857
"""
49-
request.environ['wsgi.input_terminated'] = True
50-
input_stream_copy = copy.copy(request.input_stream)
51-
request_body = input_stream_copy.read()
52-
databases = get_all_databases()
53-
run_query_validators(
54-
request_headers=dict(request.headers),
55-
request_body=request_body,
56-
request_method=request.method,
57-
request_path=request.path,
58-
databases=databases,
58+
terminate_wsgi_input = CLOUDRECO_FLASK_APP.config.get(
59+
'TERMINATE_WSGI_INPUT',
60+
False,
5961
)
62+
request.environ['wsgi.input_terminated'] = terminate_wsgi_input
6063

6164

6265
class ResponseNoContentTypeAdded(Response):
@@ -94,9 +97,16 @@ def query() -> Response:
9497
"""
9598
query_processes_deletion_seconds = 0.2
9699
query_recognizes_deletion_seconds = 0.2
100+
97101
databases = get_all_databases()
98-
input_stream_copy = copy.copy(request.input_stream)
99-
request_body = input_stream_copy.read()
102+
request_body = request.stream.read()
103+
run_query_validators(
104+
request_headers=dict(request.headers),
105+
request_body=request_body,
106+
request_method=request.method,
107+
request_path=request.path,
108+
databases=databases,
109+
)
100110
date = email.utils.formatdate(None, localtime=False, usegmt=True)
101111

102112
try:

src/mock_vws/_flask_server/vws.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
VWS_FLASK_APP = Flask(import_name=__name__)
3232
VWS_FLASK_APP.config['PROPAGATE_EXCEPTIONS'] = True
33-
STORAGE_BASE_URL: Final[str] = 'http://todo.com'
33+
STORAGE_BASE_URL: Final[str] = 'http://vws-mock-storage:5000'
3434

3535

3636
def get_all_databases() -> Set[VuforiaDatabase]:
@@ -60,12 +60,33 @@ class ResponseNoContentTypeAdded(Response):
6060
VWS_FLASK_APP.response_class = ResponseNoContentTypeAdded
6161

6262

63+
@VWS_FLASK_APP.before_request
64+
def set_terminate_wsgi_input() -> None:
65+
"""
66+
We set ``wsgi.input_terminated`` to ``True`` when going through
67+
``requests``, so that requests have the given ``Content-Length`` headers
68+
and the given data in ``request.headers`` and ``request.data``.
69+
70+
We set this to ``False`` when running an application as standalone.
71+
This is because when running the Flask application, if this is set,
72+
reading ``request.data`` hangs.
73+
74+
Therefore, when running the real Flask application, the behavior is not the
75+
same as the real Vuforia.
76+
This is documented as a difference in the documentation for this package.
77+
"""
78+
terminate_wsgi_input = VWS_FLASK_APP.config.get(
79+
'TERMINATE_WSGI_INPUT',
80+
False,
81+
)
82+
request.environ['wsgi.input_terminated'] = terminate_wsgi_input
83+
84+
6385
@VWS_FLASK_APP.before_request
6486
def validate_request() -> None:
6587
"""
6688
Run validators on the request.
6789
"""
68-
request.environ['wsgi.input_terminated'] = True
6990
databases = get_all_databases()
7091
run_services_validators(
7192
request_headers=dict(request.headers),

tests/mock_vws/fixtures/vuforia_backends.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ def _enable_use_docker_in_memory(
9393
working_database: VuforiaDatabase,
9494
inactive_database: VuforiaDatabase,
9595
) -> Generator:
96+
# We set ``wsgi.input_terminated`` to ``True`` so that when going through
97+
# ``requests``, the Flask applications
98+
# have the given ``Content-Length`` headers and the given data in
99+
# ``request.headers`` and ``request.data``.
100+
#
101+
# We do not set these in the Flask application itself.
102+
# This is because when running the Flask application, if this is set,
103+
# reading ``request.data`` hangs.
104+
#
105+
# Therefore, when running the real Flask application, the behavior is not
106+
# the same as the real Vuforia.
107+
# This is documented as a difference in the documentation for this package.
108+
VWS_FLASK_APP.config['TERMINATE_WSGI_INPUT'] = True
109+
CLOUDRECO_FLASK_APP.config['TERMINATE_WSGI_INPUT'] = True
110+
96111
with requests_mock.Mocker(real_http=False) as mock:
97112
add_flask_app_to_mock(
98113
mock_obj=mock,

tests/mock_vws/test_docker.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
Tests for running the mock server in Docker.
3+
"""
4+
5+
import io
6+
import uuid
7+
from http import HTTPStatus
8+
from pathlib import Path
9+
from typing import Iterator
10+
11+
import docker
12+
import pytest
13+
import requests
14+
from docker.models.networks import Network
15+
from vws import VWS, CloudRecoService
16+
17+
from mock_vws.database import VuforiaDatabase
18+
19+
20+
@pytest.fixture(name='custom_bridge_network')
21+
def fixture_custom_bridge_network() -> Iterator[Network]:
22+
"""
23+
Yield a custom bridge network which containers can connect to.
24+
"""
25+
client = docker.from_env()
26+
network = client.networks.create(
27+
name='test-vws-bridge-' + uuid.uuid4().hex,
28+
driver='bridge',
29+
)
30+
try:
31+
yield network
32+
finally:
33+
network.remove()
34+
35+
36+
def test_build_and_run(
37+
high_quality_image: io.BytesIO,
38+
custom_bridge_network: Network,
39+
) -> None:
40+
"""
41+
It is possible to build Docker images which combine to make a working mock
42+
application.
43+
"""
44+
repository_root = Path(__file__).parent.parent.parent
45+
client = docker.from_env()
46+
47+
dockerfile_dir = repository_root / 'src/mock_vws/_flask_server/dockerfiles'
48+
base_dockerfile = dockerfile_dir / 'base' / 'Dockerfile'
49+
storage_dockerfile = dockerfile_dir / 'storage' / 'Dockerfile'
50+
vws_dockerfile = dockerfile_dir / 'vws' / 'Dockerfile'
51+
vwq_dockerfile = dockerfile_dir / 'vwq' / 'Dockerfile'
52+
53+
random = uuid.uuid4().hex
54+
base_tag = 'vws-mock:base'
55+
storage_tag = 'vws-mock-storage:latest-' + random
56+
vws_tag = 'vws-mock-vws:latest-' + random
57+
vwq_tag = 'vws-mock-vwq:latest-' + random
58+
59+
client.images.build(
60+
path=str(repository_root),
61+
dockerfile=str(base_dockerfile),
62+
tag=base_tag,
63+
)
64+
65+
storage_image, _ = client.images.build(
66+
path=str(repository_root),
67+
dockerfile=str(storage_dockerfile),
68+
tag=storage_tag,
69+
)
70+
vws_image, _ = client.images.build(
71+
path=str(repository_root),
72+
dockerfile=str(vws_dockerfile),
73+
tag=vws_tag,
74+
)
75+
vwq_image, _ = client.images.build(
76+
path=str(repository_root),
77+
dockerfile=str(vwq_dockerfile),
78+
tag=vwq_tag,
79+
)
80+
81+
database = VuforiaDatabase()
82+
storage_container_name = 'vws-mock-storage'
83+
84+
storage_container = client.containers.run(
85+
image=storage_image,
86+
detach=True,
87+
name=storage_container_name,
88+
publish_all_ports=True,
89+
network=custom_bridge_network.name,
90+
)
91+
vws_container = client.containers.run(
92+
image=vws_image,
93+
detach=True,
94+
name='vws-mock-vws-' + random,
95+
publish_all_ports=True,
96+
network=custom_bridge_network.name,
97+
)
98+
vwq_container = client.containers.run(
99+
image=vwq_image,
100+
detach=True,
101+
name='vws-mock-vwq-' + random,
102+
publish_all_ports=True,
103+
network=custom_bridge_network.name,
104+
)
105+
106+
storage_container.reload()
107+
storage_port_attrs = storage_container.attrs['NetworkSettings']['Ports']
108+
storage_host_ip = storage_port_attrs['5000/tcp'][0]['HostIp']
109+
storage_host_port = storage_port_attrs['5000/tcp'][0]['HostPort']
110+
111+
vws_container.reload()
112+
vws_port_attrs = vws_container.attrs['NetworkSettings']['Ports']
113+
vws_host_ip = vws_port_attrs['5000/tcp'][0]['HostIp']
114+
vws_host_port = vws_port_attrs['5000/tcp'][0]['HostPort']
115+
116+
vwq_container.reload()
117+
vwq_port_attrs = vwq_container.attrs['NetworkSettings']['Ports']
118+
vwq_host_ip = vwq_port_attrs['5000/tcp'][0]['HostIp']
119+
vwq_host_port = vwq_port_attrs['5000/tcp'][0]['HostPort']
120+
121+
response = requests.post(
122+
url=f'http://{storage_host_ip}:{storage_host_port}/databases',
123+
json=database.to_dict(),
124+
)
125+
126+
assert response.status_code == HTTPStatus.CREATED
127+
128+
vws_client = VWS(
129+
server_access_key=database.server_access_key,
130+
server_secret_key=database.server_secret_key,
131+
base_vws_url=f'http://{vws_host_ip}:{vws_host_port}',
132+
)
133+
134+
target_id = vws_client.add_target(
135+
name='example',
136+
width=1,
137+
image=high_quality_image,
138+
active_flag=True,
139+
application_metadata=None,
140+
)
141+
142+
vws_client.wait_for_target_processed(target_id=target_id)
143+
144+
cloud_reco_client = CloudRecoService(
145+
client_access_key=database.client_access_key,
146+
client_secret_key=database.client_secret_key,
147+
base_vwq_url=f'http://{vwq_host_ip}:{vwq_host_port}',
148+
)
149+
150+
matching_targets = cloud_reco_client.query(image=high_quality_image)
151+
152+
for container in (storage_container, vws_container, vwq_container):
153+
container.stop()
154+
container.remove()
155+
156+
assert matching_targets[0].target_id == target_id

0 commit comments

Comments
 (0)