Compare commits
271 Commits
master
...
2.0.0-refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5978c7d09e | ||
|
|
d69c3c32f7 | ||
|
|
27ceff60c1 | ||
|
|
a15a792130 | ||
|
|
ece918fc0e | ||
|
|
2c902f3129 | ||
|
|
4df5b8f72c | ||
|
|
c6c1b121a3 | ||
|
|
02da828268 | ||
|
|
f795cdbc9c | ||
|
|
8ab13fe4fb | ||
|
|
f7e4953a4e | ||
|
|
4aef3316a3 | ||
|
|
41172d6abb | ||
|
|
71af6f703b | ||
|
|
a5ff59069a | ||
|
|
965d79b08f | ||
|
|
ca6a2f8d32 | ||
|
|
86eb3a6f7e | ||
|
|
87cf1ad189 | ||
|
|
7865a767c7 | ||
|
|
3897e44378 | ||
|
|
1558398c56 | ||
|
|
8537d4af50 | ||
|
|
116c2b8924 | ||
|
|
45f04590cc | ||
|
|
bb729b6c26 | ||
|
|
24be8d3f13 | ||
|
|
147416bfad | ||
|
|
c8fb15b9f7 | ||
|
|
771df7c78d | ||
|
|
f9972a95a7 | ||
|
|
83e1b5f029 | ||
|
|
c1b5cbeb51 | ||
|
|
4fcc89f938 | ||
|
|
d1242aee6c | ||
|
|
0442ecd7b3 | ||
|
|
e64ade3135 | ||
|
|
ace919d078 | ||
|
|
d179fdede6 | ||
|
|
9b975b759b | ||
|
|
c033d98acd | ||
|
|
bb7e631f91 | ||
|
|
d8b5be9e72 | ||
|
|
2954bbc1ad | ||
|
|
a69f613fe6 | ||
|
|
fa3b08aef5 | ||
|
|
14ab23b2cc | ||
|
|
8a64e5d868 | ||
|
|
051cea3ded | ||
|
|
40bc8c2c2c | ||
|
|
9962651d88 | ||
|
|
249c6203b2 | ||
|
|
3a3c497383 | ||
|
|
13b6388e5a | ||
|
|
1940b974b1 | ||
|
|
7e46a66698 | ||
|
|
5b45f5fa15 | ||
|
|
e43504f08d | ||
|
|
bffaa0786e | ||
|
|
8d209d63c7 | ||
|
|
0dee98b23d | ||
|
|
91701929e5 | ||
|
|
c55e41f2d8 | ||
|
|
f718b2f7ef | ||
|
|
730bdfb220 | ||
|
|
e48fa85784 | ||
|
|
6e5af4092e | ||
|
|
ea2d3223fb | ||
|
|
26573eeda3 | ||
|
|
7730950b50 | ||
|
|
9a47388017 | ||
|
|
8a2b60a8f5 | ||
|
|
9232385dea | ||
|
|
eb81e96400 | ||
|
|
e7ee0cda42 | ||
|
|
90f8f9da36 | ||
|
|
c2d7127a84 | ||
|
|
bfe8bbb8cb | ||
|
|
ecff50ae7c | ||
|
|
7a1b215d69 | ||
|
|
01ce914417 | ||
|
|
2b72174605 | ||
|
|
586871a26f | ||
|
|
187055e5eb | ||
|
|
3046b4dc26 | ||
|
|
dd591bd24b | ||
|
|
12fa52f38c | ||
|
|
043fa1ee53 | ||
|
|
1fa6bbdbc6 | ||
|
|
93747d0f63 | ||
|
|
dc4f578e94 | ||
|
|
93da0d12bb | ||
|
|
a688cbd7bd | ||
|
|
0104395790 | ||
|
|
18a9683ddb | ||
|
|
ae2509dc59 | ||
|
|
bf9f6ba8e2 | ||
|
|
868a53b23f | ||
|
|
2d1ec16714 | ||
|
|
9e2ed6a9f9 | ||
|
|
ab56c9a173 | ||
|
|
298d8d3e2c | ||
|
|
0842ec0d91 | ||
|
|
c944cdb1a7 | ||
|
|
7b998cdaf6 | ||
|
|
426967ee46 | ||
|
|
54ca81d577 | ||
|
|
13888524fb | ||
|
|
a7ffaeb18f | ||
|
|
c97393f690 | ||
|
|
7ff466e0ea | ||
|
|
02b0009219 | ||
|
|
cf13f67394 | ||
|
|
0ab86206ec | ||
|
|
35542f994c | ||
|
|
fb712af7c6 | ||
|
|
6cb13051eb | ||
|
|
456cb4157d | ||
|
|
6945760045 | ||
|
|
47f1d77c03 | ||
|
|
fb325ce43d | ||
|
|
5590669939 | ||
|
|
9c262e7138 | ||
|
|
e5a4e7e994 | ||
|
|
89f562aa71 | ||
|
|
1074f44b30 | ||
|
|
96bf831b00 | ||
|
|
5d2b71d647 | ||
|
|
d12124b2d5 | ||
|
|
7adbdefb4e | ||
|
|
a1c292a485 | ||
|
|
948575d199 | ||
|
|
2070f300c9 | ||
|
|
092a0e2964 | ||
|
|
40777ae609 | ||
|
|
2434e0ea55 | ||
|
|
08ad83b6a5 | ||
|
|
9870aa38d1 | ||
|
|
3b7605772e | ||
|
|
1acf16dc91 | ||
|
|
c09e5df23e | ||
|
|
bfdce62ccf | ||
|
|
1552cd10cc | ||
|
|
8b0c2d4e07 | ||
|
|
461c0fe6a6 | ||
|
|
9b5fc4ff77 | ||
|
|
e3793e5c7c | ||
|
|
1a04dfb426 | ||
|
|
e151d2005b | ||
|
|
da2572b8be | ||
|
|
eb8ace4ddd | ||
|
|
096068367f | ||
|
|
7bd35dce67 | ||
|
|
1eb4dbc657 | ||
|
|
ccf7a7379d | ||
|
|
b1a318872e | ||
|
|
abc56e6d9f | ||
|
|
c8579a8ad0 | ||
|
|
c68e19e6e4 | ||
|
|
de0deaa2f4 | ||
|
|
83ce7692e6 | ||
|
|
949413af4a | ||
|
|
3dba896038 | ||
|
|
453281d48d | ||
|
|
5b913983eb | ||
|
|
c2ed6d78b7 | ||
|
|
f29bd7d4d3 | ||
|
|
ec620abf54 | ||
|
|
5b30a32fff | ||
|
|
c092e7bcab | ||
|
|
1d09337378 | ||
|
|
8c1ad64464 | ||
|
|
1e21913e37 | ||
|
|
132a1a1b50 | ||
|
|
f99d779c29 | ||
|
|
f428372511 | ||
|
|
54359501f9 | ||
|
|
cbba561116 | ||
|
|
1daaf2b904 | ||
|
|
866df2dee3 | ||
|
|
962b398a9c | ||
|
|
3ae4fd8986 | ||
|
|
98af600787 | ||
|
|
ec650464a8 | ||
|
|
ed69011bf6 | ||
|
|
0fc3db2fae | ||
|
|
53eee983c4 | ||
|
|
5760a6f354 | ||
|
|
dca3eaaa54 | ||
|
|
1b04c46853 | ||
|
|
68c24c863f | ||
|
|
4a3ac150cf | ||
|
|
373c38113f | ||
|
|
b58a9d11c3 | ||
|
|
bd5fe82e06 | ||
|
|
b62652957a | ||
|
|
7a3bb9334b | ||
|
|
7ccad043f4 | ||
|
|
4fd6a5aa2a | ||
|
|
a7a44267f1 | ||
|
|
685edaa62f | ||
|
|
82d7b7f8cb | ||
|
|
d4ffd75e26 | ||
|
|
7a1db32c3b | ||
|
|
456fb1db06 | ||
|
|
e221b00933 | ||
|
|
24313241a8 | ||
|
|
ef0e805223 | ||
|
|
531ff8d3e0 | ||
|
|
14d83abd72 | ||
|
|
00ea224379 | ||
|
|
625552ec7c | ||
|
|
8afd87e44f | ||
|
|
1d70fb628e | ||
|
|
f32004c3a4 | ||
|
|
a301876ab9 | ||
|
|
35045128b4 | ||
|
|
463e1b2024 | ||
|
|
630ed51b27 | ||
|
|
a4079f6710 | ||
|
|
cf0a877569 | ||
|
|
7d8659f257 | ||
|
|
51a6bf9875 | ||
|
|
85d7ad52dc | ||
|
|
c00973b676 | ||
|
|
16fa992cae | ||
|
|
c315247625 | ||
|
|
29fb0dda30 | ||
|
|
ae39ccc8e2 | ||
|
|
8575567890 | ||
|
|
92190a42f0 | ||
|
|
ea9b405d2a | ||
|
|
77f23a2185 | ||
|
|
3172a00aaa | ||
|
|
501f0bd5fc | ||
|
|
fd57261631 | ||
|
|
23070f3480 | ||
|
|
2550a0eff2 | ||
|
|
f053a072d6 | ||
|
|
00276dbcc7 | ||
|
|
bc0e9ed643 | ||
|
|
940bc3a689 | ||
|
|
a999ce2c3b | ||
|
|
e8315ffea9 | ||
|
|
087b5af929 | ||
|
|
f47d458217 | ||
|
|
0d503d1c1d | ||
|
|
3b0d0868b9 | ||
|
|
da84ff5112 | ||
|
|
c9f26000d7 | ||
|
|
67c4bac4b7 | ||
|
|
ab5839a126 | ||
|
|
fa4e5e5e0e | ||
|
|
e58addf8c4 | ||
|
|
00e21b00ba | ||
|
|
fabc78efce | ||
|
|
90af62ed2c | ||
|
|
2af648254e | ||
|
|
9e8172427c | ||
|
|
e903c69a07 | ||
|
|
7419612c21 | ||
|
|
656bc7cd63 | ||
|
|
f6ca9c9ac5 | ||
|
|
5a948ef7ad | ||
|
|
4078b3e4ec | ||
|
|
b7882d4452 | ||
|
|
01daa634ec | ||
|
|
64624a3fd3 | ||
|
|
afd67d87a6 | ||
|
|
37881da08e |
13
config.yaml
13
config.yaml
@ -1,5 +1,13 @@
|
||||
service:
|
||||
logging_level: $LOGGING_LEVEL_ROOT|DEBUG # Logging level for service logger
|
||||
target_file_extension: $TARGET_FILE_EXTENSION|"ORIGIN.pdf.gz" # Extension for files to download from storage and process
|
||||
response_file_extension: $RESPONSE_FILE_EXTENSION|"json.gz" # Extension for response files to upload to storage
|
||||
# In case a service is not supposed to place response files under the root folder `dossierId/fileId` and requests do
|
||||
# not specify an operation, this parameter can specify an output folder
|
||||
response_folder: $RESPONSE_FOLDER|null
|
||||
# Specifies, how to handle the `page` key of a request. "multi" will download all pages matching the list of pages
|
||||
# specified in the request
|
||||
download_strategy: single
|
||||
|
||||
probing_webserver:
|
||||
host: $PROBING_WEBSERVER_HOST|"0.0.0.0" # Probe webserver address
|
||||
@ -33,3 +41,8 @@ storage:
|
||||
|
||||
azure:
|
||||
connection_string: $STORAGE_AZURECONNECTIONSTRING|"DefaultEndpointsProtocol=https;AccountName=iqserdevelopment;AccountKey=4imAbV9PYXaztSOMpIyAClg88bAZCXuXMGJG0GA1eIBpdh2PlnFGoRBnKqLy2YZUSTmZ3wJfC7tzfHtuC6FEhQ==;EndpointSuffix=core.windows.net"
|
||||
|
||||
retry:
|
||||
tries: 3
|
||||
delay: 5
|
||||
jitter: [1, 3]
|
||||
|
||||
76
doc/signatures.txt
Normal file
76
doc/signatures.txt
Normal file
@ -0,0 +1,76 @@
|
||||
Processing service interface
|
||||
|
||||
image classification now : JSON (Mdat PDF) -> (Data PDF -> JSON [Mdat ImObj]
|
||||
image classification future: JSON [Mdat FunkIm] | Mdat PDF -> (Data [FunkIm] -> JSON [Mdat FunkIm])
|
||||
object detection : JSON [Mdat PagIm] | Mdat PDF -> (Data [PagIm] -> JSON [[Mdat SemIm]])
|
||||
NER : JSON [Mdat Dict] -> (Data [Dict] -> JSON [Mdat])
|
||||
table parsing : JSON [Mdat FunkIm] | Mdat PDF -> (Data [PagIm] -> JSON [[Mdat FunkIm]])
|
||||
pdf2image : Mdat (fn, [Int], PDF) -> (JSON ([Int], Data PDF) -> [(FunkIm, Mdat)])
|
||||
|
||||
|
||||
image classification now : Mdat (fn, [Int], file) -> (Data PDF -> JSON [Mdat ImObj]
|
||||
image classification future: Mdat (fn, [Int], dir) -> (Data [FunkIm] -> JSON [Mdat FunkIm])
|
||||
object detection : Mdat (fn, [Int], dir) -> (Data [PagIm] -> JSON [[Mdat SemIm]])
|
||||
table parsing : Mdat (fn, [Int], dir) -> (Data [PagIm] -> JSON [[Mdat FunkIm]])
|
||||
NER : Mdat (fn, [Int], file) -> (Data [Dict] -> JSON [Mdat])
|
||||
pdf2image : Mdat (fn, [Int], file) -> (JSON ([Int], Data PDF) -> [(FunkIm, Mdat)])
|
||||
|
||||
|
||||
from funcy import identity
|
||||
|
||||
access(mdat):
|
||||
if mdat.path is file:
|
||||
request = {"data": load(mdat.path), "metadata": mdat}
|
||||
elif mdat.path is dir:
|
||||
get_indexed = identity if not mdat.idx else itemgetter(*mdat.idx)
|
||||
request = {"data": get_indexed(get_files(mdat.path)), "metadata": mdat}
|
||||
else:
|
||||
raise BadRequest
|
||||
|
||||
|
||||
storage:
|
||||
|
||||
fileId: {
|
||||
pages: [PagIm]
|
||||
images: [FunkIm]
|
||||
sections: gz
|
||||
}
|
||||
|
||||
|
||||
---------------
|
||||
|
||||
|
||||
|
||||
assert if targetPath is file then response list must be singleton
|
||||
{index: [], dir: fileID.pdf.gz, targetPath: fileID.images.json.gz} -> [{data: pdf bytes, metadata: request: ...] -> [{data: null, metadata: request: null, response: {classification infos: ...}]
|
||||
image classification now : Mdat (fn, [Int], file) -> [JSON (Data PDF, Mdat)] -> [JSON (Data null, Mdat [ImObj])] | 1 -> 1
|
||||
assert if targetPath is file then response list must be singleton
|
||||
{index: [], dir: fileID/images, targetPath: fileID.images.json.gz} -> [{data: image bytes, metadata: request: {image location...}] -> [{data: null, metadata: request: null, response: {classification infos: ...}]
|
||||
image classification future: Mdat (fn, [Int], dir) -> JSON (Data [FunkIm], Mdat) -> [JSON (Data null, Mdat [FunkIm])] |
|
||||
object detection : Mdat (fn, [Int], dir) -> (Data [PagIm] -> JSON [[Mdat SemIm]])
|
||||
table parsing : Mdat (fn, [Int], dir) -> (Data [PagIm] -> JSON [[Mdat FunkIm]])
|
||||
NER : Mdat (fn, [Int], file) -> (Data [Dict] -> JSON [Mdat])
|
||||
pdf2image : Mdat (fn, [Int], file) -> (JSON ([Int], Data PDF) -> [(FunkIm, Mdat)])
|
||||
|
||||
aggregate <==> targetpath is file and index is empty
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
version: '2'
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio
|
||||
image: minio/minio:RELEASE.2022-06-11T19-55-32Z
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
@ -12,7 +12,7 @@ services:
|
||||
command: server /data
|
||||
network_mode: "bridge"
|
||||
rabbitmq:
|
||||
image: docker.io/bitnami/rabbitmq:3.9
|
||||
image: docker.io/bitnami/rabbitmq:3.9.8
|
||||
ports:
|
||||
- '4369:4369'
|
||||
- '5551:5551'
|
||||
|
||||
@ -27,6 +27,9 @@ class DotIndexable:
|
||||
def __getitem__(self, item):
|
||||
return self.__getattr__(item)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.x[key] = value
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_path):
|
||||
@ -39,6 +42,10 @@ class Config:
|
||||
def __getitem__(self, item):
|
||||
return self.__getattr__(item)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.__config.key = value
|
||||
|
||||
|
||||
|
||||
CONFIG = Config(CONFIG_FILE)
|
||||
|
||||
|
||||
130
pyinfra/default_objects.py
Normal file
130
pyinfra/default_objects.py
Normal file
@ -0,0 +1,130 @@
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from funcy import rcompose, omit, merge, lmap, project
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.exceptions import AnalysisFailure
|
||||
from pyinfra.queue.consumer import Consumer
|
||||
from pyinfra.queue.queue_manager.pika_queue_manager import PikaQueueManager
|
||||
from pyinfra.server.client_pipeline import ClientPipeline
|
||||
from pyinfra.server.dispatcher.dispatchers.rest import RestDispatcher
|
||||
from pyinfra.server.interpreter.interpreters.rest_callback import RestPickupStreamer
|
||||
from pyinfra.server.packer.packers.rest import RestPacker
|
||||
from pyinfra.server.receiver.receivers.rest import RestReceiver
|
||||
from pyinfra.storage import storages
|
||||
from pyinfra.visitor import QueueVisitor
|
||||
from pyinfra.visitor.strategies.response.aggregation import AggregationStorageStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_consumer(callback=None):
|
||||
callback = callback or get_callback()
|
||||
return Consumer(get_visitor(callback), get_queue_manager())
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_visitor(callback):
|
||||
return QueueVisitor(storage=get_storage(), callback=callback, response_strategy=get_response_strategy())
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_queue_manager():
|
||||
return PikaQueueManager(CONFIG.rabbitmq.queues.input, CONFIG.rabbitmq.queues.output)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_storage():
|
||||
return storages.get_storage(CONFIG.storage.backend)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_callback(analysis_base_url=None):
|
||||
analysis_base_url = analysis_base_url or CONFIG.rabbitmq.callback.analysis_endpoint
|
||||
|
||||
callback = Callback(analysis_base_url)
|
||||
|
||||
def wrapped(body):
|
||||
body_repr = project(body, ["dossierId", "fileId", "pages", "images", "operation"])
|
||||
logger.info(f"Processing {body_repr}...")
|
||||
return callback(body)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_response_strategy(storage=None):
|
||||
return AggregationStorageStrategy(storage or get_storage())
|
||||
|
||||
|
||||
class Callback:
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url
|
||||
self.endpoint2pipeline = {}
|
||||
|
||||
def __make_endpoint(self, operation):
|
||||
return f"{self.base_url}/{operation}"
|
||||
|
||||
def __get_pipeline(self, endpoint):
|
||||
if endpoint in self.endpoint2pipeline:
|
||||
pipeline = self.endpoint2pipeline[endpoint]
|
||||
|
||||
else:
|
||||
pipeline = get_pipeline(endpoint)
|
||||
self.endpoint2pipeline[endpoint] = pipeline
|
||||
|
||||
return pipeline
|
||||
|
||||
@staticmethod
|
||||
def __run_pipeline(pipeline, body):
|
||||
"""
|
||||
TODO: since data and metadata are passed as singletons, there is no buffering and hence no batching happening
|
||||
within the pipeline. However, the queue acknowledgment logic needs to be changed in order to facilitate
|
||||
passing non-singletons, to only ack a message, once a response is pulled from the output queue of the
|
||||
pipeline. Probably the pipeline return value needs to contains the queue message frame (or so), in order for
|
||||
the queue manager to tell which message to ack.
|
||||
|
||||
TODO: casting list (lmap) on `analysis_response_stream` is a temporary solution, while the client pipeline
|
||||
operates on singletons ([data], [metadata]).
|
||||
"""
|
||||
|
||||
def combine_storage_item_metadata_with_queue_message_metadata(body):
|
||||
return merge(body["metadata"], omit(body, ["data", "metadata"]))
|
||||
|
||||
def remove_queue_message_metadata(result):
|
||||
metadata = omit(result["metadata"], queue_message_keys(body))
|
||||
return {**result, "metadata": metadata}
|
||||
|
||||
def queue_message_keys(body):
|
||||
return {*body.keys()}.difference({"data", "metadata"})
|
||||
|
||||
try:
|
||||
data = body["data"]
|
||||
metadata = combine_storage_item_metadata_with_queue_message_metadata(body)
|
||||
analysis_response_stream = pipeline([data], [metadata])
|
||||
analysis_response_stream = lmap(remove_queue_message_metadata, analysis_response_stream)
|
||||
return analysis_response_stream
|
||||
|
||||
except Exception as err:
|
||||
logger.error(err)
|
||||
raise AnalysisFailure from err
|
||||
|
||||
def __call__(self, body: dict):
|
||||
operation = body.get("operation", "submit")
|
||||
endpoint = self.__make_endpoint(operation)
|
||||
pipeline = self.__get_pipeline(endpoint)
|
||||
|
||||
try:
|
||||
logging.debug(f"Requesting analysis from {endpoint}...")
|
||||
return self.__run_pipeline(pipeline, body)
|
||||
except AnalysisFailure:
|
||||
logging.warning(f"Exception caught when calling analysis endpoint {endpoint}.")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_pipeline(endpoint):
|
||||
return ClientPipeline(
|
||||
RestPacker(), RestDispatcher(endpoint), RestReceiver(), rcompose(RestPickupStreamer(), RestReceiver())
|
||||
)
|
||||
@ -32,3 +32,19 @@ class NoSuchContainer(KeyError):
|
||||
|
||||
class IntentionalTestException(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class UnexpectedItemType(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class NoBufferCapacity(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMessage(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStorageItemFormat(ValueError):
|
||||
pass
|
||||
|
||||
@ -6,8 +6,7 @@ from waitress import serve
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
logger.setLevel(CONFIG.service.logging_level)
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def run_probing_webserver(app, host=None, port=None, mode=None):
|
||||
|
||||
0
pyinfra/parser/__init__.py
Normal file
0
pyinfra/parser/__init__.py
Normal file
14
pyinfra/parser/blob_parser.py
Normal file
14
pyinfra/parser/blob_parser.py
Normal file
@ -0,0 +1,14 @@
|
||||
import abc
|
||||
|
||||
|
||||
class ParsingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BlobParser(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def parse(self, blob: bytes):
|
||||
pass
|
||||
|
||||
def __call__(self, blob: bytes):
|
||||
return self.parse(blob)
|
||||
67
pyinfra/parser/parser_composer.py
Normal file
67
pyinfra/parser/parser_composer.py
Normal file
@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
from funcy import rcompose
|
||||
|
||||
from pyinfra.parser.blob_parser import ParsingError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Either:
|
||||
def __init__(self, item):
|
||||
self.item = item
|
||||
|
||||
def bind(self):
|
||||
return self.item
|
||||
|
||||
|
||||
class Left(Either):
|
||||
pass
|
||||
|
||||
|
||||
class Right(Either):
|
||||
pass
|
||||
|
||||
|
||||
class EitherParserWrapper:
|
||||
def __init__(self, parser):
|
||||
self.parser = parser
|
||||
|
||||
def __log(self, result):
|
||||
if isinstance(result, Right):
|
||||
logger.log(logging.DEBUG - 5, f"{self.parser.__class__.__name__} succeeded or forwarded on {result.bind()}")
|
||||
else:
|
||||
logger.log(logging.DEBUG - 5, f"{self.parser.__class__.__name__} failed on {result.bind()}")
|
||||
return result
|
||||
|
||||
def parse(self, item: Either):
|
||||
if isinstance(item, Left):
|
||||
|
||||
try:
|
||||
return Right(self.parser(item.bind()))
|
||||
except ParsingError:
|
||||
return item
|
||||
|
||||
elif isinstance(item, Right):
|
||||
return item
|
||||
|
||||
else:
|
||||
return self.parse(Left(item))
|
||||
|
||||
def __call__(self, item):
|
||||
return self.__log(self.parse(item))
|
||||
|
||||
|
||||
class EitherParserComposer:
|
||||
def __init__(self, *parsers):
|
||||
self.parser = rcompose(*map(EitherParserWrapper, parsers))
|
||||
|
||||
def parse(self, item):
|
||||
result = self.parser(item)
|
||||
if isinstance(result, Right):
|
||||
return result.bind()
|
||||
else:
|
||||
raise ParsingError("All parsers failed.")
|
||||
|
||||
def __call__(self, item):
|
||||
return self.parse(item)
|
||||
0
pyinfra/parser/parsers/__init__.py
Normal file
0
pyinfra/parser/parsers/__init__.py
Normal file
7
pyinfra/parser/parsers/identity.py
Normal file
7
pyinfra/parser/parsers/identity.py
Normal file
@ -0,0 +1,7 @@
|
||||
from pyinfra.parser.blob_parser import BlobParser
|
||||
|
||||
|
||||
class IdentityBlobParser(BlobParser):
|
||||
|
||||
def parse(self, data: bytes):
|
||||
return data
|
||||
21
pyinfra/parser/parsers/json.py
Normal file
21
pyinfra/parser/parsers/json.py
Normal file
@ -0,0 +1,21 @@
|
||||
import json
|
||||
|
||||
from pyinfra.parser.blob_parser import BlobParser, ParsingError
|
||||
from pyinfra.server.packing import string_to_bytes
|
||||
|
||||
|
||||
class JsonBlobParser(BlobParser):
|
||||
|
||||
def parse(self, data: bytes):
|
||||
try:
|
||||
data = data.decode()
|
||||
data = json.loads(data)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as err:
|
||||
raise ParsingError from err
|
||||
|
||||
try:
|
||||
data["data"] = string_to_bytes(data["data"])
|
||||
except (KeyError, TypeError) as err:
|
||||
raise ParsingError from err
|
||||
|
||||
return data
|
||||
9
pyinfra/parser/parsers/string.py
Normal file
9
pyinfra/parser/parsers/string.py
Normal file
@ -0,0 +1,9 @@
|
||||
from pyinfra.parser.blob_parser import BlobParser, ParsingError
|
||||
|
||||
|
||||
class StringBlobParser(BlobParser):
|
||||
def parse(self, data: bytes):
|
||||
try:
|
||||
return data.decode()
|
||||
except Exception as err:
|
||||
raise ParsingError from err
|
||||
@ -2,15 +2,15 @@ from pyinfra.queue.queue_manager.queue_manager import QueueManager
|
||||
|
||||
|
||||
class Consumer:
|
||||
def __init__(self, callback, queue_manager: QueueManager):
|
||||
def __init__(self, visitor, queue_manager: QueueManager):
|
||||
self.queue_manager = queue_manager
|
||||
self.callback = callback
|
||||
self.visitor = visitor
|
||||
|
||||
def consume_and_publish(self):
|
||||
self.queue_manager.consume_and_publish(self.callback)
|
||||
def consume_and_publish(self, n=None):
|
||||
self.queue_manager.consume_and_publish(self.visitor, n=n)
|
||||
|
||||
def basic_consume_and_publish(self):
|
||||
self.queue_manager.basic_consume_and_publish(self.callback)
|
||||
self.queue_manager.basic_consume_and_publish(self.visitor)
|
||||
|
||||
def consume(self, **kwargs):
|
||||
return self.queue_manager.consume(**kwargs)
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from itertools import islice
|
||||
|
||||
import pika
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.exceptions import ProcessingFailure, DataLoadingFailure
|
||||
from pyinfra.queue.queue_manager.queue_manager import QueueHandle, QueueManager
|
||||
from pyinfra.visitor import QueueVisitor
|
||||
|
||||
logger = logging.getLogger("pika")
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(CONFIG.service.logging_level)
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def monkey_patch_queue_handle(channel, queue) -> QueueHandle:
|
||||
@ -83,7 +83,7 @@ class PikaQueueManager(QueueManager):
|
||||
self.channel.queue_declare(input_queue, arguments=args, auto_delete=False, durable=True)
|
||||
self.channel.queue_declare(output_queue, arguments=args, auto_delete=False, durable=True)
|
||||
|
||||
def republish(self, body, n_current_attempts, frame):
|
||||
def republish(self, body: bytes, n_current_attempts, frame):
|
||||
self.channel.basic_publish(
|
||||
exchange="",
|
||||
routing_key=self._input_queue,
|
||||
@ -100,7 +100,7 @@ class PikaQueueManager(QueueManager):
|
||||
logger.error(f"Adding to dead letter queue: {body}")
|
||||
self.channel.basic_reject(delivery_tag=frame.delivery_tag, requeue=False)
|
||||
|
||||
def publish_response(self, message, callback, max_attempts=3):
|
||||
def publish_response(self, message, visitor: QueueVisitor, max_attempts=3):
|
||||
|
||||
logger.debug(f"Processing {message}.")
|
||||
|
||||
@ -109,8 +109,15 @@ class PikaQueueManager(QueueManager):
|
||||
n_attempts = get_n_previous_attempts(properties) + 1
|
||||
|
||||
try:
|
||||
response = json.dumps(callback(json.loads(body)))
|
||||
self.channel.basic_publish("", self._output_queue, response.encode())
|
||||
response_messages = visitor(json.loads(body))
|
||||
|
||||
if isinstance(response_messages, dict):
|
||||
response_messages = [response_messages]
|
||||
|
||||
for response_message in response_messages:
|
||||
response_message = json.dumps(response_message).encode()
|
||||
self.channel.basic_publish("", self._output_queue, response_message)
|
||||
|
||||
self.channel.basic_ack(frame.delivery_tag)
|
||||
except (ProcessingFailure, DataLoadingFailure):
|
||||
|
||||
@ -124,20 +131,21 @@ class PikaQueueManager(QueueManager):
|
||||
def pull_request(self):
|
||||
return self.channel.basic_get(self._input_queue)
|
||||
|
||||
def consume(self, inactivity_timeout=None):
|
||||
def consume(self, inactivity_timeout=None, n=None):
|
||||
logger.debug("Consuming")
|
||||
return self.channel.consume(self._input_queue, inactivity_timeout=inactivity_timeout)
|
||||
gen = self.channel.consume(self._input_queue, inactivity_timeout=inactivity_timeout)
|
||||
yield from islice(gen, n)
|
||||
|
||||
def consume_and_publish(self, visitor):
|
||||
def consume_and_publish(self, visitor: QueueVisitor, n=None):
|
||||
|
||||
logger.info(f"Consuming with callback {visitor.callback.__name__}")
|
||||
logger.info(f"Consuming input queue.")
|
||||
|
||||
for message in self.consume():
|
||||
for message in self.consume(n=n):
|
||||
self.publish_response(message, visitor)
|
||||
|
||||
def basic_consume_and_publish(self, visitor):
|
||||
def basic_consume_and_publish(self, visitor: QueueVisitor):
|
||||
|
||||
logger.info(f"Basic consuming with callback {visitor.callback.__name__}")
|
||||
logger.info(f"Basic consuming input queue.")
|
||||
|
||||
def callback(channel, frame, properties, body):
|
||||
message = (frame, properties, body)
|
||||
@ -150,6 +158,8 @@ class PikaQueueManager(QueueManager):
|
||||
try:
|
||||
self.channel.queue_purge(self._input_queue)
|
||||
self.channel.queue_purge(self._output_queue)
|
||||
assert self.input_queue.to_list() == []
|
||||
assert self.output_queue.to_list() == []
|
||||
except pika.exceptions.ChannelWrongStateError:
|
||||
pass
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ class QueueManager(abc.ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def consume_and_publish(self, callback):
|
||||
def consume_and_publish(self, callback, n=None):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
0
pyinfra/server/__init__.py
Normal file
0
pyinfra/server/__init__.py
Normal file
0
pyinfra/server/buffering/__init__.py
Normal file
0
pyinfra/server/buffering/__init__.py
Normal file
37
pyinfra/server/buffering/bufferize.py
Normal file
37
pyinfra/server/buffering/bufferize.py
Normal file
@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
from funcy import repeatedly, identity
|
||||
|
||||
from pyinfra.exceptions import NoBufferCapacity
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bufferize(fn, buffer_size=3, persist_fn=identity, null_value=None):
|
||||
def buffered_fn(item):
|
||||
|
||||
if item is not Nothing:
|
||||
buffer.append(persist_fn(item))
|
||||
|
||||
response_payload = fn(repeatedly(buffer.popleft, n_items_to_pop(buffer, item is Nothing)))
|
||||
|
||||
return response_payload or null_value
|
||||
|
||||
def buffer_full(current_buffer_size):
|
||||
if current_buffer_size > buffer_size:
|
||||
logger.warning(f"Overfull buffer. size: {current_buffer_size}; intended capacity: {buffer_size}")
|
||||
|
||||
return current_buffer_size == buffer_size
|
||||
|
||||
def n_items_to_pop(buffer, final):
|
||||
current_buffer_size = len(buffer)
|
||||
return (final or buffer_full(current_buffer_size)) * current_buffer_size
|
||||
|
||||
if not buffer_size > 0:
|
||||
raise NoBufferCapacity("Buffer size must be greater than zero.")
|
||||
|
||||
buffer = deque()
|
||||
|
||||
return buffered_fn
|
||||
24
pyinfra/server/buffering/queue.py
Normal file
24
pyinfra/server/buffering/queue.py
Normal file
@ -0,0 +1,24 @@
|
||||
from collections import deque
|
||||
from itertools import takewhile
|
||||
|
||||
from funcy import repeatedly
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import is_not_nothing, Nothing
|
||||
|
||||
|
||||
def stream_queue(queue):
|
||||
yield from takewhile(is_not_nothing, repeatedly(queue.popleft))
|
||||
|
||||
|
||||
class Queue:
|
||||
def __init__(self):
|
||||
self.__queue = deque()
|
||||
|
||||
def append(self, package) -> None:
|
||||
self.__queue.append(package)
|
||||
|
||||
def popleft(self):
|
||||
return self.__queue.popleft() if self.__queue else Nothing
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.__queue)
|
||||
44
pyinfra/server/buffering/stream.py
Normal file
44
pyinfra/server/buffering/stream.py
Normal file
@ -0,0 +1,44 @@
|
||||
from itertools import chain, takewhile
|
||||
from typing import Iterable
|
||||
|
||||
from funcy import first, repeatedly, flatten
|
||||
|
||||
from pyinfra.server.buffering.bufferize import bufferize
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing, is_not_nothing
|
||||
|
||||
|
||||
class FlatStreamBuffer:
|
||||
"""Wraps a stream buffer and chains its output. Also flushes the stream buffer when applied to an iterable."""
|
||||
|
||||
def __init__(self, fn, buffer_size=3):
|
||||
"""Function `fn` needs to be mappable and return an iterable; ideally `fn` returns a generator."""
|
||||
self.stream_buffer = StreamBuffer(fn, buffer_size=buffer_size)
|
||||
|
||||
def __call__(self, items):
|
||||
items = chain(items, [Nothing])
|
||||
yield from flatten(map(self.stream_buffer, items))
|
||||
|
||||
|
||||
class StreamBuffer:
|
||||
"""Puts a streaming function between an input and an output buffer."""
|
||||
|
||||
def __init__(self, fn, buffer_size=3):
|
||||
"""Function `fn` needs to be mappable and return an iterable; ideally `fn` returns a generator."""
|
||||
self.fn = bufferize(fn, buffer_size=buffer_size, null_value=[])
|
||||
self.result_stream = chain([])
|
||||
|
||||
def __call__(self, item) -> Iterable:
|
||||
self.push(item)
|
||||
yield from takewhile(is_not_nothing, repeatedly(self.pop))
|
||||
|
||||
def push(self, item):
|
||||
self.result_stream = chain(self.result_stream, self.compute(item))
|
||||
|
||||
def compute(self, item):
|
||||
try:
|
||||
yield from self.fn(item)
|
||||
except TypeError as err:
|
||||
raise TypeError("Function failed with type-error. Is it mappable?") from err
|
||||
|
||||
def pop(self):
|
||||
return first(chain(self.result_stream, [Nothing]))
|
||||
16
pyinfra/server/client_pipeline.py
Normal file
16
pyinfra/server/client_pipeline.py
Normal file
@ -0,0 +1,16 @@
|
||||
from funcy import rcompose, flatten
|
||||
|
||||
|
||||
# TODO: remove the dispatcher component from the pipeline; it no longer actually dispatches
|
||||
class ClientPipeline:
|
||||
def __init__(self, packer, dispatcher, receiver, interpreter):
|
||||
self.pipe = rcompose(
|
||||
packer,
|
||||
dispatcher,
|
||||
receiver,
|
||||
interpreter,
|
||||
flatten, # each analysis call returns an iterable. Can be empty, singleton or multi item. Hence, flatten.
|
||||
)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
yield from self.pipe(*args, **kwargs)
|
||||
27
pyinfra/server/debugging.py
Normal file
27
pyinfra/server/debugging.py
Normal file
@ -0,0 +1,27 @@
|
||||
from itertools import tee
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def inspect(prefix="inspect", embed=False):
|
||||
"""Can be used to inspect compositions of generator functions by placing inbetween two functions."""
|
||||
|
||||
def inner(x):
|
||||
|
||||
if isinstance(x, Iterable) and not isinstance(x, dict) and not isinstance(x, tuple):
|
||||
x, y = tee(x)
|
||||
y = list(y)
|
||||
else:
|
||||
y = x
|
||||
|
||||
l = f" {len(y)} items" if isinstance(y, list) else ""
|
||||
|
||||
print(f"{prefix}{l}:", y)
|
||||
|
||||
if embed:
|
||||
import IPython
|
||||
|
||||
IPython.embed()
|
||||
|
||||
return x
|
||||
|
||||
return inner
|
||||
0
pyinfra/server/dispatcher/__init__.py
Normal file
0
pyinfra/server/dispatcher/__init__.py
Normal file
36
pyinfra/server/dispatcher/dispatcher.py
Normal file
36
pyinfra/server/dispatcher/dispatcher.py
Normal file
@ -0,0 +1,36 @@
|
||||
import abc
|
||||
from typing import Iterable
|
||||
|
||||
from more_itertools import peekable
|
||||
|
||||
|
||||
class Nothing:
|
||||
pass
|
||||
|
||||
|
||||
def is_not_nothing(x):
|
||||
return x is not Nothing
|
||||
|
||||
|
||||
def has_next(peekable_iter):
|
||||
return peekable_iter.peek(Nothing) is not Nothing
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __call__(self, packages: Iterable[dict]):
|
||||
yield from self.dispatch_methods(packages)
|
||||
|
||||
def dispatch_methods(self, packages):
|
||||
packages = peekable(packages)
|
||||
for package in packages:
|
||||
method = self.patch if has_next(packages) else self.post
|
||||
response = method(package)
|
||||
yield response
|
||||
|
||||
@abc.abstractmethod
|
||||
def patch(self, package):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def post(self, package):
|
||||
raise NotImplementedError
|
||||
0
pyinfra/server/dispatcher/dispatchers/__init__.py
Normal file
0
pyinfra/server/dispatcher/dispatchers/__init__.py
Normal file
21
pyinfra/server/dispatcher/dispatchers/queue.py
Normal file
21
pyinfra/server/dispatcher/dispatchers/queue.py
Normal file
@ -0,0 +1,21 @@
|
||||
from itertools import takewhile
|
||||
|
||||
from funcy import repeatedly, notnone
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Dispatcher
|
||||
from pyinfra.server.stream.queued_stream_function import QueuedStreamFunction
|
||||
|
||||
|
||||
class QueuedStreamFunctionDispatcher(Dispatcher):
|
||||
def __init__(self, queued_stream_function: QueuedStreamFunction):
|
||||
self.queued_stream_function = queued_stream_function
|
||||
|
||||
def patch(self, package):
|
||||
self.queued_stream_function.push(package)
|
||||
# TODO: this is wonky and a result of the pipeline components having shifted behaviour through previous
|
||||
# refactorings. The analogous functionality for the rest pipeline is in the interpreter. Correct this
|
||||
# asymmetry!
|
||||
yield from takewhile(notnone, repeatedly(self.queued_stream_function.pop))
|
||||
|
||||
def post(self, package):
|
||||
yield from self.patch(package)
|
||||
14
pyinfra/server/dispatcher/dispatchers/rest.py
Normal file
14
pyinfra/server/dispatcher/dispatchers/rest.py
Normal file
@ -0,0 +1,14 @@
|
||||
import requests
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Dispatcher
|
||||
|
||||
|
||||
class RestDispatcher(Dispatcher):
|
||||
def __init__(self, endpoint):
|
||||
self.endpoint = endpoint
|
||||
|
||||
def patch(self, package):
|
||||
return requests.patch(self.endpoint, json=package)
|
||||
|
||||
def post(self, package):
|
||||
return requests.post(self.endpoint, json=package)
|
||||
0
pyinfra/server/exceptions.py
Normal file
0
pyinfra/server/exceptions.py
Normal file
0
pyinfra/server/interpreter/__init__.py
Normal file
0
pyinfra/server/interpreter/__init__.py
Normal file
8
pyinfra/server/interpreter/interpreter.py
Normal file
8
pyinfra/server/interpreter/interpreter.py
Normal file
@ -0,0 +1,8 @@
|
||||
import abc
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class Interpreter(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def __call__(self, payloads: Iterable):
|
||||
pass
|
||||
0
pyinfra/server/interpreter/interpreters/__init__.py
Normal file
0
pyinfra/server/interpreter/interpreters/__init__.py
Normal file
8
pyinfra/server/interpreter/interpreters/identity.py
Normal file
8
pyinfra/server/interpreter/interpreters/identity.py
Normal file
@ -0,0 +1,8 @@
|
||||
from typing import Iterable
|
||||
|
||||
from pyinfra.server.interpreter.interpreter import Interpreter
|
||||
|
||||
|
||||
class IdentityInterpreter(Interpreter):
|
||||
def __call__(self, payloads: Iterable):
|
||||
yield from payloads
|
||||
23
pyinfra/server/interpreter/interpreters/rest_callback.py
Normal file
23
pyinfra/server/interpreter/interpreters/rest_callback.py
Normal file
@ -0,0 +1,23 @@
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
from funcy import takewhile, repeatedly, mapcat
|
||||
|
||||
from pyinfra.server.interpreter.interpreter import Interpreter
|
||||
|
||||
|
||||
def stream_responses(endpoint):
|
||||
def receive():
|
||||
response = requests.get(endpoint)
|
||||
return response
|
||||
|
||||
def more_is_coming(response):
|
||||
return response.status_code == 206
|
||||
|
||||
response_stream = takewhile(more_is_coming, repeatedly(receive))
|
||||
yield from response_stream
|
||||
|
||||
|
||||
class RestPickupStreamer(Interpreter):
|
||||
def __call__(self, payloads: Iterable):
|
||||
yield from mapcat(stream_responses, payloads)
|
||||
17
pyinfra/server/normalization.py
Normal file
17
pyinfra/server/normalization.py
Normal file
@ -0,0 +1,17 @@
|
||||
from itertools import chain
|
||||
from typing import Iterable, Union, Tuple
|
||||
|
||||
from pyinfra.exceptions import UnexpectedItemType
|
||||
|
||||
|
||||
def normalize(itr: Iterable[Union[Tuple, Iterable]]) -> Iterable[Tuple]:
|
||||
return chain.from_iterable(map(normalize_item, normalize_item(itr)))
|
||||
|
||||
|
||||
def normalize_item(itm: Union[Tuple, Iterable]) -> Iterable:
|
||||
if isinstance(itm, tuple):
|
||||
return [itm]
|
||||
elif isinstance(itm, Iterable):
|
||||
return itm
|
||||
else:
|
||||
raise UnexpectedItemType("Encountered an item that could not be normalized to a list.")
|
||||
0
pyinfra/server/packer/__init__.py
Normal file
0
pyinfra/server/packer/__init__.py
Normal file
8
pyinfra/server/packer/packer.py
Normal file
8
pyinfra/server/packer/packer.py
Normal file
@ -0,0 +1,8 @@
|
||||
import abc
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class Packer(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def __call__(self, data: Iterable, metadata: Iterable):
|
||||
pass
|
||||
0
pyinfra/server/packer/packers/__init__.py
Normal file
0
pyinfra/server/packer/packers/__init__.py
Normal file
14
pyinfra/server/packer/packers/identity.py
Normal file
14
pyinfra/server/packer/packers/identity.py
Normal file
@ -0,0 +1,14 @@
|
||||
from itertools import starmap
|
||||
from typing import Iterable
|
||||
|
||||
from pyinfra.server.packer.packer import Packer
|
||||
|
||||
|
||||
def bundle(data: bytes, metadata: dict):
|
||||
package = {"data": data, "metadata": metadata}
|
||||
return package
|
||||
|
||||
|
||||
class IdentityPacker(Packer):
|
||||
def __call__(self, data: Iterable, metadata):
|
||||
yield from starmap(bundle, zip(data, metadata))
|
||||
9
pyinfra/server/packer/packers/rest.py
Normal file
9
pyinfra/server/packer/packers/rest.py
Normal file
@ -0,0 +1,9 @@
|
||||
from typing import Iterable
|
||||
|
||||
from pyinfra.server.packer.packer import Packer
|
||||
from pyinfra.server.packing import pack_data_and_metadata_for_rest_transfer
|
||||
|
||||
|
||||
class RestPacker(Packer):
|
||||
def __call__(self, data: Iterable[bytes], metadata: Iterable[dict]):
|
||||
yield from pack_data_and_metadata_for_rest_transfer(data, metadata)
|
||||
34
pyinfra/server/packing.py
Normal file
34
pyinfra/server/packing.py
Normal file
@ -0,0 +1,34 @@
|
||||
import base64
|
||||
from _operator import itemgetter
|
||||
from itertools import starmap
|
||||
from typing import Iterable
|
||||
|
||||
from funcy import compose
|
||||
|
||||
from pyinfra.utils.func import starlift, lift
|
||||
|
||||
|
||||
def pack_data_and_metadata_for_rest_transfer(data: Iterable, metadata: Iterable):
|
||||
yield from starmap(pack, zip(data, metadata))
|
||||
|
||||
|
||||
def unpack_fn_pack(fn):
|
||||
return compose(starlift(pack), fn, lift(unpack))
|
||||
|
||||
|
||||
def pack(data: bytes, metadata: dict):
|
||||
package = {"data": bytes_to_string(data), "metadata": metadata}
|
||||
return package
|
||||
|
||||
|
||||
def unpack(package):
|
||||
data, metadata = itemgetter("data", "metadata")(package)
|
||||
return string_to_bytes(data), metadata
|
||||
|
||||
|
||||
def bytes_to_string(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def string_to_bytes(data: str) -> bytes:
|
||||
return base64.b64decode(data.encode())
|
||||
0
pyinfra/server/receiver/__init__.py
Normal file
0
pyinfra/server/receiver/__init__.py
Normal file
8
pyinfra/server/receiver/receiver.py
Normal file
8
pyinfra/server/receiver/receiver.py
Normal file
@ -0,0 +1,8 @@
|
||||
import abc
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class Receiver(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def __call__(self, package: Iterable):
|
||||
pass
|
||||
0
pyinfra/server/receiver/receivers/__init__.py
Normal file
0
pyinfra/server/receiver/receivers/__init__.py
Normal file
11
pyinfra/server/receiver/receivers/identity.py
Normal file
11
pyinfra/server/receiver/receivers/identity.py
Normal file
@ -0,0 +1,11 @@
|
||||
from typing import Iterable
|
||||
|
||||
from pyinfra.server.receiver.receiver import Receiver
|
||||
from funcy import notnone
|
||||
|
||||
|
||||
class QueuedStreamFunctionReceiver(Receiver):
|
||||
|
||||
def __call__(self, responses: Iterable):
|
||||
for response in filter(notnone, responses):
|
||||
yield response
|
||||
16
pyinfra/server/receiver/receivers/rest.py
Normal file
16
pyinfra/server/receiver/receivers/rest.py
Normal file
@ -0,0 +1,16 @@
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
from funcy import chunks, flatten
|
||||
|
||||
from pyinfra.server.receiver.receiver import Receiver
|
||||
|
||||
|
||||
class RestReceiver(Receiver):
|
||||
def __init__(self, chunk_size=3):
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
def __call__(self, responses: Iterable[requests.Response]):
|
||||
for response in flatten(chunks(self.chunk_size, responses)):
|
||||
response.raise_for_status()
|
||||
yield response.json()
|
||||
39
pyinfra/server/rest.py
Normal file
39
pyinfra/server/rest.py
Normal file
@ -0,0 +1,39 @@
|
||||
# from typing import Iterable
|
||||
#
|
||||
# from funcy import rcompose
|
||||
#
|
||||
# from pyinfra.server.dispatcher.dispatchers.rest import RestDispatcher
|
||||
# from pyinfra.server.interpreter.interpreters.identity import IdentityInterpreter
|
||||
# from pyinfra.server.interpreter.interpreters.rest_callback import RestPickupStreamer
|
||||
# from pyinfra.server.packer.packers.rest import RestPacker
|
||||
# from pyinfra.server.client_pipeline import ClientPipeline
|
||||
# from pyinfra.server.receiver.receivers.rest import RestReceiver
|
||||
#
|
||||
#
|
||||
# def process_eagerly(endpoint, data: Iterable[bytes], metadata: Iterable[dict]):
|
||||
# """Posts `data` to `url` and aggregates responses for each element of `data`."""
|
||||
# pipeline = get_eager_pipeline(endpoint)
|
||||
# yield from pipeline(data, metadata)
|
||||
#
|
||||
#
|
||||
# def process_lazily(endpoint, data: Iterable[bytes], metadata: Iterable[dict]):
|
||||
# """Posts `data` to `url` and aggregates responses for each element of `data` by querying a pickup endpoint.
|
||||
#
|
||||
# Requires:
|
||||
# - responses must provide return pickup_endpoint as JSON payload
|
||||
# - responses must have status code 206 for more responses coming and 204 for the last response already sent
|
||||
# """
|
||||
# pipeline = get_lazy_pipeline(endpoint)
|
||||
# yield from pipeline(data, metadata)
|
||||
#
|
||||
#
|
||||
# def get_eager_pipeline(endpoint):
|
||||
# return ClientPipeline(*pipeline_head(endpoint), IdentityInterpreter())
|
||||
#
|
||||
#
|
||||
# def get_lazy_pipeline(endpoint):
|
||||
# return ClientPipeline(*pipeline_head(endpoint), rcompose(RestPickupStreamer(), RestReceiver()))
|
||||
#
|
||||
#
|
||||
# def pipeline_head(url):
|
||||
# return RestPacker(), RestDispatcher(url), RestReceiver()
|
||||
89
pyinfra/server/server.py
Normal file
89
pyinfra/server/server.py
Normal file
@ -0,0 +1,89 @@
|
||||
from functools import singledispatch
|
||||
from typing import Dict
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from funcy import merge
|
||||
from prometheus_client import generate_latest, Summary, CollectorRegistry
|
||||
|
||||
from pyinfra.server.buffering.stream import FlatStreamBuffer
|
||||
from pyinfra.server.stream.queued_stream_function import QueuedStreamFunction
|
||||
from pyinfra.server.stream.rest import LazyRestProcessor
|
||||
|
||||
|
||||
def set_up_processing_server(server_stream_function, buffer_size):
|
||||
flat_stream_buffer = FlatStreamBuffer(server_stream_function, buffer_size=buffer_size)
|
||||
queued_stream_function = QueuedStreamFunction(flat_stream_buffer)
|
||||
return __set_up_processing_server(queued_stream_function)
|
||||
|
||||
|
||||
def build_endpoint_suffixes(op: str):
|
||||
return {"submit_suffix": submit_suffix(op), "pickup_suffix": pickup_suffix(op)}
|
||||
|
||||
|
||||
def submit_suffix(op: str):
|
||||
return "submit" if not op else op
|
||||
|
||||
|
||||
def pickup_suffix(op: str):
|
||||
return "pickup" if not op else f"{op}_pickup"
|
||||
|
||||
|
||||
@singledispatch
|
||||
def __set_up_processing_server(arg):
|
||||
pass
|
||||
|
||||
|
||||
@__set_up_processing_server.register
|
||||
def _(operation2function: dict):
|
||||
return __set_up_processing_server_impl(operation2function)
|
||||
|
||||
|
||||
@__set_up_processing_server.register
|
||||
def _(queued_stream_function: object):
|
||||
operation2function = {None: queued_stream_function}
|
||||
return __set_up_processing_server_impl(operation2function)
|
||||
|
||||
|
||||
def __set_up_processing_server_impl(operation2function: Dict[str, QueuedStreamFunction]):
|
||||
app = Flask(__name__)
|
||||
registry = CollectorRegistry(auto_describe=True)
|
||||
|
||||
operation2processor = {
|
||||
op: LazyRestProcessor(fn, **build_endpoint_suffixes(op)) for op, fn in operation2function.items()
|
||||
}
|
||||
|
||||
def make_summary_instance(op):
|
||||
return Summary(f"redactmanager_{op}_seconds", f"Time spent on {op}.", registry=registry)
|
||||
|
||||
submit_operation2processor = {submit_suffix(op): prc for op, prc in operation2processor.items()}
|
||||
pickup_operation2processor = {pickup_suffix(op): prc for op, prc in operation2processor.items()}
|
||||
operation2processor = merge(submit_operation2processor, pickup_operation2processor)
|
||||
|
||||
operation2metric = {op: make_summary_instance(op) for op in operation2processor}
|
||||
|
||||
@app.route("/ready", methods=["GET"])
|
||||
def ready():
|
||||
resp = jsonify("OK")
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def healthy():
|
||||
resp = jsonify("OK")
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
|
||||
@app.route("/prometheus", methods=["GET"])
|
||||
def prometheus():
|
||||
return generate_latest(registry=registry)
|
||||
|
||||
@app.route("/<operation>", methods=["POST", "PATCH"])
|
||||
def submit(operation):
|
||||
return operation2processor[operation].push(request)
|
||||
|
||||
@app.route("/<operation>", methods=["GET"])
|
||||
def pickup(operation):
|
||||
with operation2metric[operation.replace("_pickup", "")].time():
|
||||
return operation2processor[operation].pop()
|
||||
|
||||
return app
|
||||
0
pyinfra/server/stream/__init__.py
Normal file
0
pyinfra/server/stream/__init__.py
Normal file
16
pyinfra/server/stream/queued_stream_function.py
Normal file
16
pyinfra/server/stream/queued_stream_function.py
Normal file
@ -0,0 +1,16 @@
|
||||
from funcy import first
|
||||
|
||||
from pyinfra.server.buffering.queue import stream_queue, Queue
|
||||
|
||||
|
||||
class QueuedStreamFunction:
|
||||
def __init__(self, fn):
|
||||
self.queue = Queue()
|
||||
self.fn = fn
|
||||
|
||||
def push(self, item):
|
||||
self.queue.append(item)
|
||||
|
||||
def pop(self):
|
||||
items = stream_queue(self.queue)
|
||||
return first(self.fn(items))
|
||||
40
pyinfra/server/stream/rest.py
Normal file
40
pyinfra/server/stream/rest.py
Normal file
@ -0,0 +1,40 @@
|
||||
import logging
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing
|
||||
from pyinfra.server.stream.queued_stream_function import QueuedStreamFunction
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class LazyRestProcessor:
|
||||
def __init__(self, queued_stream_function: QueuedStreamFunction, submit_suffix="submit", pickup_suffix="pickup"):
|
||||
self.submit_suffix = submit_suffix
|
||||
self.pickup_suffix = pickup_suffix
|
||||
self.queued_stream_function = queued_stream_function
|
||||
|
||||
def push(self, request):
|
||||
self.queued_stream_function.push(request.json)
|
||||
return jsonify(request.base_url.replace(self.submit_suffix, self.pickup_suffix))
|
||||
|
||||
def pop(self):
|
||||
result = self.queued_stream_function.pop() or Nothing
|
||||
|
||||
if not valid(result):
|
||||
logger.error(f"Received invalid result: {result}")
|
||||
result = Nothing
|
||||
|
||||
if result is Nothing:
|
||||
resp = jsonify("No more items left")
|
||||
resp.status_code = 204
|
||||
|
||||
else:
|
||||
resp = jsonify(result)
|
||||
resp.status_code = 206
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def valid(result):
|
||||
return isinstance(result, dict) or result is Nothing
|
||||
15
pyinfra/server/utils.py
Normal file
15
pyinfra/server/utils.py
Normal file
@ -0,0 +1,15 @@
|
||||
from funcy import compose, identity
|
||||
|
||||
from pyinfra.server.normalization import normalize
|
||||
from pyinfra.server.packing import unpack_fn_pack
|
||||
from pyinfra.utils.func import starlift
|
||||
|
||||
|
||||
def make_streamable_and_wrap_in_packing_logic(fn, batched):
|
||||
fn = make_streamable(fn, batched)
|
||||
fn = unpack_fn_pack(fn)
|
||||
return fn
|
||||
|
||||
|
||||
def make_streamable(fn, batched):
|
||||
return compose(normalize, (identity if batched else starlift)(fn))
|
||||
@ -1,12 +1,11 @@
|
||||
import logging
|
||||
from itertools import repeat
|
||||
from operator import attrgetter
|
||||
|
||||
from azure.storage.blob import ContainerClient, BlobServiceClient
|
||||
|
||||
from pyinfra.storage.adapters.adapter import StorageAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger()
|
||||
logging.getLogger("azure").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
@ -20,15 +19,15 @@ class AzureStorageAdapter(StorageAdapter):
|
||||
container_client = self.__client.get_container_client(bucket_name)
|
||||
return container_client.exists()
|
||||
|
||||
def make_bucket(self, bucket_name):
|
||||
container_client = self.__client.get_container_client(bucket_name)
|
||||
container_client if container_client.exists() else self.__client.create_container(bucket_name)
|
||||
|
||||
def __provide_container_client(self, bucket_name) -> ContainerClient:
|
||||
self.make_bucket(bucket_name)
|
||||
container_client = self.__client.get_container_client(bucket_name)
|
||||
return container_client
|
||||
|
||||
def make_bucket(self, bucket_name):
|
||||
container_client = self.__client.get_container_client(bucket_name)
|
||||
container_client if container_client.exists() else self.__client.create_container(bucket_name)
|
||||
|
||||
def put_object(self, bucket_name, object_name, data):
|
||||
logger.debug(f"Uploading '{object_name}'...")
|
||||
container_client = self.__provide_container_client(bucket_name)
|
||||
@ -62,4 +61,4 @@ class AzureStorageAdapter(StorageAdapter):
|
||||
def get_all_object_names(self, bucket_name):
|
||||
container_client = self.__provide_container_client(bucket_name)
|
||||
blobs = container_client.list_blobs()
|
||||
return zip(repeat(bucket_name), map(attrgetter("name"), blobs))
|
||||
return map(attrgetter("name"), blobs)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import io
|
||||
from itertools import repeat
|
||||
import logging
|
||||
from itertools import repeat
|
||||
from operator import attrgetter
|
||||
|
||||
from minio import Minio
|
||||
@ -8,7 +8,7 @@ from minio import Minio
|
||||
from pyinfra.exceptions import DataLoadingFailure
|
||||
from pyinfra.storage.adapters.adapter import StorageAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class S3StorageAdapter(StorageAdapter):
|
||||
@ -55,4 +55,4 @@ class S3StorageAdapter(StorageAdapter):
|
||||
|
||||
def get_all_object_names(self, bucket_name):
|
||||
objs = self.__client.list_objects(bucket_name, recursive=True)
|
||||
return zip(repeat(bucket_name), map(attrgetter("object_name"), objs))
|
||||
return map(attrgetter("object_name"), objs)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import logging
|
||||
|
||||
from retry import retry
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.exceptions import DataLoadingFailure
|
||||
from pyinfra.storage.adapters.adapter import StorageAdapter
|
||||
from pyinfra.utils.retry import retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(CONFIG.service.logging_level)
|
||||
@ -26,7 +26,7 @@ class Storage:
|
||||
def get_object(self, bucket_name, object_name):
|
||||
return self.__get_object(bucket_name, object_name)
|
||||
|
||||
@retry(DataLoadingFailure, tries=3, delay=5, jitter=(1, 3))
|
||||
@retry(DataLoadingFailure)
|
||||
def __get_object(self, bucket_name, object_name):
|
||||
try:
|
||||
return self.__adapter.get_object(bucket_name, object_name)
|
||||
|
||||
16
pyinfra/utils/encoding.py
Normal file
16
pyinfra/utils/encoding.py
Normal file
@ -0,0 +1,16 @@
|
||||
import gzip
|
||||
import json
|
||||
|
||||
from pyinfra.server.packing import bytes_to_string
|
||||
|
||||
|
||||
def pack_for_upload(data: bytes):
|
||||
return compress(json.dumps(bytes_to_string(data)).encode())
|
||||
|
||||
|
||||
def compress(data: bytes):
|
||||
return gzip.compress(data)
|
||||
|
||||
|
||||
def decompress(data: bytes):
|
||||
return gzip.decompress(data)
|
||||
47
pyinfra/utils/func.py
Normal file
47
pyinfra/utils/func.py
Normal file
@ -0,0 +1,47 @@
|
||||
from itertools import starmap, tee
|
||||
|
||||
from funcy import curry, compose, filter
|
||||
|
||||
|
||||
def lift(fn):
|
||||
return curry(map)(fn)
|
||||
|
||||
|
||||
def llift(fn):
|
||||
return compose(list, lift(fn))
|
||||
|
||||
|
||||
def starlift(fn):
|
||||
return curry(starmap)(fn)
|
||||
|
||||
|
||||
def lstarlift(fn):
|
||||
return compose(list, starlift(fn))
|
||||
|
||||
|
||||
def parallel(*fs):
|
||||
return lambda *args: (f(a) for f, a in zip(fs, args))
|
||||
|
||||
|
||||
def star(f):
|
||||
return lambda x: f(*x)
|
||||
|
||||
|
||||
def duplicate_stream_and_apply(f1, f2):
|
||||
return compose(star(parallel(f1, f2)), tee)
|
||||
|
||||
|
||||
def foreach(fn, iterable):
|
||||
for itm in iterable:
|
||||
fn(itm)
|
||||
|
||||
|
||||
def flift(pred):
|
||||
return curry(filter)(pred)
|
||||
|
||||
|
||||
def parallel_map(f1, f2):
|
||||
"""Applies functions to a stream in parallel and yields a stream of tuples:
|
||||
parallel_map :: a -> b, a -> c -> [a] -> [(b, c)]
|
||||
"""
|
||||
return compose(star(zip), duplicate_stream_and_apply(f1, f2))
|
||||
15
pyinfra/utils/retry.py
Normal file
15
pyinfra/utils/retry.py
Normal file
@ -0,0 +1,15 @@
|
||||
from pyinfra.config import CONFIG
|
||||
from retry import retry as _retry
|
||||
|
||||
|
||||
def retry(exception):
|
||||
|
||||
def decorator(fn):
|
||||
|
||||
@_retry(exception, tries=CONFIG.retry.tries, delay=CONFIG.retry.delay, jitter=tuple(CONFIG.retry.jitter))
|
||||
def inner(*args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
|
||||
return decorator
|
||||
@ -1,91 +0,0 @@
|
||||
import abc
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
from typing import Callable
|
||||
|
||||
from pyinfra.config import CONFIG, parse_disjunction_string
|
||||
from pyinfra.exceptions import DataLoadingFailure
|
||||
from pyinfra.storage.storage import Storage
|
||||
|
||||
|
||||
def get_object_name(body):
|
||||
dossier_id, file_id, target_file_extension = itemgetter("dossierId", "fileId", "targetFileExtension")(body)
|
||||
object_name = f"{dossier_id}/{file_id}.{target_file_extension}"
|
||||
return object_name
|
||||
|
||||
|
||||
def get_response_object_name(body):
|
||||
dossier_id, file_id, response_file_extension = itemgetter("dossierId", "fileId", "responseFileExtension")(body)
|
||||
object_name = f"{dossier_id}/{file_id}.{response_file_extension}"
|
||||
return object_name
|
||||
|
||||
|
||||
def get_object_descriptor(body):
|
||||
return {"bucket_name": parse_disjunction_string(CONFIG.storage.bucket), "object_name": get_object_name(body)}
|
||||
|
||||
|
||||
def get_response_object_descriptor(body):
|
||||
return {
|
||||
"bucket_name": parse_disjunction_string(CONFIG.storage.bucket),
|
||||
"object_name": get_response_object_name(body),
|
||||
}
|
||||
|
||||
|
||||
class ResponseStrategy(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def handle_response(self, body):
|
||||
pass
|
||||
|
||||
def __call__(self, body):
|
||||
return self.handle_response(body)
|
||||
|
||||
|
||||
class StorageStrategy(ResponseStrategy):
|
||||
def __init__(self, storage):
|
||||
self.storage = storage
|
||||
|
||||
def handle_response(self, body):
|
||||
self.storage.put_object(**get_response_object_descriptor(body), data=gzip.compress(json.dumps(body).encode()))
|
||||
body.pop("data")
|
||||
return body
|
||||
|
||||
|
||||
class ForwardingStrategy(ResponseStrategy):
|
||||
def handle_response(self, body):
|
||||
return body
|
||||
|
||||
|
||||
class QueueVisitor:
|
||||
def __init__(self, storage: Storage, callback: Callable, response_strategy):
|
||||
self.storage = storage
|
||||
self.callback = callback
|
||||
self.response_strategy = response_strategy
|
||||
|
||||
def load_data(self, body):
|
||||
def download():
|
||||
logging.debug(f"Downloading {object_descriptor}...")
|
||||
data = self.storage.get_object(**object_descriptor)
|
||||
logging.debug(f"Downloaded {object_descriptor}.")
|
||||
return data
|
||||
|
||||
object_descriptor = get_object_descriptor(body)
|
||||
|
||||
try:
|
||||
return gzip.decompress(download())
|
||||
except Exception as err:
|
||||
logging.warning(f"Loading data from storage failed for {object_descriptor}.")
|
||||
raise DataLoadingFailure from err
|
||||
|
||||
def process_data(self, data, body):
|
||||
return self.callback({**body, "data": data})
|
||||
|
||||
def load_and_process(self, body):
|
||||
data = self.process_data(self.load_data(body), body)
|
||||
result_body = {**body, "data": data}
|
||||
return result_body
|
||||
|
||||
def __call__(self, body):
|
||||
result_body = self.load_and_process(body)
|
||||
return self.response_strategy(result_body)
|
||||
1
pyinfra/visitor/__init__.py
Normal file
1
pyinfra/visitor/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .visitor import QueueVisitor
|
||||
0
pyinfra/visitor/dispatch/__init__.py
Normal file
0
pyinfra/visitor/dispatch/__init__.py
Normal file
8
pyinfra/visitor/dispatch/dispatch.py
Normal file
8
pyinfra/visitor/dispatch/dispatch.py
Normal file
@ -0,0 +1,8 @@
|
||||
import abc
|
||||
|
||||
|
||||
# TODO: rename
|
||||
class DispatchCallback(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def __call__(self, payload):
|
||||
pass
|
||||
21
pyinfra/visitor/dispatch/identifier_dispatch.py
Normal file
21
pyinfra/visitor/dispatch/identifier_dispatch.py
Normal file
@ -0,0 +1,21 @@
|
||||
from _operator import itemgetter
|
||||
|
||||
from pyinfra.visitor.dispatch.dispatch import DispatchCallback
|
||||
|
||||
|
||||
class IdentifierDispatchCallback(DispatchCallback):
|
||||
def __init__(self):
|
||||
self.identifier = None
|
||||
|
||||
def has_new_identifier(self, metadata):
|
||||
|
||||
identifier = ":".join(itemgetter("fileId", "dossierId")(metadata))
|
||||
|
||||
if not self.identifier:
|
||||
self.identifier = identifier
|
||||
|
||||
return identifier != self.identifier
|
||||
|
||||
def __call__(self, metadata):
|
||||
|
||||
return self.has_new_identifier(metadata)
|
||||
0
pyinfra/visitor/strategies/__init__.py
Normal file
0
pyinfra/visitor/strategies/__init__.py
Normal file
0
pyinfra/visitor/strategies/blob_parsing/__init__.py
Normal file
0
pyinfra/visitor/strategies/blob_parsing/__init__.py
Normal file
14
pyinfra/visitor/strategies/blob_parsing/blob_parsing.py
Normal file
14
pyinfra/visitor/strategies/blob_parsing/blob_parsing.py
Normal file
@ -0,0 +1,14 @@
|
||||
import abc
|
||||
|
||||
|
||||
class BlobParsingStrategy(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def parse(self, data: bytes):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def parse_and_wrap(self, data: bytes):
|
||||
pass
|
||||
|
||||
def __call__(self, data: bytes):
|
||||
return self.parse_and_wrap(data)
|
||||
20
pyinfra/visitor/strategies/blob_parsing/dynamic.py
Normal file
20
pyinfra/visitor/strategies/blob_parsing/dynamic.py
Normal file
@ -0,0 +1,20 @@
|
||||
from typing import Union
|
||||
|
||||
from pyinfra.parser.parser_composer import EitherParserComposer
|
||||
from pyinfra.parser.parsers.identity import IdentityBlobParser
|
||||
from pyinfra.parser.parsers.json import JsonBlobParser
|
||||
from pyinfra.parser.parsers.string import StringBlobParser
|
||||
from pyinfra.visitor.strategies.blob_parsing.blob_parsing import BlobParsingStrategy
|
||||
|
||||
|
||||
# TODO: Each analysis service should specify a custom parsing strategy for the type of data it expects to be found
|
||||
# on the storage. This class is only a temporary trial-and-error->fallback type of solution.
|
||||
class DynamicParsingStrategy(BlobParsingStrategy):
|
||||
def __init__(self):
|
||||
self.parser = EitherParserComposer(JsonBlobParser(), StringBlobParser(), IdentityBlobParser())
|
||||
|
||||
def parse(self, data: bytes) -> Union[bytes, dict]:
|
||||
return self.parser(data)
|
||||
|
||||
def parse_and_wrap(self, data):
|
||||
return self.parse(data)
|
||||
0
pyinfra/visitor/strategies/download/__init__.py
Normal file
0
pyinfra/visitor/strategies/download/__init__.py
Normal file
38
pyinfra/visitor/strategies/download/download.py
Normal file
38
pyinfra/visitor/strategies/download/download.py
Normal file
@ -0,0 +1,38 @@
|
||||
import abc
|
||||
import logging
|
||||
|
||||
from pyinfra.config import parse_disjunction_string, CONFIG
|
||||
from pyinfra.exceptions import DataLoadingFailure
|
||||
from pyinfra.utils.encoding import decompress
|
||||
|
||||
|
||||
class DownloadStrategy(abc.ABC):
|
||||
def _load_data(self, storage, queue_item_body):
|
||||
object_descriptor = self.get_object_descriptor(queue_item_body)
|
||||
logging.debug(f"Downloading {object_descriptor}...")
|
||||
data = self.__download(storage, object_descriptor)
|
||||
logging.debug(f"Downloaded {object_descriptor}.")
|
||||
assert isinstance(data, bytes)
|
||||
data = decompress(data)
|
||||
return [data]
|
||||
|
||||
@staticmethod
|
||||
def __download(storage, object_descriptor):
|
||||
try:
|
||||
data = storage.get_object(**object_descriptor)
|
||||
except Exception as err:
|
||||
logging.warning(f"Loading data from storage failed for {object_descriptor}.")
|
||||
raise DataLoadingFailure from err
|
||||
|
||||
return data
|
||||
|
||||
def get_object_descriptor(self, body):
|
||||
return {
|
||||
"bucket_name": parse_disjunction_string(CONFIG.storage.bucket),
|
||||
"object_name": self.get_object_name(body),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def get_object_name(body: dict):
|
||||
raise NotImplementedError
|
||||
58
pyinfra/visitor/strategies/download/multi.py
Normal file
58
pyinfra/visitor/strategies/download/multi.py
Normal file
@ -0,0 +1,58 @@
|
||||
from _operator import itemgetter
|
||||
from functools import partial
|
||||
from typing import Collection
|
||||
|
||||
from funcy import compose
|
||||
|
||||
from pyinfra.config import parse_disjunction_string, CONFIG
|
||||
from pyinfra.exceptions import InvalidMessage
|
||||
from pyinfra.storage.storage import Storage
|
||||
from pyinfra.utils.encoding import decompress
|
||||
from pyinfra.utils.func import flift
|
||||
from pyinfra.visitor.strategies.download.download import DownloadStrategy
|
||||
|
||||
|
||||
class MultiDownloadStrategy(DownloadStrategy):
|
||||
def __init__(self):
|
||||
# TODO: pass in bucket name from outside / introduce closure-like abstraction for the bucket
|
||||
self.bucket_name = parse_disjunction_string(CONFIG.storage.bucket)
|
||||
|
||||
def get_page_matcher(self, pages):
|
||||
pages = "|".join(map(str, pages))
|
||||
page_matcher = r".*id:(" + pages + r").*"
|
||||
return page_matcher
|
||||
|
||||
def get_names_of_objects_by_pages(self, storage, pages: Collection[int]):
|
||||
matches_page = flift(self.get_page_matcher(pages))
|
||||
page_object_names = compose(matches_page, storage.get_all_object_names)(self.bucket_name)
|
||||
return page_object_names
|
||||
|
||||
def download_and_decompress_object(self, storage, object_names):
|
||||
download = partial(storage.get_object, self.bucket_name)
|
||||
return map(compose(decompress, download), object_names)
|
||||
|
||||
def download(self, storage: Storage, queue_item_body):
|
||||
page_object_names = self.get_names_of_objects_by_pages(storage, queue_item_body["pages"])
|
||||
objects = self.download_and_decompress_object(storage, page_object_names)
|
||||
|
||||
return objects
|
||||
|
||||
@staticmethod
|
||||
def get_object_name(body: dict):
|
||||
def get_key(key):
|
||||
return key if key in body else False
|
||||
|
||||
folder = f"/{get_key('pages') or get_key('images')}/"
|
||||
if not folder:
|
||||
raise InvalidMessage("Expected a folder like 'images' oder 'pages' to be specified in message.")
|
||||
|
||||
idnt = f"id:{body.get('id', 0)}"
|
||||
|
||||
dossier_id, file_id = itemgetter("dossierId", "fileId")(body)
|
||||
|
||||
object_name = f"{dossier_id}/{file_id}{folder}{idnt}.{CONFIG.service.target_file_extension}"
|
||||
|
||||
return object_name
|
||||
|
||||
def __call__(self, storage, queue_item_body):
|
||||
return self.download(storage, queue_item_body)
|
||||
25
pyinfra/visitor/strategies/download/single.py
Normal file
25
pyinfra/visitor/strategies/download/single.py
Normal file
@ -0,0 +1,25 @@
|
||||
from _operator import itemgetter
|
||||
from copy import deepcopy
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.visitor.strategies.download.download import DownloadStrategy
|
||||
|
||||
|
||||
class SingleDownloadStrategy(DownloadStrategy):
|
||||
def download(self, storage, queue_item_body):
|
||||
return self._load_data(storage, queue_item_body)
|
||||
|
||||
@staticmethod
|
||||
def get_object_name(body: dict):
|
||||
|
||||
# TODO: deepcopy still necessary?
|
||||
body = deepcopy(body)
|
||||
|
||||
dossier_id, file_id = itemgetter("dossierId", "fileId")(body)
|
||||
|
||||
object_name = f"{dossier_id}/{file_id}.{CONFIG.service.target_file_extension}"
|
||||
|
||||
return object_name
|
||||
|
||||
def __call__(self, storage, queue_item_body):
|
||||
return self.download(storage, queue_item_body)
|
||||
0
pyinfra/visitor/strategies/response/__init__.py
Normal file
0
pyinfra/visitor/strategies/response/__init__.py
Normal file
60
pyinfra/visitor/strategies/response/aggregation.py
Normal file
60
pyinfra/visitor/strategies/response/aggregation.py
Normal file
@ -0,0 +1,60 @@
|
||||
import json
|
||||
from collections import deque
|
||||
from typing import Callable
|
||||
|
||||
from funcy import omit, filter
|
||||
from more_itertools import peekable
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing, is_not_nothing
|
||||
from pyinfra.utils.encoding import compress
|
||||
from pyinfra.visitor.dispatch.dispatch import DispatchCallback
|
||||
from pyinfra.visitor.dispatch.identifier_dispatch import IdentifierDispatchCallback
|
||||
from pyinfra.visitor.strategies.response.response import ResponseStrategy
|
||||
from pyinfra.visitor.utils import build_storage_upload_info
|
||||
|
||||
|
||||
class AggregationStorageStrategy(ResponseStrategy):
|
||||
def __init__(self, storage, merger: Callable = None, dispatch_callback: DispatchCallback = None):
|
||||
self.storage = storage
|
||||
self.merger = merger or list
|
||||
self.dispatch_callback = dispatch_callback or IdentifierDispatchCallback()
|
||||
self.buffer = deque()
|
||||
|
||||
def handle_response(self, analysis_response, final=False):
|
||||
def upload_or_aggregate(analysis_payload):
|
||||
return self.upload_or_aggregate(analysis_payload, request_metadata, last=not result_data.peek(False))
|
||||
|
||||
request_metadata = omit(analysis_response, ["data"])
|
||||
result_data = peekable(analysis_response["data"])
|
||||
|
||||
yield from filter(is_not_nothing, map(upload_or_aggregate, result_data))
|
||||
|
||||
def upload_or_aggregate(self, analysis_payload, request_metadata, last=False):
|
||||
"""analysis_payload : {data: ..., metadata: ...}"""
|
||||
|
||||
storage_upload_info = build_storage_upload_info(analysis_payload, request_metadata)
|
||||
analysis_payload["metadata"].pop("id")
|
||||
|
||||
if analysis_payload["data"]:
|
||||
return self.put_object(json.dumps(analysis_payload).encode(), storage_upload_info)
|
||||
|
||||
else:
|
||||
self.buffer.append(analysis_payload)
|
||||
if last or self.dispatch_callback(storage_upload_info):
|
||||
return self.upload_queue_items(storage_upload_info)
|
||||
else:
|
||||
return Nothing
|
||||
|
||||
def put_object(self, data: bytes, storage_upload_info):
|
||||
object_descriptor = self.get_response_object_descriptor(storage_upload_info)
|
||||
self.storage.put_object(**object_descriptor, data=compress(data))
|
||||
return {**storage_upload_info, "responseFile": object_descriptor["object_name"]}
|
||||
|
||||
def merge_queue_items(self):
|
||||
merged_buffer_content = self.merger(self.buffer)
|
||||
self.buffer.clear()
|
||||
return merged_buffer_content
|
||||
|
||||
def upload_queue_items(self, storage_upload_info):
|
||||
data = json.dumps(self.merge_queue_items()).encode()
|
||||
return self.put_object(data, storage_upload_info)
|
||||
6
pyinfra/visitor/strategies/response/forwarding.py
Normal file
6
pyinfra/visitor/strategies/response/forwarding.py
Normal file
@ -0,0 +1,6 @@
|
||||
from pyinfra.visitor.strategies.response.response import ResponseStrategy
|
||||
|
||||
|
||||
class ForwardingStrategy(ResponseStrategy):
|
||||
def handle_response(self, analysis_response):
|
||||
return analysis_response
|
||||
34
pyinfra/visitor/strategies/response/response.py
Normal file
34
pyinfra/visitor/strategies/response/response.py
Normal file
@ -0,0 +1,34 @@
|
||||
import abc
|
||||
from _operator import itemgetter
|
||||
|
||||
from pyinfra.config import parse_disjunction_string, CONFIG
|
||||
|
||||
|
||||
class ResponseStrategy(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def handle_response(self, analysis_response: dict):
|
||||
pass
|
||||
|
||||
def __call__(self, analysis_response: dict):
|
||||
return self.handle_response(analysis_response)
|
||||
|
||||
def get_response_object_descriptor(self, body):
|
||||
return {
|
||||
"bucket_name": parse_disjunction_string(CONFIG.storage.bucket),
|
||||
"object_name": self.get_response_object_name(body),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_response_object_name(body):
|
||||
|
||||
if "pages" not in body:
|
||||
body["pages"] = []
|
||||
|
||||
if "id" not in body:
|
||||
body["id"] = 0
|
||||
|
||||
dossier_id, file_id, pages, idnt = itemgetter("dossierId", "fileId", "pages", "id")(body)
|
||||
|
||||
object_name = f"{dossier_id}/{file_id}/id:{idnt}.{CONFIG.service.response_file_extension}"
|
||||
|
||||
return object_name
|
||||
16
pyinfra/visitor/strategies/response/storage.py
Normal file
16
pyinfra/visitor/strategies/response/storage.py
Normal file
@ -0,0 +1,16 @@
|
||||
import json
|
||||
|
||||
from pyinfra.utils.encoding import compress
|
||||
from pyinfra.visitor.strategies.response.response import ResponseStrategy
|
||||
|
||||
|
||||
class StorageStrategy(ResponseStrategy):
|
||||
def __init__(self, storage):
|
||||
self.storage = storage
|
||||
|
||||
def handle_response(self, body: dict):
|
||||
response_object_descriptor = self.get_response_object_descriptor(body)
|
||||
self.storage.put_object(**response_object_descriptor, data=compress(json.dumps(body).encode()))
|
||||
body.pop("data")
|
||||
body["responseFile"] = response_object_descriptor["object_name"]
|
||||
return body
|
||||
70
pyinfra/visitor/utils.py
Normal file
70
pyinfra/visitor/utils.py
Normal file
@ -0,0 +1,70 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.exceptions import InvalidStorageItemFormat
|
||||
from pyinfra.server.packing import string_to_bytes
|
||||
from pyinfra.visitor.strategies.download.multi import MultiDownloadStrategy
|
||||
from pyinfra.visitor.strategies.download.single import SingleDownloadStrategy
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def build_storage_upload_info(analysis_payload, request_metadata):
|
||||
storage_upload_info = {**request_metadata, "id": analysis_payload["metadata"].get("id", 0)}
|
||||
storage_upload_info["fileId"] = build_file_path(
|
||||
storage_upload_info, storage_upload_info.get("operation", CONFIG.service.response_folder)
|
||||
)
|
||||
return storage_upload_info
|
||||
|
||||
|
||||
def build_file_path(storage_upload_info, folder):
|
||||
return f"{storage_upload_info['fileId']}" + (f"/{folder}" if folder else "")
|
||||
|
||||
|
||||
def standardize(storage_item) -> Dict:
|
||||
"""Storage items can be a blob or a blob with metadata. Standardizes to the latter.
|
||||
|
||||
Cases:
|
||||
1) backend upload: data as bytes
|
||||
2) Some Python service's upload: data as bytes of a json string "{'data': <str>, 'metadata': <dict>}",
|
||||
where value of key 'data' was encoded with bytes_to_string(...)
|
||||
|
||||
Returns:
|
||||
{"data": bytes, "metadata": dict}
|
||||
"""
|
||||
|
||||
def is_blob_without_metadata(storage_item):
|
||||
return isinstance(storage_item, bytes)
|
||||
|
||||
def is_blob_with_metadata(storage_item: Dict):
|
||||
return isinstance(storage_item, dict)
|
||||
|
||||
if is_blob_without_metadata(storage_item):
|
||||
return wrap(storage_item)
|
||||
|
||||
elif is_blob_with_metadata(storage_item):
|
||||
validate(storage_item)
|
||||
return storage_item
|
||||
|
||||
else: # Fallback / used for testing with simple data
|
||||
logger.warning("Encountered storage data in unexpected format.")
|
||||
assert isinstance(storage_item, str)
|
||||
return wrap(string_to_bytes(storage_item))
|
||||
|
||||
|
||||
def wrap(data):
|
||||
return {"data": data, "metadata": {}}
|
||||
|
||||
|
||||
def validate(storage_item):
|
||||
if not ("data" in storage_item and "metadata" in storage_item):
|
||||
raise InvalidStorageItemFormat(f"Expected a mapping with keys 'data' and 'metadata', got {storage_item}.")
|
||||
|
||||
|
||||
def get_download_strategy(download_strategy_type=None):
|
||||
download_strategies = {
|
||||
"single": SingleDownloadStrategy(),
|
||||
"multi": MultiDownloadStrategy(),
|
||||
}
|
||||
return download_strategies.get(download_strategy_type or CONFIG.service.download_strategy, SingleDownloadStrategy())
|
||||
74
pyinfra/visitor/visitor.py
Normal file
74
pyinfra/visitor/visitor.py
Normal file
@ -0,0 +1,74 @@
|
||||
from typing import Callable
|
||||
|
||||
from funcy import lflatten, compose
|
||||
|
||||
from pyinfra.storage.storage import Storage
|
||||
from pyinfra.utils.func import lift
|
||||
from pyinfra.visitor.strategies.download.download import DownloadStrategy
|
||||
from pyinfra.visitor.strategies.blob_parsing.dynamic import DynamicParsingStrategy
|
||||
from pyinfra.visitor.strategies.blob_parsing.blob_parsing import BlobParsingStrategy
|
||||
from pyinfra.visitor.strategies.response.response import ResponseStrategy
|
||||
from pyinfra.visitor.strategies.response.storage import StorageStrategy
|
||||
from pyinfra.visitor.utils import standardize, get_download_strategy
|
||||
|
||||
|
||||
class QueueVisitor:
|
||||
def __init__(
|
||||
self,
|
||||
storage: Storage,
|
||||
callback: Callable,
|
||||
download_strategy: DownloadStrategy = None,
|
||||
parsing_strategy: BlobParsingStrategy = None,
|
||||
response_strategy: ResponseStrategy = None,
|
||||
):
|
||||
"""Processes queue messages that specify items on a storage to process with a given callback.
|
||||
|
||||
Args:
|
||||
storage: storage to pull items specified by queue message from
|
||||
callback: callback to apply to storage items
|
||||
download_strategy: behaviour for loading items from the storage
|
||||
parsing_strategy: behaviour for interpreting storage items
|
||||
response_strategy: behaviour for response production
|
||||
|
||||
Returns:
|
||||
depends on response strategy
|
||||
"""
|
||||
self.storage = storage
|
||||
self.callback = callback
|
||||
self.download_strategy = download_strategy or get_download_strategy()
|
||||
self.parsing_strategy = parsing_strategy or DynamicParsingStrategy()
|
||||
self.response_strategy = response_strategy or StorageStrategy()
|
||||
|
||||
def __call__(self, queue_item_body):
|
||||
analysis_result_body = self.load_items_from_storage_and_process_with_callback(queue_item_body)
|
||||
return self.response_strategy(analysis_result_body)
|
||||
|
||||
def load_items_from_storage_and_process_with_callback(self, queue_item_body):
|
||||
"""Bundles the result from processing a storage item with the body of the corresponding queue item."""
|
||||
|
||||
callback_results = compose(
|
||||
lflatten,
|
||||
lift(self.get_item_processor(queue_item_body)),
|
||||
self.load_data,
|
||||
)(queue_item_body)
|
||||
|
||||
return {"data": callback_results, **queue_item_body}
|
||||
|
||||
def get_item_processor(self, queue_item_body):
|
||||
def process_storage_item(storage_item):
|
||||
analysis_input = {**storage_item, **queue_item_body}
|
||||
return self.process_storage_item(analysis_input)
|
||||
|
||||
return process_storage_item
|
||||
|
||||
def load_data(self, queue_item_body):
|
||||
data = compose(
|
||||
lift(standardize),
|
||||
lift(self.parsing_strategy),
|
||||
self.download_strategy,
|
||||
)(self.storage, queue_item_body)
|
||||
|
||||
return data
|
||||
|
||||
def process_storage_item(self, data_metadata_pack):
|
||||
return self.callback(data_metadata_pack)
|
||||
@ -13,3 +13,8 @@ tqdm==4.62.3
|
||||
pytest~=7.0.1
|
||||
funcy==1.17
|
||||
fpdf==1.7.2
|
||||
PyMuPDF==1.19.6
|
||||
more-itertools==8.12.0
|
||||
numpy==1.22.3
|
||||
Pillow==9.0.1
|
||||
prometheus-client==0.13.1
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import argparse
|
||||
import gzip
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@ -7,6 +6,7 @@ from tqdm import tqdm
|
||||
|
||||
from pyinfra.config import CONFIG, parse_disjunction_string
|
||||
from pyinfra.storage.storages import get_s3_storage
|
||||
from pyinfra.utils.encoding import compress
|
||||
|
||||
|
||||
def parse_args():
|
||||
@ -31,7 +31,7 @@ def combine_dossier_id_and_file_id_and_extension(dossier_id, file_id, extension)
|
||||
|
||||
|
||||
def upload_compressed_response(storage, bucket_name, dossier_id, file_id, result) -> None:
|
||||
data = gzip.compress(result.encode())
|
||||
data = compress(result.encode())
|
||||
path_gz = combine_dossier_id_and_file_id_and_extension(dossier_id, file_id, CONFIG.service.response.extension)
|
||||
storage.put_object(bucket_name, path_gz, data)
|
||||
|
||||
@ -44,7 +44,7 @@ def add_file_compressed(storage, bucket_name, dossier_id, path) -> None:
|
||||
path_gz = combine_dossier_id_and_file_id_and_extension(dossier_id, Path(path).stem, suffix_gz)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = gzip.compress(f.read())
|
||||
data = compress(f.read())
|
||||
storage.put_object(bucket_name, path_gz, data)
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,12 @@ from pyinfra.storage.storages import get_s3_storage
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bucket_name", "-b", required=True)
|
||||
parser.add_argument("--analysis_container", "-a", choices=["detr", "ner", "image", "dl_error"], required=True)
|
||||
parser.add_argument(
|
||||
"--analysis_container",
|
||||
"-a",
|
||||
choices=["detr", "ner", "image", "conversion", "extraction", "dl_error"],
|
||||
required=True,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
@ -47,6 +52,19 @@ def build_message_bodies(analyse_container_type, bucket_name):
|
||||
def update_message(message_dict):
|
||||
if analyse_container_type == "detr" or analyse_container_type == "image":
|
||||
message_dict.update({"targetFileExtension": "ORIGIN.pdf.gz", "responseFileExtension": "IMAGE_INFO.json.gz"})
|
||||
if analyse_container_type == "conversion":
|
||||
message_dict.update(
|
||||
{
|
||||
"targetFileExtension": "ORIGIN.pdf.gz",
|
||||
"responseFileExtension": "json.gz",
|
||||
"operation": "conversion",
|
||||
"pages": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
if analyse_container_type == "extraction":
|
||||
message_dict.update(
|
||||
{"targetFileExtension": "ORIGIN.pdf.gz", "responseFileExtension": "json.gz", "operation": "extraction"}
|
||||
)
|
||||
if analyse_container_type == "dl_error":
|
||||
message_dict.update({"targetFileExtension": "no_such_file", "responseFileExtension": "IMAGE_INFO.json.gz"})
|
||||
if analyse_container_type == "ner":
|
||||
@ -56,7 +74,7 @@ def build_message_bodies(analyse_container_type, bucket_name):
|
||||
return message_dict
|
||||
|
||||
storage = get_s3_storage()
|
||||
for bucket_name, pdf_name in storage.get_all_object_names(bucket_name):
|
||||
for pdf_name in storage.get_all_object_names(bucket_name):
|
||||
if "pdf" not in pdf_name:
|
||||
continue
|
||||
file_id = pdf_name.split(".")[0]
|
||||
|
||||
57
src/serve.py
57
src/serve.py
@ -1,42 +1,25 @@
|
||||
import logging
|
||||
from multiprocessing import Process
|
||||
|
||||
import requests
|
||||
from retry import retry
|
||||
from pyinfra.utils.retry import retry
|
||||
|
||||
from pyinfra.config import CONFIG
|
||||
from pyinfra.exceptions import AnalysisFailure, ConsumerError
|
||||
from pyinfra.default_objects import get_consumer
|
||||
from pyinfra.exceptions import ConsumerError
|
||||
from pyinfra.flask import run_probing_webserver, set_up_probing_webserver
|
||||
from pyinfra.queue.consumer import Consumer
|
||||
from pyinfra.queue.queue_manager.pika_queue_manager import PikaQueueManager
|
||||
from pyinfra.storage.storages import get_storage
|
||||
from pyinfra.utils.banner import show_banner
|
||||
from pyinfra.visitor import QueueVisitor, StorageStrategy
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def make_callback(analysis_endpoint):
|
||||
def callback(message):
|
||||
def perform_operation(operation):
|
||||
endpoint = f"{analysis_endpoint}/{operation}"
|
||||
try:
|
||||
logging.debug(f"Requesting analysis from {endpoint}...")
|
||||
analysis_response = requests.post(endpoint, data=message["data"])
|
||||
analysis_response.raise_for_status()
|
||||
analysis_response = analysis_response.json()
|
||||
logging.debug(f"Received response.")
|
||||
return analysis_response
|
||||
except Exception as err:
|
||||
logging.warning(f"Exception caught when calling analysis endpoint {endpoint}.")
|
||||
raise AnalysisFailure() from err
|
||||
|
||||
operations = message.get("operations", ["/"])
|
||||
results = map(perform_operation, operations)
|
||||
result = dict(zip(operations, results))
|
||||
if list(result.keys()) == ["/"]:
|
||||
result = list(result.values())[0]
|
||||
return result
|
||||
|
||||
return callback
|
||||
@retry(ConsumerError)
|
||||
def consume():
|
||||
try:
|
||||
consumer = get_consumer()
|
||||
consumer.basic_consume_and_publish()
|
||||
except Exception as err:
|
||||
logger.exception(err)
|
||||
raise ConsumerError from err
|
||||
|
||||
|
||||
def main():
|
||||
@ -46,20 +29,6 @@ def main():
|
||||
logging.info("Starting webserver...")
|
||||
webserver.start()
|
||||
|
||||
callback = make_callback(CONFIG.rabbitmq.callback.analysis_endpoint)
|
||||
storage = get_storage(CONFIG.storage.backend)
|
||||
response_strategy = StorageStrategy(storage)
|
||||
visitor = QueueVisitor(storage, callback, response_strategy)
|
||||
|
||||
@retry(ConsumerError, tries=3, delay=5, jitter=(1, 3))
|
||||
def consume():
|
||||
try: # RED-4049 queue manager needs to be in try scope to eventually throw Exception after connection loss.
|
||||
queue_manager = PikaQueueManager(CONFIG.rabbitmq.queues.input, CONFIG.rabbitmq.queues.output)
|
||||
consumer = Consumer(visitor, queue_manager)
|
||||
consumer.basic_consume_and_publish()
|
||||
except Exception as err:
|
||||
raise ConsumerError from err
|
||||
|
||||
try:
|
||||
consume()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@ -22,4 +22,7 @@ webserver:
|
||||
mode: $SERVER_MODE|production # webserver mode: {development, production}
|
||||
|
||||
|
||||
mock_analysis_endpoint: "http://127.0.0.1:5000"
|
||||
mock_analysis_endpoint: "http://127.0.0.1:5000"
|
||||
|
||||
use_docker_fixture: True
|
||||
logging: False
|
||||
@ -1,11 +1,16 @@
|
||||
import json
|
||||
|
||||
from pyinfra.config import CONFIG as MAIN_CONFIG
|
||||
|
||||
MAIN_CONFIG["retry"]["delay"] = 0.1
|
||||
MAIN_CONFIG["retry"]["jitter"] = (0.1, 0.2)
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pika
|
||||
import pytest
|
||||
import testcontainers.compose
|
||||
from testcontainers.compose import DockerCompose
|
||||
|
||||
from pyinfra.exceptions import UnknownClient
|
||||
from pyinfra.locations import TEST_DIR, COMPOSE_PATH
|
||||
@ -16,14 +21,37 @@ from pyinfra.storage.adapters.s3 import S3StorageAdapter
|
||||
from pyinfra.storage.clients.azure import get_azure_client
|
||||
from pyinfra.storage.clients.s3 import get_s3_client
|
||||
from pyinfra.storage.storage import Storage
|
||||
from pyinfra.visitor import QueueVisitor
|
||||
from pyinfra.visitor.strategies.response.forwarding import ForwardingStrategy
|
||||
from pyinfra.visitor.strategies.response.storage import StorageStrategy
|
||||
from test.config import CONFIG
|
||||
from test.queue.queue_manager_mock import QueueManagerMock
|
||||
from test.storage.adapter_mock import StorageAdapterMock
|
||||
from test.storage.client_mock import StorageClientMock
|
||||
from pyinfra.visitor import StorageStrategy, ForwardingStrategy, QueueVisitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
# TODO: refactor all fixtures into cleanly separated modules
|
||||
pytest_plugins = [
|
||||
"test.fixtures.consumer",
|
||||
"test.fixtures.input",
|
||||
"test.fixtures.pdf",
|
||||
"test.fixtures.server",
|
||||
"test.integration_tests.serve_test",
|
||||
]
|
||||
|
||||
|
||||
logging.getLogger("PIL.PngImagePlugin").setLevel(level=logging.CRITICAL + 1)
|
||||
logging.getLogger("waitress").setLevel(level=logging.CRITICAL + 1)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mute_logger():
|
||||
if not CONFIG.logging:
|
||||
logger.setLevel(logging.CRITICAL + 1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@ -58,10 +86,14 @@ def mock_make_load_data():
|
||||
return load_data
|
||||
|
||||
|
||||
@pytest.fixture(params=["minio", "aws"], scope="session")
|
||||
def storage(client_name, bucket_name, request):
|
||||
# def pytest_make_parametrize_id(config, val, argname):
|
||||
# return f"\n\t{argname}={val}\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(client_name, bucket_name, s3_backend, docker_compose):
|
||||
logger.debug("Setup for storage")
|
||||
storage = Storage(get_adapter(client_name, request.param))
|
||||
storage = Storage(get_adapter(client_name, s3_backend))
|
||||
storage.make_bucket(bucket_name)
|
||||
storage.clear_bucket(bucket_name)
|
||||
yield storage
|
||||
@ -69,15 +101,23 @@ def storage(client_name, bucket_name, request):
|
||||
storage.clear_bucket(bucket_name)
|
||||
|
||||
|
||||
@pytest.fixture(params=["minio", "aws"])
|
||||
def s3_backend(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def docker_compose(sleep_seconds=30):
|
||||
logger.info(f"Starting docker containers with {COMPOSE_PATH}/docker-compose.yml...")
|
||||
compose = testcontainers.compose.DockerCompose(COMPOSE_PATH, compose_file_name="docker-compose.yml")
|
||||
compose.start()
|
||||
logger.info(f"Sleeping for {sleep_seconds} seconds to wait for containers to finish startup... ")
|
||||
time.sleep(sleep_seconds)
|
||||
yield compose
|
||||
compose.stop()
|
||||
if CONFIG.use_docker_fixture:
|
||||
logger.info(f"Starting docker containers with {COMPOSE_PATH}/docker-compose.yml...")
|
||||
compose = DockerCompose(COMPOSE_PATH, compose_file_name="docker-compose.yml")
|
||||
compose.start()
|
||||
logger.info(f"Sleeping for {sleep_seconds} seconds to wait for containers to finish startup... ")
|
||||
time.sleep(sleep_seconds)
|
||||
yield compose
|
||||
compose.stop()
|
||||
else:
|
||||
yield None
|
||||
|
||||
|
||||
def get_pika_connection_params():
|
||||
@ -110,7 +150,7 @@ def get_queue_manager(queue_manager_name) -> QueueManager:
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def queue_manager(queue_manager_name):
|
||||
def queue_manager(queue_manager_name, docker_compose):
|
||||
def close_connections():
|
||||
if queue_manager_name == "pika":
|
||||
try:
|
||||
@ -134,7 +174,7 @@ def queue_manager(queue_manager_name):
|
||||
@pytest.fixture(scope="session")
|
||||
def callback():
|
||||
def inner(request):
|
||||
return request["data"].decode() * 2
|
||||
return [request["data"].decode() * 2]
|
||||
|
||||
return inner
|
||||
|
||||
@ -157,4 +197,4 @@ def response_strategy(response_strategy_name, storage):
|
||||
|
||||
@pytest.fixture()
|
||||
def visitor(storage, analysis_callback, response_strategy):
|
||||
return QueueVisitor(storage, analysis_callback, response_strategy)
|
||||
return QueueVisitor(storage=storage, callback=analysis_callback, response_strategy=response_strategy)
|
||||
12
test/exploration_tests/pickup_endpoint_test.py
Normal file
12
test/exploration_tests/pickup_endpoint_test.py
Normal file
@ -0,0 +1,12 @@
|
||||
# import pytest
|
||||
# from funcy import lmap
|
||||
#
|
||||
# from pyinfra.server.rest import process_lazily
|
||||
# from pyinfra.server.utils import unpack
|
||||
#
|
||||
#
|
||||
# @pytest.mark.parametrize("batched", [True, False])
|
||||
# @pytest.mark.parametrize("item_type", ["string", "image", "pdf"])
|
||||
# def test_pickup_endpoint(url, input_data_items, metadata, operation, targets, server_process):
|
||||
# output = lmap(unpack, process_lazily(f"{url}/submit", input_data_items, metadata))
|
||||
# assert output == targets
|
||||
32
test/exploration_tests/repeated_first_chunk.py
Normal file
32
test/exploration_tests/repeated_first_chunk.py
Normal file
@ -0,0 +1,32 @@
|
||||
from functools import partial
|
||||
|
||||
from funcy import chunks, first, compose
|
||||
|
||||
|
||||
def test_repeated_first_chunk_consumption():
|
||||
def f(chunk):
|
||||
return sum(chunk)
|
||||
|
||||
def g():
|
||||
return f(first(chunks(3, items)))
|
||||
|
||||
items = iter(range(10))
|
||||
|
||||
assert g() == 3
|
||||
assert g() == 12
|
||||
assert g() == 21
|
||||
assert g() == 9
|
||||
|
||||
|
||||
def test_repeated_first_chunk_consumption_passing():
|
||||
def f(chunk):
|
||||
return sum(chunk)
|
||||
|
||||
g = compose(f, first, partial(chunks, 3))
|
||||
|
||||
items = iter(range(10))
|
||||
|
||||
assert g(items) == 3
|
||||
assert g(items) == 12
|
||||
assert g(items) == 21
|
||||
assert g(items) == 9
|
||||
0
test/fixtures/__init__.py
vendored
Normal file
0
test/fixtures/__init__.py
vendored
Normal file
22
test/fixtures/consumer.py
vendored
Normal file
22
test/fixtures/consumer.py
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
from _operator import itemgetter
|
||||
|
||||
import pytest
|
||||
|
||||
from pyinfra.queue.consumer import Consumer
|
||||
from test.utils.input import pair_data_with_queue_message
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def consumer(queue_manager, callback):
|
||||
return Consumer(callback, queue_manager)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def access_callback():
|
||||
return itemgetter("fileId")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def items():
|
||||
numbers = [f"{i}".encode() for i in range(3)]
|
||||
return pair_data_with_queue_message(numbers)
|
||||
150
test/fixtures/input.py
vendored
Normal file
150
test/fixtures/input.py
vendored
Normal file
@ -0,0 +1,150 @@
|
||||
from functools import partial
|
||||
from itertools import starmap, repeat
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from funcy import lmap, compose, flatten, lflatten, omit, second, first, lzip
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing
|
||||
from pyinfra.server.normalization import normalize_item
|
||||
from pyinfra.server.packing import pack, unpack
|
||||
from pyinfra.utils.func import star, lift, lstarlift
|
||||
from test.utils.image import image_to_bytes
|
||||
from test.utils.input import pair_data_with_queue_message
|
||||
from test.utils.pdf import pdf_stream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def input_data_items(unencoded_input_data, input_data_encoder):
|
||||
return input_data_encoder(unencoded_input_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unencoded_input_data(item_type, unencoded_strings, unencoded_images, unencoded_pdfs):
|
||||
if item_type == "string":
|
||||
return unencoded_strings
|
||||
elif item_type == "image":
|
||||
return unencoded_images
|
||||
elif item_type == "pdf":
|
||||
return unencoded_pdfs
|
||||
else:
|
||||
raise ValueError(f"Unknown item type {item_type}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def input_data_encoder(item_type):
|
||||
if item_type == "string":
|
||||
return strings_to_bytes
|
||||
elif item_type == "image":
|
||||
return images_to_bytes
|
||||
elif item_type == "pdf":
|
||||
return pdfs_to_bytes
|
||||
else:
|
||||
raise ValueError(f"Unknown item type {item_type}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unencoded_pdfs(n_items, unencoded_pdf):
|
||||
return [unencoded_pdf] * n_items
|
||||
|
||||
|
||||
def pdfs_to_bytes(unencoded_pdfs):
|
||||
return [pdf_stream(pdf) for pdf in unencoded_pdfs]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def target_data_items(input_data_items, core_operation, metadata):
|
||||
|
||||
if core_operation is Nothing:
|
||||
return Nothing
|
||||
|
||||
op = compose(normalize_item, core_operation)
|
||||
expected = lflatten(starmap(op, zip(input_data_items, metadata)))
|
||||
return expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unencoded_strings(n_items):
|
||||
return [f"content{i}" for i in range(n_items)]
|
||||
|
||||
|
||||
def strings_to_bytes(strings):
|
||||
return [bytes(s, encoding="utf8") for s in strings]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def targets(data_message_pairs, input_data_items, operation, metadata, server_side_test):
|
||||
"""TODO: this has become super wonky"""
|
||||
metadata = [{**m1, **m2} for m1, m2 in zip(lmap(second, data_message_pairs), metadata)]
|
||||
|
||||
if operation is Nothing:
|
||||
return Nothing
|
||||
|
||||
op = compose(lift(star(pack)), normalize_item, operation)
|
||||
|
||||
try:
|
||||
response_data, response_metadata = zip(*map(unpack, flatten(starmap(op, zip(input_data_items, metadata)))))
|
||||
|
||||
queue_message_keys = ["id"] * (not server_side_test) + [
|
||||
*second(first(pair_data_with_queue_message([b""]))).keys()
|
||||
]
|
||||
response_metadata = lmap(partial(omit, keys=queue_message_keys), response_metadata)
|
||||
expected = lzip(response_data, response_metadata)
|
||||
|
||||
except ValueError:
|
||||
expected = []
|
||||
|
||||
return expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint(url):
|
||||
return f"{url}/submit"
|
||||
|
||||
|
||||
@pytest.fixture(params=["rest", "basic"])
|
||||
def client_pipeline_type(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[1, 0, 5])
|
||||
def n_items(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[0, 100])
|
||||
def n_pages(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[1, 5])
|
||||
def buffer_size(request):
|
||||
return request.param
|
||||
|
||||
|
||||
def array_to_image(array) -> Image.Image:
|
||||
return Image.fromarray(np.uint8(array * 255), mode="RGB")
|
||||
|
||||
|
||||
def input_batch(n_items):
|
||||
return np.random.random_sample(size=(n_items, 3, 30, 30))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unencoded_images(n_items):
|
||||
return lmap(array_to_image, input_batch(n_items))
|
||||
|
||||
|
||||
def images_to_bytes(images):
|
||||
return lmap(image_to_bytes, images)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata(n_items):
|
||||
return list(repeat({"key": "value"}, times=n_items))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def packages(input_data_items, metadata):
|
||||
return lstarlift(pack)(zip(input_data_items, metadata))
|
||||
11
test/fixtures/pdf.py
vendored
Normal file
11
test/fixtures/pdf.py
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
import fpdf
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unencoded_pdf(n_pages):
|
||||
pdf = fpdf.FPDF(unit="pt")
|
||||
for _ in range(n_pages):
|
||||
pdf.add_page()
|
||||
|
||||
return pdf
|
||||
194
test/fixtures/server.py
vendored
Normal file
194
test/fixtures/server.py
vendored
Normal file
@ -0,0 +1,194 @@
|
||||
import io
|
||||
import logging
|
||||
import socket
|
||||
from collections import Counter
|
||||
from multiprocessing import Process
|
||||
from operator import itemgetter
|
||||
from typing import Generator
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
import requests
|
||||
from PIL import Image
|
||||
from funcy import retry, project, omit
|
||||
from waitress import serve
|
||||
|
||||
from pyinfra.server.dispatcher.dispatcher import Nothing
|
||||
from pyinfra.server.server import (
|
||||
set_up_processing_server,
|
||||
)
|
||||
from pyinfra.server.utils import make_streamable_and_wrap_in_packing_logic
|
||||
from pyinfra.utils.func import starlift
|
||||
from test.utils.image import image_to_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host():
|
||||
return "0.0.0.0"
|
||||
|
||||
|
||||
def get_free_port(host):
|
||||
sock = socket.socket()
|
||||
sock.bind((host, 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def port(host):
|
||||
return get_free_port(host)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def url(host, port):
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(server_stream_function, buffer_size):
|
||||
return set_up_processing_server(server_stream_function, buffer_size)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_stream_function(operation_conditionally_batched, batched):
|
||||
return make_streamable_and_wrap_in_packing_logic(operation_conditionally_batched, batched)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operation_conditionally_batched(operation, batched):
|
||||
return starlift(operation) if batched else operation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operation(core_operation, server_side_test):
|
||||
auto_counter = Counter()
|
||||
|
||||
def auto_count(metadata):
|
||||
if not server_side_test:
|
||||
idnt = itemgetter("dossierId", "fileId")(metadata)
|
||||
auto_counter[idnt] += 1
|
||||
return {**metadata, "id": auto_counter[idnt]} if "id" not in metadata else metadata
|
||||
else:
|
||||
return metadata
|
||||
|
||||
def op(data, metadata):
|
||||
assert isinstance(metadata, dict)
|
||||
result = core_operation(data, metadata)
|
||||
if isinstance(result, Generator):
|
||||
for data, metadata in result:
|
||||
yield data, auto_count(omit(metadata, ["pages", "operation"]))
|
||||
else:
|
||||
data, metadata = result
|
||||
yield data, auto_count(omit(metadata, ["pages", "operation"]))
|
||||
|
||||
if core_operation is Nothing:
|
||||
return Nothing
|
||||
|
||||
return op
|
||||
|
||||
|
||||
@pytest.fixture(params=[False])
|
||||
def server_side_test(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def core_operation(item_type, one_to_many, analysis_task):
|
||||
def duplicate(string: bytes, metadata):
|
||||
for _ in range(2):
|
||||
yield upper(string, metadata), metadata
|
||||
|
||||
def upper(string: bytes, metadata):
|
||||
return string.decode().upper().encode(), metadata
|
||||
|
||||
def extract(string: bytes, metadata):
|
||||
for i, c in project(dict(enumerate(string.decode())), metadata["pages"]).items():
|
||||
metadata["id"] = i
|
||||
yield c.encode(), metadata
|
||||
|
||||
def rotate(im: bytes, metadata):
|
||||
im = Image.open(io.BytesIO(im))
|
||||
return image_to_bytes(im.rotate(90)), metadata
|
||||
|
||||
def classify(_: bytes, metadata):
|
||||
return b"", {"classification": 1, **metadata}
|
||||
|
||||
def stream_pages(pdf: bytes, metadata):
|
||||
for i, page in enumerate(fitz.open(stream=pdf)):
|
||||
# yield page.get_pixmap().tobytes("png"), metadata
|
||||
metadata["id"] = i
|
||||
yield f"page_{i}".encode(), metadata
|
||||
|
||||
params2op = {
|
||||
False: {
|
||||
"string": {False: upper},
|
||||
"image": {False: rotate, True: classify},
|
||||
},
|
||||
True: {
|
||||
"string": {False: extract},
|
||||
"pdf": {False: stream_pages},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
return params2op[one_to_many][item_type][analysis_task]
|
||||
except KeyError:
|
||||
msg = f"No operation defined for [{one_to_many=}, {item_type=}, {analysis_task=}]."
|
||||
pytest.skip(msg)
|
||||
logger.debug(msg)
|
||||
return Nothing
|
||||
|
||||
|
||||
@pytest.fixture(params=["pdf", "string", "image"])
|
||||
def item_type(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[True, False])
|
||||
def one_to_many(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[True, False])
|
||||
def many_to_n(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[True, False])
|
||||
def analysis_task(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture(params=[False, True])
|
||||
def batched(request):
|
||||
"""Controls, whether the buffer processor function of the webserver is applied to batches or single items."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_and_port(host, port):
|
||||
return {"host": host, "port": port}
|
||||
|
||||
|
||||
@retry(tries=5, timeout=1)
|
||||
def server_ready(url):
|
||||
response = requests.get(f"{url}/ready")
|
||||
response.raise_for_status()
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False, scope="function")
|
||||
def server_process(server, host_and_port, url):
|
||||
def get_server_process():
|
||||
return Process(target=serve, kwargs={"app": server, **host_and_port})
|
||||
|
||||
server = get_server_process()
|
||||
server.start()
|
||||
|
||||
if server_ready(url):
|
||||
yield
|
||||
|
||||
server.kill()
|
||||
server.join()
|
||||
server.close()
|
||||
0
test/integration_tests/__init__.py
Normal file
0
test/integration_tests/__init__.py
Normal file
238
test/integration_tests/serve_test.py
Normal file
238
test/integration_tests/serve_test.py
Normal file
@ -0,0 +1,238 @@
|
||||
import json
|
||||
import re
|
||||
from itertools import starmap, repeat, chain
|
||||
from operator import itemgetter
|
||||
|
||||
import pytest
|
||||
from funcy import compose, lpluck, first, second
|
||||
|
||||
from pyinfra.default_objects import (
|
||||
get_callback,
|
||||
get_response_strategy,
|
||||
get_consumer,
|
||||
get_queue_manager,
|
||||
get_storage,
|
||||
)
|
||||
from pyinfra.queue.consumer import Consumer
|
||||
from pyinfra.server.packing import unpack, pack
|
||||
from pyinfra.utils.encoding import compress, decompress
|
||||
from pyinfra.visitor import QueueVisitor
|
||||
from pyinfra.visitor.utils import get_download_strategy
|
||||
from test.utils.input import pair_data_with_queue_message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"batched",
|
||||
[
|
||||
False,
|
||||
True,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"one_to_many",
|
||||
[
|
||||
False,
|
||||
True,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"analysis_task",
|
||||
[
|
||||
# False,
|
||||
True,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"n_items",
|
||||
[
|
||||
1,
|
||||
3,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("n_pages", [2])
|
||||
@pytest.mark.parametrize("buffer_size", [1, 2])
|
||||
@pytest.mark.parametrize(
|
||||
"item_type",
|
||||
[
|
||||
"string",
|
||||
"image",
|
||||
"pdf",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"queue_manager_name",
|
||||
[
|
||||
# "mock",
|
||||
"pika",
|
||||
],
|
||||
scope="session",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"client_name",
|
||||
[
|
||||
"mock",
|
||||
# "s3",
|
||||
# "azure",
|
||||
],
|
||||
scope="session",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"components_type",
|
||||
[
|
||||
# "test",
|
||||
"real",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"many_to_n",
|
||||
[
|
||||
False,
|
||||
True,
|
||||
],
|
||||
)
|
||||
def test_serving(
|
||||
server_process, bucket_name, components, targets, data_message_pairs, n_items, many_to_n, download_strategy
|
||||
):
|
||||
|
||||
storage, queue_manager, consumer = components
|
||||
|
||||
assert queue_manager.input_queue.to_list() == []
|
||||
assert queue_manager.output_queue.to_list() == []
|
||||
assert [*storage.get_all_object_names(bucket_name)] == []
|
||||
|
||||
if n_items:
|
||||
assert data_message_pairs
|
||||
|
||||
if many_to_n:
|
||||
upload_data_to_folder_in_storage_and_publish_single_request_to_queue(
|
||||
storage, queue_manager, data_message_pairs, download_strategy
|
||||
)
|
||||
else:
|
||||
upload_data_to_storage_and_publish_requests_to_queue(
|
||||
storage, queue_manager, data_message_pairs, download_strategy
|
||||
)
|
||||
|
||||
consumer.consume_and_publish(n=int(many_to_n) or n_items)
|
||||
|
||||
outputs = get_data_uploaded_by_consumer(queue_manager, storage, bucket_name)
|
||||
|
||||
# TODO: correctness of target should be validated as well, since production was become non-trivial
|
||||
assert sorted(outputs) == sorted(targets)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_metadata_packs(input_data_items, metadata):
|
||||
return list(starmap(compose(lambda s: s.encode(), json.dumps, pack), zip(input_data_items, metadata)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_message_pairs(data_metadata_packs):
|
||||
data_message_pairs = pair_data_with_queue_message(data_metadata_packs)
|
||||
return data_message_pairs
|
||||
|
||||
|
||||
# TODO: refactor; too many params
|
||||
def upload_data_to_storage_and_publish_requests_to_queue(storage, queue_manager, data_message_pairs, download_strategy):
|
||||
for data, message in data_message_pairs:
|
||||
upload_data_to_storage_and_publish_request_to_queue(storage, queue_manager, data, message, download_strategy)
|
||||
|
||||
|
||||
# TODO: refactor; too many params
|
||||
def upload_data_to_storage_and_publish_request_to_queue(storage, queue_manager, data, message, download_strategy):
|
||||
storage.put_object(**download_strategy.get_object_descriptor(message), data=compress(data))
|
||||
queue_manager.publish_request(message)
|
||||
|
||||
|
||||
# TODO: refactor body; too long and scripty
|
||||
def upload_data_to_folder_in_storage_and_publish_single_request_to_queue(
|
||||
storage, queue_manager, data_message_pairs, download_strategy
|
||||
):
|
||||
assert data_message_pairs
|
||||
|
||||
ref_message = second(first(data_message_pairs))
|
||||
pages = ref_message["pages"]
|
||||
|
||||
for data, page in zip(map(first, data_message_pairs), pages):
|
||||
object_descriptor = download_strategy.get_object_descriptor(ref_message)
|
||||
object_descriptor["object_name"] = build_filepath(object_descriptor, page)
|
||||
|
||||
storage.put_object(**object_descriptor, data=compress(data))
|
||||
|
||||
queue_manager.publish_request(ref_message)
|
||||
|
||||
|
||||
def build_filepath(object_descriptor, page):
|
||||
object_name = object_descriptor["object_name"]
|
||||
parts = object_name.split("/")
|
||||
parts.insert(-1, "pages")
|
||||
path = "/".join(parts)
|
||||
path = re.sub("id:\d", f"id:{page}", path)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def get_data_uploaded_by_consumer(queue_manager, storage, bucket_name):
|
||||
names_of_uploaded_files = lpluck("responseFile", queue_manager.output_queue.to_list())
|
||||
uploaded_files = starmap(storage.get_object, zip(repeat(bucket_name), names_of_uploaded_files))
|
||||
outputs = sorted(chain(*map(decode, uploaded_files)), key=itemgetter(0))
|
||||
return outputs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def components(components_type, real_components, test_components, bucket_name):
|
||||
if components_type == "real":
|
||||
components = real_components
|
||||
elif components_type == "test":
|
||||
components = test_components
|
||||
else:
|
||||
raise ValueError(f"Unknown component type '{components_type}'.")
|
||||
|
||||
storage, queue_manager, consumer = components
|
||||
|
||||
queue_manager.clear()
|
||||
storage.make_bucket(bucket_name)
|
||||
storage.clear_bucket(bucket_name)
|
||||
|
||||
yield storage, queue_manager, consumer
|
||||
|
||||
queue_manager.clear()
|
||||
storage.clear_bucket(bucket_name)
|
||||
|
||||
|
||||
def decode(storage_item):
|
||||
storage_item = json.loads(decompress(storage_item).decode())
|
||||
if not isinstance(storage_item, list):
|
||||
storage_item = [storage_item]
|
||||
|
||||
yield from map(unpack, storage_item)
|
||||
|
||||
|
||||
@pytest.fixture(params=["real", "mixed"])
|
||||
def components_type(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_components(url, download_strategy):
|
||||
callback = get_callback(url)
|
||||
consumer = get_consumer(callback)
|
||||
queue_manager = get_queue_manager()
|
||||
storage = get_storage()
|
||||
|
||||
consumer.visitor.download_strategy = download_strategy
|
||||
return storage, queue_manager, consumer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_strategy(many_to_n):
|
||||
return get_download_strategy("multi" if many_to_n else "single")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_components(url, queue_manager, storage):
|
||||
|
||||
callback = get_callback(url)
|
||||
visitor = QueueVisitor(storage=storage, callback=callback, response_strategy=get_response_strategy(storage))
|
||||
consumer = Consumer(visitor, queue_manager)
|
||||
|
||||
return storage, queue_manager, consumer
|
||||
0
test/parser/__init__.py
Normal file
0
test/parser/__init__.py
Normal file
43
test/parser/parser_test.py
Normal file
43
test/parser/parser_test.py
Normal file
@ -0,0 +1,43 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pyinfra.parser.blob_parser import ParsingError
|
||||
from pyinfra.parser.parser_composer import EitherParserComposer
|
||||
from pyinfra.parser.parsers.identity import IdentityBlobParser
|
||||
from pyinfra.parser.parsers.json import JsonBlobParser
|
||||
from pyinfra.parser.parsers.string import StringBlobParser
|
||||
from pyinfra.server.packing import bytes_to_string
|
||||
|
||||
|
||||
def test_json_parser():
|
||||
d = {"data": bytes_to_string(b"aa")}
|
||||
assert JsonBlobParser()(json.dumps(d).encode()) == {"data": b"aa"}
|
||||
|
||||
|
||||
def test_string_parser():
|
||||
a = "a"
|
||||
assert StringBlobParser()(a.encode()) == a
|
||||
|
||||
|
||||
def test_identity_parser():
|
||||
a = "a"
|
||||
assert IdentityBlobParser()(a.encode()) == a.encode()
|
||||
|
||||
|
||||
def test_either_parser_composer():
|
||||
parser = EitherParserComposer(JsonBlobParser(), StringBlobParser(), IdentityBlobParser())
|
||||
|
||||
d = {"data": bytes_to_string(b"aa")}
|
||||
assert parser(json.dumps(d).encode()) == {"data": b"aa"}
|
||||
|
||||
a = "a"
|
||||
assert parser(a.encode()) == a
|
||||
|
||||
a = 1
|
||||
assert parser(a) == a
|
||||
|
||||
parser = EitherParserComposer(JsonBlobParser(), StringBlobParser())
|
||||
|
||||
with pytest.raises(ParsingError):
|
||||
parser(1)
|
||||
@ -1,3 +1,5 @@
|
||||
from itertools import islice
|
||||
|
||||
from pyinfra.queue.queue_manager.queue_manager import QueueManager, QueueHandle
|
||||
from test.queue.queue_mock import QueueMock
|
||||
|
||||
@ -17,7 +19,14 @@ class QueueManagerMock(QueueManager):
|
||||
self._input_queue.append(request)
|
||||
|
||||
def publish_response(self, message, callback):
|
||||
self._output_queue.append(callback(message))
|
||||
|
||||
response_messages = callback(message)
|
||||
|
||||
if isinstance(response_messages, dict):
|
||||
response_messages = [response_messages]
|
||||
|
||||
for response_message in response_messages:
|
||||
self._output_queue.append(response_message)
|
||||
|
||||
def pull_request(self):
|
||||
return self._input_queue.popleft()
|
||||
@ -26,8 +35,8 @@ class QueueManagerMock(QueueManager):
|
||||
while self._input_queue:
|
||||
yield self.pull_request()
|
||||
|
||||
def consume_and_publish(self, callback):
|
||||
for message in self.consume():
|
||||
def consume_and_publish(self, callback, n=None):
|
||||
for message in islice(self.consume(), n):
|
||||
self.publish_response(message, callback)
|
||||
|
||||
def basic_consume_and_publish(self, callback):
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
from itertools import repeat
|
||||
|
||||
|
||||
class StorageClientMock:
|
||||
def __init__(self):
|
||||
self.__data = {}
|
||||
@ -24,4 +21,4 @@ class StorageClientMock:
|
||||
self.__data[bucket_name] = {}
|
||||
|
||||
def get_all_object_names(self, bucket_name):
|
||||
return zip(repeat(bucket_name), self.__data[bucket_name])
|
||||
return self.__data[bucket_name]
|
||||
|
||||
@ -1,43 +1,21 @@
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
|
||||
import pytest
|
||||
from funcy import lmapcat
|
||||
|
||||
from pyinfra.exceptions import ProcessingFailure
|
||||
from pyinfra.queue.consumer import Consumer
|
||||
from pyinfra.visitor import get_object_descriptor, ForwardingStrategy
|
||||
from pyinfra.utils.encoding import pack_for_upload
|
||||
from pyinfra.visitor.strategies.download.single import SingleDownloadStrategy
|
||||
from pyinfra.visitor.strategies.response.forwarding import ForwardingStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def consumer(queue_manager, callback):
|
||||
return Consumer(callback, queue_manager)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def access_callback():
|
||||
return itemgetter("fileId")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def items():
|
||||
def inner():
|
||||
for i in range(3):
|
||||
body = {
|
||||
"dossierId": "folder",
|
||||
"fileId": f"file{i}",
|
||||
"targetFileExtension": "in.gz",
|
||||
"responseFileExtension": "out.gz",
|
||||
}
|
||||
yield f"{i}".encode(), body
|
||||
|
||||
return list(inner())
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="NOTE: Something is messed up in the test setups."
|
||||
" These tests fail when run together with other tests. Do not know yet which ones and why."
|
||||
)
|
||||
class TestConsumer:
|
||||
@pytest.mark.parametrize("queue_manager_name", ["mock", "pika"], scope="session")
|
||||
def test_consuming_empty_input_queue_does_not_put_anything_on_output_queue(self, consumer, queue_manager):
|
||||
@ -45,12 +23,22 @@ class TestConsumer:
|
||||
consumer.consume()
|
||||
assert queue_manager.output_queue.empty()
|
||||
|
||||
@pytest.mark.parametrize("queue_manager_name", ["mock", "pika"], scope="session")
|
||||
@pytest.mark.parametrize(
|
||||
"queue_manager_name",
|
||||
[
|
||||
"pika", # NOTE: pika must come first. Test fails IFF pika is in second place, for whatever reason.
|
||||
"mock",
|
||||
],
|
||||
scope="session",
|
||||
)
|
||||
def test_consuming_nonempty_input_queue_puts_messages_on_output_queue_in_fifo_order(
|
||||
self, consumer, queue_manager, callback
|
||||
self, consumer, queue_manager, callback, queue_manager_name
|
||||
):
|
||||
|
||||
assert consumer.queue_manager is queue_manager
|
||||
|
||||
def produce_items():
|
||||
return map(str, range(3))
|
||||
return map(str, range(4))
|
||||
|
||||
def mock_visitor(callback):
|
||||
def inner(data):
|
||||
@ -65,18 +53,18 @@ class TestConsumer:
|
||||
for item in produce_items():
|
||||
queue_manager.publish_request(item)
|
||||
|
||||
requests = consumer.consume()
|
||||
requests = consumer.consume(n=4)
|
||||
|
||||
for _, r in zip(produce_items(), requests):
|
||||
for r in requests:
|
||||
queue_manager.publish_response(r, callback)
|
||||
|
||||
assert queue_manager.output_queue.to_list() == ["00", "11", "22"]
|
||||
assert queue_manager.output_queue.to_list()[:3] == ["00", "11", "22"]
|
||||
|
||||
@pytest.mark.parametrize("queue_manager_name", ["mock", "pika"], scope="session")
|
||||
@pytest.mark.parametrize("client_name", ["mock", "s3", "azure"], scope="session")
|
||||
@pytest.mark.parametrize("response_strategy_name", ["forwarding", "storage"], scope="session")
|
||||
def test_consuming_nonempty_input_queue_with_visitor_puts_messages_on_output_queue_in_fifo_order(
|
||||
self, consumer, queue_manager, visitor, bucket_name, storage, items
|
||||
self, consumer, queue_manager, visitor, bucket_name, storage, items, input_data_encoder
|
||||
):
|
||||
|
||||
visitor.response_strategy = ForwardingStrategy()
|
||||
@ -85,7 +73,7 @@ class TestConsumer:
|
||||
storage.clear_bucket(bucket_name)
|
||||
|
||||
for data, message in items:
|
||||
storage.put_object(**get_object_descriptor(message), data=gzip.compress(data))
|
||||
storage.put_object(**SingleDownloadStrategy().get_object_descriptor(message), data=pack_for_upload(data))
|
||||
queue_manager.publish_request(message)
|
||||
|
||||
requests = consumer.consume(inactivity_timeout=5)
|
||||
@ -94,7 +82,7 @@ class TestConsumer:
|
||||
logger.debug(f"Processing item {itm}")
|
||||
queue_manager.publish_response(req, visitor)
|
||||
|
||||
assert list(map(itemgetter("data"), queue_manager.output_queue.to_list())) == ["00", "11", "22"]
|
||||
assert lmapcat(itemgetter("data"), queue_manager.output_queue.to_list()) == ["00", "11", "22"]
|
||||
|
||||
@pytest.mark.parametrize("queue_manager_name", ["pika"], scope="session")
|
||||
def test_message_is_republished_when_callback_raises_processing_failure_exception(
|
||||
@ -104,10 +92,10 @@ class TestConsumer:
|
||||
pass
|
||||
|
||||
def callback(_):
|
||||
raise ProcessingFailure()
|
||||
raise ProcessingFailure
|
||||
|
||||
def reject_patch(*args, **kwargs):
|
||||
raise DebugError()
|
||||
raise DebugError
|
||||
|
||||
queue_manager.reject = reject_patch
|
||||
|
||||
@ -121,6 +109,10 @@ class TestConsumer:
|
||||
logger = logging.getLogger("pyinfra.queue.queue_manager.pika_queue_manager")
|
||||
logger.addFilter(lambda record: False)
|
||||
|
||||
# TODO:
|
||||
# Note above says order of mock and pika matters. Note, that you can produce an error for debugging, when
|
||||
# using `queue_manager.publish_response(next(requests), callback)` without the-with and while-block, where it
|
||||
# becomes obvious, that the test with the note above then uses the data from THIS here test.
|
||||
with pytest.raises(DebugError):
|
||||
while True:
|
||||
queue_manager.publish_response(next(requests), callback)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import gzip
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pyinfra.visitor import get_object_descriptor, get_response_object_descriptor
|
||||
from pyinfra.utils.encoding import pack_for_upload, decompress
|
||||
from pyinfra.visitor.strategies.download.single import SingleDownloadStrategy
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -18,21 +18,23 @@ class TestVisitor:
|
||||
self, visitor, body, storage, bucket_name
|
||||
):
|
||||
storage.clear_bucket(bucket_name)
|
||||
storage.put_object(**get_object_descriptor(body), data=gzip.compress(b"content"))
|
||||
data_received = visitor.load_data(body)
|
||||
assert b"content" == data_received
|
||||
storage.put_object(**SingleDownloadStrategy().get_object_descriptor(body), data=pack_for_upload(b"content"))
|
||||
data_received = list(visitor.load_data(body))
|
||||
assert [{"data": b"content", "metadata": {}}] == data_received
|
||||
|
||||
@pytest.mark.parametrize("response_strategy_name", ["forwarding", "storage"], scope="session")
|
||||
def test_visitor_pulls_and_processes_data(self, visitor, body, storage, bucket_name):
|
||||
storage.clear_bucket(bucket_name)
|
||||
storage.put_object(**get_object_descriptor(body), data=gzip.compress("2".encode()))
|
||||
response_body = visitor.load_and_process(body)
|
||||
assert response_body["data"] == "22"
|
||||
storage.put_object(**SingleDownloadStrategy().get_object_descriptor(body), data=pack_for_upload(b"2"))
|
||||
response_body = visitor.load_items_from_storage_and_process_with_callback(body)
|
||||
assert response_body["data"] == ["22"]
|
||||
|
||||
@pytest.mark.parametrize("response_strategy_name", ["storage"], scope="session")
|
||||
def test_visitor_puts_response_on_storage(self, visitor, body, storage, bucket_name):
|
||||
storage.clear_bucket(bucket_name)
|
||||
storage.put_object(**get_object_descriptor(body), data=gzip.compress("2".encode()))
|
||||
storage.put_object(**SingleDownloadStrategy().get_object_descriptor(body), data=pack_for_upload(b"2"))
|
||||
response_body = visitor(body)
|
||||
assert "data" not in response_body
|
||||
assert json.loads(gzip.decompress(storage.get_object(**get_response_object_descriptor(body))))["data"] == "22"
|
||||
assert json.loads(
|
||||
decompress(storage.get_object(bucket_name=bucket_name, object_name=response_body["responseFile"]))
|
||||
)["data"] == ["22"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user