plastron-stomp 4.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. plastron_stomp-4.3.2/PKG-INFO +125 -0
  2. plastron_stomp-4.3.2/README.md +104 -0
  3. plastron_stomp-4.3.2/VERSION +1 -0
  4. plastron_stomp-4.3.2/pyproject.toml +46 -0
  5. plastron_stomp-4.3.2/setup.cfg +4 -0
  6. plastron_stomp-4.3.2/src/plastron/stomp/__init__.py +4 -0
  7. plastron_stomp-4.3.2/src/plastron/stomp/commands/__init__.py +42 -0
  8. plastron_stomp-4.3.2/src/plastron/stomp/commands/echo.py +26 -0
  9. plastron_stomp-4.3.2/src/plastron/stomp/commands/export.py +28 -0
  10. plastron_stomp-4.3.2/src/plastron/stomp/commands/importcommand.py +84 -0
  11. plastron_stomp-4.3.2/src/plastron/stomp/commands/publish.py +20 -0
  12. plastron_stomp-4.3.2/src/plastron/stomp/commands/unpublish.py +20 -0
  13. plastron_stomp-4.3.2/src/plastron/stomp/commands/update.py +45 -0
  14. plastron_stomp-4.3.2/src/plastron/stomp/daemon.py +116 -0
  15. plastron_stomp-4.3.2/src/plastron/stomp/handlers.py +55 -0
  16. plastron_stomp-4.3.2/src/plastron/stomp/inbox_watcher.py +49 -0
  17. plastron_stomp-4.3.2/src/plastron/stomp/listeners.py +143 -0
  18. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/PKG-INFO +125 -0
  19. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/SOURCES.txt +24 -0
  20. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/dependency_links.txt +1 -0
  21. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/entry_points.txt +2 -0
  22. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/requires.txt +14 -0
  23. plastron_stomp-4.3.2/src/plastron_stomp.egg-info/top_level.txt +1 -0
  24. plastron_stomp-4.3.2/tests/test_connection.py +76 -0
  25. plastron_stomp-4.3.2/tests/test_handlers.py +80 -0
  26. plastron_stomp-4.3.2/tests/test_inbox_watcher.py +52 -0
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.1
2
+ Name: plastron-stomp
3
+ Version: 4.3.2
4
+ Summary: Plastron STOMP daemon
5
+ Author-email: University of Maryland Libraries <lib-ssdr@umd.edu>, Josh Westgard <westgard@umd.edu>, Peter Eichman <peichman@umd.edu>, Mohamed Abdul Rasheed <mohideen@umd.edu>, Ben Wallberg <wallberg@umd.edu>, David Steelman <dsteelma@umd.edu>, Marc Andreu Grillo Aguilar <aguilarm@umd.edu>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click
9
+ Requires-Dist: BeautifulSoup4
10
+ Requires-Dist: plastron-models
11
+ Requires-Dist: plastron-repo
12
+ Requires-Dist: plastron-utils
13
+ Requires-Dist: pyparsing
14
+ Requires-Dist: PyYAML
15
+ Requires-Dist: stomp.py
16
+ Requires-Dist: watchdog==0.10.3
17
+ Provides-Extra: test
18
+ Requires-Dist: CoilMQ; extra == "test"
19
+ Requires-Dist: pytest; extra == "test"
20
+ Requires-Dist: pytest-cov; extra == "test"
21
+
22
+ # plastron-stomp
23
+
24
+ STOMP listener client for asynchronous and synchronous operations
25
+
26
+ ## Running with Python
27
+
28
+ As a Python module:
29
+
30
+ ```bash
31
+ python -m plastron.stomp.daemon -c <config file>
32
+ ```
33
+
34
+ Using the console script entrypoint:
35
+
36
+ ```bash
37
+ plastrond-stomp -c <config file>
38
+ ```
39
+
40
+ ## Integration Tests
41
+
42
+ See the [integration test README](integration-tests/README.md) for
43
+ instructions on running the manual integration tests.
44
+
45
+ ## Docker Image
46
+
47
+ The plastron-stomp package contains a [Dockerfile](Dockerfile) for
48
+ building the `plastrond-stomp` Docker image.
49
+
50
+ ### Building
51
+
52
+ **Important:** This image **MUST** be built from the main _plastron_
53
+ project directory, in order to include the other plastron packages in the
54
+ build context.
55
+
56
+ ```bash
57
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
58
+ -f plastron-stomp/Dockerfile .
59
+ ```
60
+
61
+ ### Running with Docker Swarm
62
+
63
+ This repository contains a [compose.yml](compose.yml) file that defines
64
+ part of a `plastrond` Docker stack intended to be run alongside the
65
+ [umd-fcrepo-docker] stack. This repository's configuration adds a
66
+ `plastrond-stomp` container.
67
+
68
+ ```bash
69
+ # if you are not already running in swarm mode
70
+ docker swarm init
71
+
72
+ # build the image
73
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
74
+ -f plastron-stomp/Dockerfile .
75
+
76
+ # Create an "archelon_id" private/public key pair
77
+ # The Archelon instance should be configured with "archelon_id.pub" as the
78
+ # PLASTRON_PUBLIC_KEY
79
+ ssh-keygen -q -t rsa -N '' -f archelon_id
80
+
81
+ # Copy the docker-plastron-template.yml and edit the configuration
82
+ cp docker-plastron.template.yml docker-plastron.yml
83
+ vim docker-plastron.yml
84
+
85
+ # deploy the stack to run the STOMP application
86
+ docker stack deploy -c plastron-stomp/compose.yml plastrond
87
+ ```
88
+
89
+ To watch the logs:
90
+
91
+ ```bash
92
+ docker service logs -f plastrond_stomp
93
+ ```
94
+
95
+ To stop the STOMP service:
96
+
97
+ ```bash
98
+ docker service rm plastrond_stomp
99
+ ```
100
+
101
+ ## Configuration
102
+
103
+ See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
104
+ example of the config file.
105
+
106
+ ## STOMP Message Headers
107
+
108
+ The Plastron Daemon expects the following headers to be present in messages
109
+ received on the `JOBS` destination:
110
+
111
+ * `PlastronCommand`
112
+ * `PlastronJobId`
113
+
114
+ Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
115
+ Many of these are specific to the command, but there is one with standard behavior across
116
+ all commands:
117
+
118
+ | Header | Description |
119
+ |----------------------------|-----------------------------------------------|
120
+ | `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
121
+
122
+ See the [messages documentation](docs/messages.md) for details on the headers
123
+ and bodies of the messages the Plastron STOMP Daemon emits.
124
+
125
+ [umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
@@ -0,0 +1,104 @@
1
+ # plastron-stomp
2
+
3
+ STOMP listener client for asynchronous and synchronous operations
4
+
5
+ ## Running with Python
6
+
7
+ As a Python module:
8
+
9
+ ```bash
10
+ python -m plastron.stomp.daemon -c <config file>
11
+ ```
12
+
13
+ Using the console script entrypoint:
14
+
15
+ ```bash
16
+ plastrond-stomp -c <config file>
17
+ ```
18
+
19
+ ## Integration Tests
20
+
21
+ See the [integration test README](integration-tests/README.md) for
22
+ instructions on running the manual integration tests.
23
+
24
+ ## Docker Image
25
+
26
+ The plastron-stomp package contains a [Dockerfile](Dockerfile) for
27
+ building the `plastrond-stomp` Docker image.
28
+
29
+ ### Building
30
+
31
+ **Important:** This image **MUST** be built from the main _plastron_
32
+ project directory, in order to include the other plastron packages in the
33
+ build context.
34
+
35
+ ```bash
36
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
37
+ -f plastron-stomp/Dockerfile .
38
+ ```
39
+
40
+ ### Running with Docker Swarm
41
+
42
+ This repository contains a [compose.yml](compose.yml) file that defines
43
+ part of a `plastrond` Docker stack intended to be run alongside the
44
+ [umd-fcrepo-docker] stack. This repository's configuration adds a
45
+ `plastrond-stomp` container.
46
+
47
+ ```bash
48
+ # if you are not already running in swarm mode
49
+ docker swarm init
50
+
51
+ # build the image
52
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
53
+ -f plastron-stomp/Dockerfile .
54
+
55
+ # Create an "archelon_id" private/public key pair
56
+ # The Archelon instance should be configured with "archelon_id.pub" as the
57
+ # PLASTRON_PUBLIC_KEY
58
+ ssh-keygen -q -t rsa -N '' -f archelon_id
59
+
60
+ # Copy the docker-plastron-template.yml and edit the configuration
61
+ cp docker-plastron.template.yml docker-plastron.yml
62
+ vim docker-plastron.yml
63
+
64
+ # deploy the stack to run the STOMP application
65
+ docker stack deploy -c plastron-stomp/compose.yml plastrond
66
+ ```
67
+
68
+ To watch the logs:
69
+
70
+ ```bash
71
+ docker service logs -f plastrond_stomp
72
+ ```
73
+
74
+ To stop the STOMP service:
75
+
76
+ ```bash
77
+ docker service rm plastrond_stomp
78
+ ```
79
+
80
+ ## Configuration
81
+
82
+ See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
83
+ example of the config file.
84
+
85
+ ## STOMP Message Headers
86
+
87
+ The Plastron Daemon expects the following headers to be present in messages
88
+ received on the `JOBS` destination:
89
+
90
+ * `PlastronCommand`
91
+ * `PlastronJobId`
92
+
93
+ Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
94
+ Many of these are specific to the command, but there is one with standard behavior across
95
+ all commands:
96
+
97
+ | Header | Description |
98
+ |----------------------------|-----------------------------------------------|
99
+ | `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
100
+
101
+ See the [messages documentation](docs/messages.md) for details on the headers
102
+ and bodies of the messages the Plastron STOMP Daemon emits.
103
+
104
+ [umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
@@ -0,0 +1 @@
1
+ 4.3.2
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "plastron-stomp"
3
+ description = "Plastron STOMP daemon"
4
+ authors = [
5
+ { name='University of Maryland Libraries', email='lib-ssdr@umd.edu' },
6
+ { name='Josh Westgard', email='westgard@umd.edu' },
7
+ { name='Peter Eichman', email='peichman@umd.edu' },
8
+ { name='Mohamed Abdul Rasheed', email='mohideen@umd.edu' },
9
+ { name='Ben Wallberg', email='wallberg@umd.edu' },
10
+ { name='David Steelman', email='dsteelma@umd.edu' },
11
+ { name='Marc Andreu Grillo Aguilar', email='aguilarm@umd.edu' },
12
+ ]
13
+ readme = "README.md"
14
+ requires-python = ">= 3.8"
15
+ dependencies = [
16
+ "click",
17
+ "BeautifulSoup4",
18
+ "plastron-models",
19
+ "plastron-repo",
20
+ "plastron-utils",
21
+ "pyparsing",
22
+ "PyYAML",
23
+ "stomp.py",
24
+ 'watchdog==0.10.3',
25
+ ]
26
+ dynamic = ["version"]
27
+
28
+ [project.optional-dependencies]
29
+ test = [
30
+ 'CoilMQ',
31
+ "pytest",
32
+ "pytest-cov",
33
+ ]
34
+
35
+ [project.scripts]
36
+ plastrond-stomp = 'plastron.stomp.daemon:main'
37
+
38
+ [build-system]
39
+ requires = ["setuptools>=66.1.0"]
40
+ build-backend = "setuptools.build_meta"
41
+
42
+ [tool.setuptools.dynamic]
43
+ version = { "file" = "VERSION" }
44
+
45
+ [tool.pytest.ini_options]
46
+ markers = ['jobs_dir']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """.. include:: ../../../README.md"""
2
+ import importlib.metadata
3
+
4
+ __version__ = importlib.metadata.version('plastron-stomp')
@@ -0,0 +1,42 @@
1
+ from importlib import import_module
2
+ from types import ModuleType
3
+ from typing import Optional, Dict, TypeVar, Type
4
+
5
+ from plastron.repo import Repository
6
+
7
+
8
+ class BaseCommand:
9
+ def __init__(self, config=None):
10
+ if config is None:
11
+ config = {}
12
+ self.config = config
13
+ self.repo: Optional[Repository] = None
14
+ self.result: Optional[Dict] = None
15
+
16
+
17
+ T = TypeVar('T', bound=BaseCommand)
18
+
19
+
20
+ def get_module_name(command_name: str) -> str:
21
+ if command_name == 'import':
22
+ # special case for the import command, to avoid conflict
23
+ # with the "import" keyword
24
+ return command_name + 'command'
25
+ else:
26
+ return command_name
27
+
28
+
29
+ def get_command_module(command_name: str) -> ModuleType:
30
+ try:
31
+ return import_module(".".join((__package__, get_module_name(command_name))))
32
+ except ModuleNotFoundError as e:
33
+ raise RuntimeError(f'Unable to load a command with the name "{command_name}"') from e
34
+
35
+
36
+ def get_command_class(command_name: str) -> Type[BaseCommand]:
37
+ command_module = get_command_module(command_name)
38
+ command_class = getattr(command_module, 'Command')
39
+ if command_class is None:
40
+ raise RuntimeError(f'Command class not found in module "{command_module}"')
41
+
42
+ return command_class
@@ -0,0 +1,26 @@
1
+ import logging
2
+ import time
3
+ from typing import Generator, Any, Dict
4
+
5
+ from plastron.messaging.messages import PlastronCommandMessage
6
+ from plastron.repo import Repository
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def echo(
12
+ _repo: Repository,
13
+ _config: Dict[str, Any],
14
+ message: PlastronCommandMessage,
15
+ ) -> Generator[Any, None, Dict[str, Any]]:
16
+ message_body = message.body.encode('utf-8').decode('utf-8-sig')
17
+ echo_delay = int(message.args.get('echo-delay', "0"))
18
+ if echo_delay:
19
+ time.sleep(echo_delay)
20
+
21
+ yield {'echo': message_body}
22
+
23
+ return {
24
+ 'type': 'Done',
25
+ 'echo': message_body,
26
+ }
@@ -0,0 +1,28 @@
1
+ import logging
2
+ from typing import Generator, Any, Dict
3
+
4
+ from plastron.context import PlastronContext
5
+ from plastron.jobs.exportjob import ExportJob
6
+ from plastron.messaging.messages import PlastronCommandMessage
7
+ from plastron.utils import strtobool
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def export(
13
+ context: PlastronContext,
14
+ message: PlastronCommandMessage,
15
+ ) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
16
+ ssh_key = context.config.get('COMMANDS', {}).get('EXPORT', {}).get('SSH_PRIVATE_KEY', None)
17
+ export_job = ExportJob(
18
+ context=context,
19
+ export_binaries=bool(strtobool(message.args.get('export-binaries', 'false'))),
20
+ binary_types=message.args.get('binary-types'),
21
+ uris=message.body.strip().split('\n'),
22
+ export_format=message.args.get('format', 'text/turtle'),
23
+ output_dest=message.args.get('output-dest'),
24
+ uri_template=message.args.get('uri-template'),
25
+ key=ssh_key,
26
+ )
27
+ logger.info(f'Received message to initiate export job {message.job_id} containing {len(export_job.uris)} items')
28
+ return export_job.run()
@@ -0,0 +1,84 @@
1
+ import io
2
+ import logging
3
+ from argparse import ArgumentTypeError
4
+ from typing import Any, Dict, Generator, Optional
5
+
6
+ from rdflib import URIRef
7
+
8
+ from plastron.context import PlastronContext
9
+ from plastron.jobs import Jobs
10
+ from plastron.jobs.importjob import ImportConfig, ImportJob
11
+ from plastron.messaging.messages import PlastronCommandMessage
12
+ from plastron.utils import datetimestamp, strtobool, uri_or_curie
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_access_uri(access) -> Optional[URIRef]:
18
+ if access is None:
19
+ return None
20
+ try:
21
+ return uri_or_curie(access)
22
+ except ArgumentTypeError as e:
23
+ raise RuntimeError(f'PlastronArg-access {e}')
24
+
25
+
26
+ def importcommand(
27
+ context: PlastronContext,
28
+ message: PlastronCommandMessage,
29
+ ) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
30
+ """
31
+ Performs the import
32
+
33
+ :param context
34
+ :param message:
35
+ """
36
+ job_id = message.job_id
37
+
38
+ # per-request options that are NOT saved to the config
39
+ limit = message.args.get('limit', None)
40
+ if limit is not None:
41
+ limit = int(limit)
42
+ message.body = message.body.encode('utf-8').decode('utf-8-sig')
43
+ percentage = message.args.get('percent', None)
44
+ validate_only = bool(strtobool(message.args.get('validate-only', 'false')))
45
+ publish = bool(strtobool(message.args.get('publish', 'false')))
46
+ resume = bool(strtobool(message.args.get('resume', 'false')))
47
+ import_file = io.StringIO(message.body)
48
+
49
+ # options that are saved to the config
50
+ job_config_args = {
51
+ 'job_id': job_id,
52
+ 'model': message.args.get('model'),
53
+ 'access': get_access_uri(message.args.get('access')),
54
+ 'member_of': message.args.get('member-of'),
55
+ 'container': message.args.get('relpath'),
56
+ 'binaries_location': message.args.get('binaries-location'),
57
+ }
58
+
59
+ if resume and job_id is None:
60
+ raise RuntimeError('Resuming a job requires a job id')
61
+
62
+ if job_id is None:
63
+ # TODO: generate a more unique id? add in user and hostname?
64
+ job_id = f"import-{datetimestamp()}"
65
+
66
+ config = context.config.get('COMMANDS', {}).get('IMPORT', {})
67
+ jobs = Jobs(directory=config.get('JOBS_DIR', 'jobs'))
68
+ if resume:
69
+ job = jobs.get_job(ImportJob, job_id=job_id)
70
+ # update the config with any changes in this request
71
+ job.update_config(job_config_args)
72
+ else:
73
+ job = jobs.create_job(ImportJob, config=ImportConfig(**job_config_args))
74
+
75
+ job.ssh_private_key = config.get('SSH_PRIVATE_KEY', None)
76
+
77
+ return job.run(
78
+ context=context,
79
+ import_file=import_file,
80
+ limit=limit,
81
+ percentage=percentage,
82
+ validate_only=validate_only,
83
+ publish=publish,
84
+ )
@@ -0,0 +1,20 @@
1
+ from typing import Dict, Any, Generator
2
+
3
+ from plastron.context import PlastronContext
4
+ from plastron.jobs.publicationjob import PublicationJob, PublicationAction
5
+ from plastron.messaging.messages import PlastronCommandMessage
6
+ from plastron.utils import strtobool
7
+
8
+
9
+ def publish(
10
+ context: PlastronContext,
11
+ message: PlastronCommandMessage,
12
+ ) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
13
+ job = PublicationJob(
14
+ context=context,
15
+ uris=message.body.strip().split('\n'),
16
+ action=PublicationAction.PUBLISH,
17
+ force_hidden=bool(strtobool(message.args.get('hidden', 'false'))),
18
+ force_visible=bool(strtobool(message.args.get('visible', 'false'))),
19
+ )
20
+ return job.run()
@@ -0,0 +1,20 @@
1
+ from typing import Dict, Any, Generator
2
+
3
+ from plastron.context import PlastronContext
4
+ from plastron.jobs.publicationjob import PublicationJob, PublicationAction
5
+ from plastron.messaging.messages import PlastronCommandMessage
6
+ from plastron.utils import strtobool
7
+
8
+
9
+ def unpublish(
10
+ context: PlastronContext,
11
+ message: PlastronCommandMessage,
12
+ ) -> Generator[Dict[str, Any], None, Dict[str, Any]]:
13
+ job = PublicationJob(
14
+ context=context,
15
+ uris=message.body.strip().split('\n'),
16
+ action=PublicationAction.UNPUBLISH,
17
+ force_hidden=bool(strtobool(message.args.get('hidden', 'false'))),
18
+ force_visible=bool(strtobool(message.args.get('visible', 'false'))),
19
+ )
20
+ return job.run()
@@ -0,0 +1,45 @@
1
+ import json
2
+ import logging
3
+ from typing import Generator, Any, Dict
4
+
5
+ from plastron.context import PlastronContext
6
+ from plastron.jobs.updatejob import UpdateJob
7
+ from plastron.messaging.messages import PlastronCommandMessage
8
+ from plastron.models import get_model_class
9
+ from plastron.utils import strtobool, parse_predicate_list
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def parse_message(message: PlastronCommandMessage) -> Dict[str, Any]:
15
+ message.body = message.body.encode('utf-8').decode('utf-8-sig')
16
+ body = json.loads(message.body)
17
+ uris = body['uris']
18
+ sparql_update = body['sparql_update']
19
+ validate = bool(strtobool(message.args.get('validate', 'false')))
20
+ model = message.args.get('model', None)
21
+ recursive = message.args.get('recursive', None)
22
+
23
+ if validate and not model:
24
+ raise RuntimeError("Model must be provided when performing validation")
25
+
26
+ # Retrieve the model to use for validation
27
+ model_class = get_model_class(model) if model else None
28
+
29
+ traverse = parse_predicate_list(recursive) if recursive is not None else []
30
+ return {
31
+ 'uris': uris,
32
+ 'sparql_update': sparql_update,
33
+ 'model_class': model_class,
34
+ 'traverse': traverse,
35
+ 'dry_run': bool(strtobool(message.args.get('dry-run', 'false'))),
36
+ # Default to no transactions, due to LIBFCREPO-842
37
+ 'use_transactions': not bool(strtobool(message.args.get('no-transactions', 'true'))),
38
+ }
39
+
40
+
41
+ def update(
42
+ context: PlastronContext,
43
+ message: PlastronCommandMessage,
44
+ ) -> Generator[Dict[str, str], None, Dict[str, Any]]:
45
+ return UpdateJob(repo=context.repo, **parse_message(message)).run()
@@ -0,0 +1,116 @@
1
+ import logging
2
+ import logging.config
3
+ import os
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from threading import Event, Thread
8
+ from typing import TextIO, Dict, Any
9
+
10
+ import click
11
+ import yaml
12
+
13
+ from plastron.context import PlastronContext
14
+ from plastron.stomp import __version__
15
+ from plastron.stomp.listeners import CommandListener
16
+ from plastron.utils import DEFAULT_LOGGING_OPTIONS, envsubst
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class STOMPDaemon(Thread):
22
+ def __init__(self, config: Dict[str, Any], **kwargs):
23
+ super().__init__(**kwargs)
24
+ self.context = PlastronContext(config)
25
+ self.started = Event()
26
+ self.stopped = Event()
27
+ self.broker = self.context.broker
28
+
29
+ def started():
30
+ self.stopped.clear()
31
+ self.started.set()
32
+
33
+ def stopped():
34
+ self.started.clear()
35
+ self.stopped.set()
36
+
37
+ self.command_listener = CommandListener(
38
+ context=self.context,
39
+ after_connected=started,
40
+ after_disconnected=stopped,
41
+ )
42
+
43
+ def run(self):
44
+ # setup listeners
45
+ self.broker.set_listener('command', self.command_listener)
46
+
47
+ # connect and listen until the stopped Event is set
48
+ if self.broker.connect(client_id=f'plastrond/{__version__}-{os.uname().nodename}-{os.getpid()}'):
49
+ self.stopped.wait()
50
+
51
+ self.broker.disconnect()
52
+ if self.command_listener.inbox_watcher:
53
+ self.command_listener.inbox_watcher.stop()
54
+ else:
55
+ logger.error('Unable to connect to STOMP broker')
56
+
57
+
58
+ def configure_logging(log_filename_base: str, log_dir: str = 'logs', verbose: bool = False) -> None:
59
+ logging_options = DEFAULT_LOGGING_OPTIONS
60
+
61
+ # log file configuration
62
+ log_dirname = Path(log_dir)
63
+ log_dirname.mkdir(parents=True, exist_ok=True)
64
+ now = datetime.utcnow().strftime('%Y%m%d%H%M%S')
65
+ log_filename = '.'.join((log_filename_base, now, 'log'))
66
+ logfile = log_dirname / log_filename
67
+ logging_options['handlers']['file']['filename'] = str(logfile)
68
+
69
+ # manipulate console verbosity
70
+ if verbose:
71
+ logging_options['handlers']['console']['level'] = 'DEBUG'
72
+
73
+ # configure logging
74
+ logging.config.dictConfig(logging_options)
75
+
76
+
77
+ @click.command
78
+ @click.option(
79
+ '-c', '--config-file',
80
+ type=click.File(),
81
+ help='Configuration file',
82
+ required=True,
83
+ )
84
+ @click.option(
85
+ '-v', '--verbose',
86
+ is_flag=True,
87
+ help='increase the verbosity of the status output',
88
+ )
89
+ def main(config_file: TextIO, verbose: bool):
90
+ config = envsubst(yaml.safe_load(config_file))
91
+ repo_config = config.get('REPOSITORY', {})
92
+ configure_logging(
93
+ log_filename_base='plastron.daemon',
94
+ log_dir=repo_config.get('LOG_DIR', 'logs'),
95
+ verbose=verbose,
96
+ )
97
+ daemon_description = f'plastrond-stomp/{__version__}'
98
+ logger.info(f'Starting {daemon_description}')
99
+ thread = STOMPDaemon(config=config)
100
+
101
+ try:
102
+ thread.start()
103
+ while thread.is_alive():
104
+ thread.join(1)
105
+ except KeyboardInterrupt:
106
+ logger.warning(f'Shutting down {daemon_description}')
107
+ if hasattr(thread, 'stopped'):
108
+ thread.stopped.set()
109
+ thread.stopped.wait()
110
+
111
+ logger.info(f'{daemon_description} shut down successfully')
112
+ sys.exit()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -0,0 +1,55 @@
1
+ import logging
2
+
3
+ from plastron.messaging.broker import Destination
4
+ from plastron.messaging.messages import PlastronMessage, PlastronErrorMessage
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class AsynchronousResponseHandler:
10
+ def __init__(self, listener, message: PlastronMessage):
11
+ self.listener = listener
12
+ self.message = message
13
+ self.reply_queue: Destination = listener.broker['JOB_STATUS']
14
+
15
+ def __call__(self, future):
16
+ e = future.exception()
17
+ if e:
18
+ logger.error(f"Job {self.message.job_id} failed: {e}")
19
+ response = PlastronErrorMessage(job_id=self.message.job_id, error=str(e))
20
+ else:
21
+ # assume no errors, return the response
22
+ response = future.result()
23
+
24
+ # save a copy of the response message in the outbox
25
+ job_id = response.job_id
26
+ self.listener.outbox.add(job_id, response)
27
+
28
+ # remove the message from the inbox now that processing has completed
29
+ self.listener.inbox.remove(self.message.id)
30
+
31
+ # send the job completed message
32
+ self.reply_queue.send(response)
33
+ logger.debug(f'Response message sent to {self.reply_queue} with headers: {response.headers}')
34
+
35
+ # remove the message from the outbox now that sending has completed
36
+ self.listener.outbox.remove(job_id)
37
+
38
+
39
+ class SynchronousResponseHandler:
40
+ def __init__(self, listener, message: PlastronMessage):
41
+ self.listener = listener
42
+ self.message = message
43
+ self.reply_queue = Destination(self.listener.broker, message.headers['reply-to'])
44
+
45
+ def __call__(self, future):
46
+ e = future.exception()
47
+ if e:
48
+ logger.error(f"Job {self.message.job_id} failed: {e}")
49
+ self.reply_queue.send(PlastronErrorMessage(job_id=self.message.job_id, error=str(e)))
50
+ else:
51
+ # assume no errors, return the response
52
+ response = future.result()
53
+ # send to the specified "reply to" queue
54
+ self.reply_queue.send(response)
55
+ logger.debug(f'Response message sent to {self.reply_queue} with headers: {response.headers}')
@@ -0,0 +1,49 @@
1
+ import logging
2
+
3
+ from plastron.stomp.handlers import AsynchronousResponseHandler
4
+ from watchdog.observers import Observer
5
+ from watchdog.events import FileSystemEventHandler, FileCreatedEvent
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class InboxEventHandler(FileSystemEventHandler):
12
+ """Triggers message processing when a file is added in the
13
+ inbox directory."""
14
+ # Note to maintainers: The original implementation of this class included
15
+ # both "on_created" and "on_modified" event handlers. On Mac OS X, new file
16
+ # creation only triggers the "on_created" event. On Linux, new file
17
+ # creation triggers both an "on_created" event and "on_modified" event,
18
+ # leading to duplicate processing (see LIBFCREPO-821).
19
+ def __init__(self, command_listener, message_box):
20
+ self.command_listener = command_listener
21
+ self.message_box = message_box
22
+
23
+ def on_created(self, event):
24
+ if isinstance(event, FileCreatedEvent):
25
+ logger.info(f"Triggering inbox processing due to {event}")
26
+ message = self.message_box.message_class.read(event.src_path)
27
+ self.command_listener.process_message(message, AsynchronousResponseHandler(self.command_listener, message))
28
+
29
+
30
+ class InboxWatcher:
31
+ """
32
+ Watches for changes to the inbox directory, in order to trigger message
33
+ processing via InboxEventHandler
34
+ """
35
+ def __init__(self, command_listener, message_box):
36
+ """Constructs the watchdog Observer"""
37
+ self.observer = Observer()
38
+ self.observer.schedule(InboxEventHandler(command_listener, message_box), message_box.dir, recursive=True)
39
+
40
+ def start(self):
41
+ """Start the watcher"""
42
+ logger.debug("Starting InboxWatcher")
43
+ self.observer.start()
44
+
45
+ def stop(self):
46
+ """Stop the watcher"""
47
+ logger.debug("Stopping InboxWatcher")
48
+ self.observer.unschedule_all()
49
+ self.observer.stop()
@@ -0,0 +1,143 @@
1
+ import importlib.metadata
2
+ import logging
3
+ import os
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from typing import Dict, Any, Callable, Generator, Iterator
6
+
7
+ from stomp.listener import ConnectionListener
8
+
9
+ from plastron.context import PlastronContext
10
+ from plastron.messaging.broker import Destination
11
+ from plastron.messaging.messages import MessageBox, PlastronCommandMessage, PlastronMessage
12
+ from plastron.stomp.commands import get_command_module, get_module_name
13
+ from plastron.stomp.handlers import AsynchronousResponseHandler, SynchronousResponseHandler
14
+ from plastron.stomp.inbox_watcher import InboxWatcher
15
+
16
+ logger = logging.getLogger(__name__)
17
+ version = importlib.metadata.version('plastron-stomp')
18
+
19
+
20
+ class CommandListener(ConnectionListener):
21
+ def __init__(self, context: PlastronContext, after_connected: Callable = None, after_disconnected: Callable = None):
22
+ self.context = context
23
+ self.broker = context.broker
24
+ self.inbox = MessageBox(os.path.join(self.broker.message_store_dir, 'inbox'), PlastronCommandMessage)
25
+ self.outbox = MessageBox(os.path.join(self.broker.message_store_dir, 'outbox'), PlastronMessage)
26
+ self.executor = ThreadPoolExecutor(thread_name_prefix=__name__)
27
+ self.inbox_watcher = None
28
+ self.processor = MessageProcessor(context)
29
+ self.after_connected = after_connected
30
+ self.after_disconnected = after_disconnected
31
+
32
+ def on_connecting(self, host_and_port):
33
+ logger.info(f'Connecting to STOMP message broker {self.broker}')
34
+
35
+ def on_connected(self, frame):
36
+ logger.info(f'Connected to STOMP message broker {self.broker}')
37
+
38
+ # first attempt to send anything in the outbox
39
+ for message in self.outbox:
40
+ logger.info(f"Found response message for job {message.job_id} in outbox")
41
+ # send the job completed message
42
+ self.broker['JOB_STATUS'].send(message)
43
+ logger.info(f'Sent response message for job {message.job_id}')
44
+ # remove the message from the outbox now that sending has completed
45
+ self.outbox.remove(message.job_id)
46
+
47
+ # then process anything in the inbox
48
+ for message in self.inbox:
49
+ self.process_message(message, AsynchronousResponseHandler(self, message))
50
+
51
+ # subscribe to receive asynchronous jobs
52
+ self.broker['JOBS'].subscribe(id='plastron', ack='client-individual')
53
+ # subscribe to receive synchronous jobs
54
+ self.broker['SYNCHRONOUS_JOBS'].subscribe(id='plastron-synchronous', ack='client-individual')
55
+
56
+ self.inbox_watcher = InboxWatcher(self, self.inbox)
57
+ self.inbox_watcher.start()
58
+
59
+ if self.after_connected:
60
+ self.after_connected()
61
+
62
+ def on_message(self, frame):
63
+ headers = frame.headers
64
+ body = frame.body
65
+ logger.debug(f'Received message on {headers["destination"]} with headers: {headers}')
66
+ if headers['destination'] == self.broker['SYNCHRONOUS_JOBS'].name:
67
+ message = PlastronCommandMessage(headers=headers, body=body)
68
+ self.process_message(message, SynchronousResponseHandler(self, message))
69
+ self.broker.ack(message.id, 'plastron-synchronous')
70
+
71
+ elif headers['destination'] == self.broker['JOBS'].name:
72
+ # save the message in the inbox until we can process it
73
+ # Note: Processing will occur via the InboxWatcher, which will
74
+ # respond to the inbox placing a file in the inbox message directory
75
+ # containing the message
76
+ message = PlastronCommandMessage(headers=headers, body=body)
77
+ self.inbox.add(message.id, message)
78
+ self.broker.ack(message.id, 'plastron')
79
+
80
+ def process_message(self, message, response_handler):
81
+ # send to a message processor thread
82
+ self.executor.submit(self.processor, message, self.broker['JOB_PROGRESS']).add_done_callback(response_handler)
83
+
84
+ def on_disconnected(self):
85
+ logger.warning('Disconnected from the STOMP message broker')
86
+ if self.inbox_watcher:
87
+ self.inbox_watcher.stop()
88
+ if self.after_disconnected:
89
+ self.after_disconnected()
90
+
91
+
92
+ # type alias for command functions that take a context and a message, and return a generator that yields
93
+ # status updates in the form of dictionaries, and returns a final state, also as a dictionary
94
+ STOMPCommandFunction = Callable[[PlastronContext, PlastronMessage], Generator[Dict[str, Any], None, Dict[str, Any]]]
95
+
96
+
97
+ def get_command(command_name: str) -> STOMPCommandFunction:
98
+ module_name = get_module_name(command_name)
99
+ module = get_command_module(command_name)
100
+ command = getattr(module, module_name, None)
101
+ if command is None:
102
+ raise RuntimeError(f'Command function "{module_name}" not found in {module.__name__}')
103
+ return command
104
+
105
+
106
+ class MessageProcessor:
107
+ def __init__(self, context: PlastronContext):
108
+ self.context = context
109
+ # cache for command instances
110
+ self.commands = {}
111
+ self.result = None
112
+
113
+ def __call__(self, message: PlastronCommandMessage, progress_topic: Destination):
114
+ if message.job_id is None:
115
+ raise RuntimeError('Expecting a PlastronJobId header')
116
+
117
+ logger.info(f'Received message to initiate job {message.job_id}')
118
+
119
+ # determine which command to load to process the message
120
+ command = get_command(message.command)
121
+
122
+ delegated_user = message.args.get('on-behalf-of')
123
+ if delegated_user is not None:
124
+ logger.info(f'Running repository operations on behalf of {delegated_user}')
125
+
126
+ # run the command, and send a progress message over STOMP every time it yields
127
+ # the _run() delegating generator captures the final status in self.result
128
+ with self.context.repo_configuration(
129
+ delegated_user=delegated_user,
130
+ ua_string=f'plastron/{version}',
131
+ ) as run_context:
132
+ for status in self._run(command(run_context, message)):
133
+ progress_topic.send(PlastronMessage(job_id=message.job_id, body=status))
134
+
135
+ logger.info(f'Job {message.job_id} complete')
136
+
137
+ # default message state is "Done"
138
+ return message.response(state=self.result.get('type', 'Done'), body=self.result)
139
+
140
+ def _run(self, command: Generator[Dict, None, Dict]) -> Iterator[Dict[str, Any]]:
141
+ # delegating generator; each progress step is passed to the calling
142
+ # method, and the return value from the command is stored as the result
143
+ self.result = yield from command
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.1
2
+ Name: plastron-stomp
3
+ Version: 4.3.2
4
+ Summary: Plastron STOMP daemon
5
+ Author-email: University of Maryland Libraries <lib-ssdr@umd.edu>, Josh Westgard <westgard@umd.edu>, Peter Eichman <peichman@umd.edu>, Mohamed Abdul Rasheed <mohideen@umd.edu>, Ben Wallberg <wallberg@umd.edu>, David Steelman <dsteelma@umd.edu>, Marc Andreu Grillo Aguilar <aguilarm@umd.edu>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click
9
+ Requires-Dist: BeautifulSoup4
10
+ Requires-Dist: plastron-models
11
+ Requires-Dist: plastron-repo
12
+ Requires-Dist: plastron-utils
13
+ Requires-Dist: pyparsing
14
+ Requires-Dist: PyYAML
15
+ Requires-Dist: stomp.py
16
+ Requires-Dist: watchdog==0.10.3
17
+ Provides-Extra: test
18
+ Requires-Dist: CoilMQ; extra == "test"
19
+ Requires-Dist: pytest; extra == "test"
20
+ Requires-Dist: pytest-cov; extra == "test"
21
+
22
+ # plastron-stomp
23
+
24
+ STOMP listener client for asynchronous and synchronous operations
25
+
26
+ ## Running with Python
27
+
28
+ As a Python module:
29
+
30
+ ```bash
31
+ python -m plastron.stomp.daemon -c <config file>
32
+ ```
33
+
34
+ Using the console script entrypoint:
35
+
36
+ ```bash
37
+ plastrond-stomp -c <config file>
38
+ ```
39
+
40
+ ## Integration Tests
41
+
42
+ See the [integration test README](integration-tests/README.md) for
43
+ instructions on running the manual integration tests.
44
+
45
+ ## Docker Image
46
+
47
+ The plastron-stomp package contains a [Dockerfile](Dockerfile) for
48
+ building the `plastrond-stomp` Docker image.
49
+
50
+ ### Building
51
+
52
+ **Important:** This image **MUST** be built from the main _plastron_
53
+ project directory, in order to include the other plastron packages in the
54
+ build context.
55
+
56
+ ```bash
57
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
58
+ -f plastron-stomp/Dockerfile .
59
+ ```
60
+
61
+ ### Running with Docker Swarm
62
+
63
+ This repository contains a [compose.yml](compose.yml) file that defines
64
+ part of a `plastrond` Docker stack intended to be run alongside the
65
+ [umd-fcrepo-docker] stack. This repository's configuration adds a
66
+ `plastrond-stomp` container.
67
+
68
+ ```bash
69
+ # if you are not already running in swarm mode
70
+ docker swarm init
71
+
72
+ # build the image
73
+ docker build -t docker.lib.umd.edu/plastrond-stomp:latest \
74
+ -f plastron-stomp/Dockerfile .
75
+
76
+ # Create an "archelon_id" private/public key pair
77
+ # The Archelon instance should be configured with "archelon_id.pub" as the
78
+ # PLASTRON_PUBLIC_KEY
79
+ ssh-keygen -q -t rsa -N '' -f archelon_id
80
+
81
+ # Copy the docker-plastron-template.yml and edit the configuration
82
+ cp docker-plastron.template.yml docker-plastron.yml
83
+ vim docker-plastron.yml
84
+
85
+ # deploy the stack to run the STOMP application
86
+ docker stack deploy -c plastron-stomp/compose.yml plastrond
87
+ ```
88
+
89
+ To watch the logs:
90
+
91
+ ```bash
92
+ docker service logs -f plastrond_stomp
93
+ ```
94
+
95
+ To stop the STOMP service:
96
+
97
+ ```bash
98
+ docker service rm plastrond_stomp
99
+ ```
100
+
101
+ ## Configuration
102
+
103
+ See [docker-plastron.template.yml](../docker-plastron.template.yml) for an
104
+ example of the config file.
105
+
106
+ ## STOMP Message Headers
107
+
108
+ The Plastron Daemon expects the following headers to be present in messages
109
+ received on the `JOBS` destination:
110
+
111
+ * `PlastronCommand`
112
+ * `PlastronJobId`
113
+
114
+ Additional arguments for a command are sent in headers with the form `PlastronArg-{name}`.
115
+ Many of these are specific to the command, but there is one with standard behavior across
116
+ all commands:
117
+
118
+ | Header | Description |
119
+ |----------------------------|-----------------------------------------------|
120
+ | `PlastronArg-on-behalf-of` | Username to delegate repository operations to |
121
+
122
+ See the [messages documentation](docs/messages.md) for details on the headers
123
+ and bodies of the messages the Plastron STOMP Daemon emits.
124
+
125
+ [umd-fcrepo-docker]: https://github.com/umd-lib/umd-fcrepo-docker
@@ -0,0 +1,24 @@
1
+ README.md
2
+ VERSION
3
+ pyproject.toml
4
+ src/plastron/stomp/__init__.py
5
+ src/plastron/stomp/daemon.py
6
+ src/plastron/stomp/handlers.py
7
+ src/plastron/stomp/inbox_watcher.py
8
+ src/plastron/stomp/listeners.py
9
+ src/plastron/stomp/commands/__init__.py
10
+ src/plastron/stomp/commands/echo.py
11
+ src/plastron/stomp/commands/export.py
12
+ src/plastron/stomp/commands/importcommand.py
13
+ src/plastron/stomp/commands/publish.py
14
+ src/plastron/stomp/commands/unpublish.py
15
+ src/plastron/stomp/commands/update.py
16
+ src/plastron_stomp.egg-info/PKG-INFO
17
+ src/plastron_stomp.egg-info/SOURCES.txt
18
+ src/plastron_stomp.egg-info/dependency_links.txt
19
+ src/plastron_stomp.egg-info/entry_points.txt
20
+ src/plastron_stomp.egg-info/requires.txt
21
+ src/plastron_stomp.egg-info/top_level.txt
22
+ tests/test_connection.py
23
+ tests/test_handlers.py
24
+ tests/test_inbox_watcher.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plastrond-stomp = plastron.stomp.daemon:main
@@ -0,0 +1,14 @@
1
+ click
2
+ BeautifulSoup4
3
+ plastron-models
4
+ plastron-repo
5
+ plastron-utils
6
+ pyparsing
7
+ PyYAML
8
+ stomp.py
9
+ watchdog==0.10.3
10
+
11
+ [test]
12
+ CoilMQ
13
+ pytest
14
+ pytest-cov
@@ -0,0 +1,76 @@
1
+ from threading import Thread
2
+
3
+ import pytest
4
+ from coilmq.protocol import STOMP11
5
+ from coilmq.queue import QueueManager
6
+ from coilmq.server.socket_server import StompServer
7
+ from coilmq.store.memory import MemoryQueue
8
+ from coilmq.topic import TopicManager
9
+
10
+ from plastron.messaging.broker import ServerTuple, Broker
11
+ from plastron.stomp.daemon import STOMPDaemon
12
+
13
+
14
+ @pytest.fixture()
15
+ def server_address() -> ServerTuple:
16
+ return ServerTuple('localhost', 61613)
17
+
18
+
19
+ @pytest.fixture()
20
+ def broker(server_address, shared_datadir) -> Broker:
21
+ return Broker(
22
+ server=server_address,
23
+ message_store_dir=(shared_datadir / 'msg'),
24
+ destinations={
25
+ 'JOBS': '/queue/plastron.jobs',
26
+ 'JOB_PROGRESS': '/topic/plastron.jobs.progress',
27
+ 'JOB_STATUS': '/queue/plastron.jobs.status',
28
+ 'SYNCHRONOUS_JOBS': '/queue/plastron.jobs.synchronous',
29
+ 'REINDEXING': '/queue/reindex',
30
+ }
31
+ )
32
+
33
+
34
+ @pytest.fixture()
35
+ def stomp_server(server_address):
36
+ server = StompServer(
37
+ server_address=server_address,
38
+ queue_manager=QueueManager(store=MemoryQueue()),
39
+ topic_manager=TopicManager(),
40
+ protocol=STOMP11,
41
+ )
42
+
43
+ yield server
44
+
45
+ server.server_close()
46
+
47
+
48
+ @pytest.fixture()
49
+ def plastrond_stomp(broker):
50
+ plastrond = STOMPDaemon(broker=broker, repo_config={})
51
+
52
+ yield plastrond
53
+
54
+ if plastrond.is_alive():
55
+ plastrond.stopped.set()
56
+ plastrond.stopped.wait()
57
+
58
+
59
+ @pytest.mark.skip('intermittent failures, test is unreliable')
60
+ def test_stomp_server_closed(stomp_server, plastrond_stomp):
61
+ server_thread = Thread(target=stomp_server.serve_forever)
62
+ server_thread.start()
63
+ assert server_thread.is_alive()
64
+
65
+ plastrond_stomp.start()
66
+ assert plastrond_stomp.is_alive()
67
+ # wait for startup and subscriptions to happen
68
+ plastrond_stomp.started.wait()
69
+
70
+ # stop the server
71
+ stomp_server.shutdown()
72
+
73
+ # wait for the daemon to shut down
74
+ plastrond_stomp.stopped.wait(10)
75
+
76
+ assert not plastrond_stomp.is_alive()
@@ -0,0 +1,80 @@
1
+ from concurrent.futures import Future
2
+ from typing import cast, Dict, Type
3
+ from unittest import TestCase
4
+ from unittest.mock import Mock
5
+
6
+ import pytest
7
+
8
+ from plastron.messaging.broker import Destination
9
+ from plastron.messaging.messages import MessageBox, PlastronCommandMessage, PlastronErrorMessage, PlastronMessage, \
10
+ Message
11
+ from plastron.stomp.handlers import AsynchronousResponseHandler
12
+ from plastron.stomp.listeners import CommandListener
13
+
14
+
15
+ @pytest.fixture
16
+ def mock_listener():
17
+ return Mock(
18
+ spec=CommandListener,
19
+ inbox=Mock(MessageBox),
20
+ outbox=Mock(MessageBox),
21
+ broker={'JOB_STATUS': Mock(Destination)},
22
+ )
23
+
24
+
25
+ def test_asynchronous_response_handler_successful_call_removes_inbox_and_outbox_entries(mock_listener):
26
+ # Set up mocks
27
+ job_id = 'test_asynchronous_response_handler_successful_call-123'
28
+
29
+ # This future represents a successful call
30
+ future_response = PlastronMessage(headers={'PlastronJobId': job_id}, body="Success!")
31
+ future_mock_attrs = {'exception.return_value': None, 'result.return_value': future_response}
32
+ future = Mock(Future, **future_mock_attrs)
33
+
34
+ incoming_message = Mock(PlastronCommandMessage, id='incoming_test_message', job_id=job_id)
35
+
36
+ # Handle the incoming message
37
+ handler = AsynchronousResponseHandler(mock_listener, incoming_message)
38
+ handler(future)
39
+
40
+ # Verify outbox/inbox handling and message sending
41
+ mock_listener.outbox.add.assert_called_once_with(job_id, future_response)
42
+ mock_listener.inbox.remove.assert_called_once_with(incoming_message.id)
43
+ mock_listener.broker['JOB_STATUS'].send.assert_called_once_with(future_response)
44
+ mock_listener.outbox.remove.assert_called_once_with(job_id)
45
+
46
+
47
+ def test_asynchronous_response_handler_call_with_exception_removes_inbox_and_outbox_entries(mock_listener):
48
+ # Set up mocks
49
+ job_id = 'test_asynchronous_response_handler_call_with_exception-123'
50
+
51
+ # This future throw an exception, representing a failed call
52
+ future_response = PlastronErrorMessage(headers={'PlastronJobId': job_id})
53
+ exception_message = 'An error occurred'
54
+ future_mock_attrs = {'exception.return_value': Exception(exception_message), 'result.return_value': future_response}
55
+ future = Mock(Future, **future_mock_attrs)
56
+
57
+ incoming_message = Mock(PlastronCommandMessage, id='incoming_test_message', job_id=job_id)
58
+
59
+ # Handle the incoming message
60
+ handler = AsynchronousResponseHandler(mock_listener, incoming_message)
61
+ handler(future)
62
+
63
+ # Verify outbox/inbox handling and message sending
64
+ mock_listener.outbox.add.assert_called_once()
65
+ mock_listener.inbox.remove.assert_called_once_with(incoming_message.id)
66
+
67
+ mock_listener.broker['JOB_STATUS'].send.assert_called_once()
68
+ expected_msg_headers = {'PlastronJobId': job_id, 'PlastronJobError': exception_message, 'persistent': 'true'}
69
+ assert_sent_message(mock_listener.broker['JOB_STATUS'], PlastronErrorMessage, expected_msg_headers, '')
70
+
71
+ mock_listener.outbox.remove.assert_called_once_with(job_id)
72
+
73
+
74
+ def assert_sent_message(queue: Destination, message_class: Type[Message],
75
+ expected_message_headers: Dict[str, str], expected_body: str):
76
+ status_queue_send_args = cast(Mock, queue).send.call_args[0] # using cast, so mypy doesn't complain
77
+ sent_message = status_queue_send_args[0]
78
+ assert isinstance(sent_message, message_class)
79
+ TestCase().assertDictEqual(sent_message.headers, expected_message_headers)
80
+ assert sent_message.body == expected_body
@@ -0,0 +1,52 @@
1
+ import os
2
+ import tempfile
3
+ import time
4
+ from contextlib import contextmanager
5
+ from unittest.mock import patch
6
+
7
+ from plastron.stomp.inbox_watcher import InboxWatcher
8
+ from plastron.messaging.messages import MessageBox, PlastronMessage
9
+
10
+
11
+ def test_new_file_in_inbox():
12
+ with tempfile.TemporaryDirectory() as inbox_dirname:
13
+ with patch('plastron.stomp.listeners.CommandListener') as mock_command_listener:
14
+ # Mock "process_message" and use to verify that CommandListener
15
+ # is called (when a file is created in the inbox).
16
+ mock_method = mock_command_listener.process_message
17
+
18
+ with inbox_watcher(inbox_dirname, mock_command_listener):
19
+ create_test_file(inbox_dirname)
20
+
21
+ wait_until_called(mock_method)
22
+
23
+ mock_method.assert_called_once()
24
+
25
+ # Utility methods
26
+
27
+
28
+ @contextmanager
29
+ def inbox_watcher(inbox_dirname, mock_command_listener):
30
+ inbox = MessageBox(inbox_dirname, PlastronMessage)
31
+ watcher = InboxWatcher(mock_command_listener, inbox)
32
+ watcher.start()
33
+
34
+ try:
35
+ yield
36
+ finally:
37
+ watcher.stop()
38
+
39
+
40
+ def create_test_file(inbox_dirname):
41
+ temp_file = open(os.path.join(inbox_dirname, 'test_new_file'), 'w')
42
+ temp_file.write("test:test")
43
+ temp_file.close()
44
+
45
+
46
+ def wait_until_called(mock_method, interval=0.1, timeout=5):
47
+ '''Polls at the given interval until either the mock_method is called
48
+ or the timeout occurs.'''
49
+ # Inspired by https://stackoverflow.com/a/36040926
50
+ start = time.time()
51
+ while not mock_method.called and time.time() - start < timeout:
52
+ time.sleep(interval)