├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── doc.yml │ ├── maintenance.yml │ ├── feature_request.yml │ └── bug.yml ├── workflows │ ├── record_pr.yml │ ├── responded.yml │ ├── codeql.yml │ ├── stale_prs_and_issues.yml │ ├── auto_approve.yml │ ├── release_bump.yml │ ├── code_quality.yml │ ├── on_opened_pr.yml │ └── release_publish.yml ├── dependabot.yml ├── scripts │ └── get_latest_changelog.py └── PULL_REQUEST_TEMPLATE.md ├── requirements-release.txt ├── requirements-development.txt ├── test └── openjd │ ├── adaptor_runtime │ ├── integ │ │ ├── AdaptorExample │ │ │ ├── AdaptorExample.json │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── adaptor_client.py │ │ ├── CommandAdaptorExample │ │ │ ├── CommandAdaptorExample.json │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── adaptor.py │ │ ├── __init__.py │ │ ├── _utils │ │ │ ├── __init__.py │ │ │ └── test_secure_open.py │ │ ├── adaptors │ │ │ ├── __init__.py │ │ │ ├── configuration │ │ │ │ ├── __init__.py │ │ │ │ ├── test_configuration.py │ │ │ │ └── test_configuration_manager.py │ │ │ └── test_integration_adaptor.py │ │ ├── process │ │ │ ├── __init__.py │ │ │ ├── scripts │ │ │ │ ├── echo_sleep_n_times.py │ │ │ │ └── signals_test.py │ │ │ └── test_integration_managed_process.py │ │ ├── _named_pipe │ │ │ ├── __init__.py │ │ │ └── test_named_pipe_helper.py │ │ ├── background │ │ │ └── __init__.py │ │ └── application_ipc │ │ │ ├── __init__.py │ │ │ └── fake_app_client.py │ ├── __init__.py │ ├── unit │ │ ├── __init__.py │ │ ├── http │ │ │ └── __init__.py │ │ ├── utils │ │ │ ├── __init__.py │ │ │ ├── test_logging.py │ │ │ └── test_secure_open.py │ │ ├── adaptors │ │ │ ├── __init__.py │ │ │ ├── configuration │ │ │ │ ├── __init__.py │ │ │ │ └── stubs.py │ │ │ ├── fake_adaptor.py │ │ │ ├── test_basic_adaptor.py │ │ │ ├── test_versioning.py │ │ │ └── test_adaptor.py │ │ ├── background │ │ │ ├── __init__.py │ │ │ ├── test_model.py │ │ │ ├── test_server_response.py │ │ │ └── test_loaders.py │ │ ├── handlers │ │ │ └── __init__.py │ │ ├── process │ │ │ ├── __init__.py │ │ │ ├── test_managed_process.py │ │ │ └── test_stream_logger.py │ │ ├── application_ipc │ │ │ ├── __init__.py │ │ │ └── test_actions_queue.py │ │ ├── test_osname.py │ │ └── named_pipe │ │ │ └── test_named_pipe_helper.py │ ├── test_importable.py │ └── conftest.py │ ├── adaptor_runtime_client │ ├── __init__.py │ ├── integ │ │ ├── __init__.py │ │ ├── fake_client.py │ │ └── test_integration_client_interface.py │ ├── unit │ │ ├── __init__.py │ │ └── test_action.py │ └── test_importable.py │ ├── test_importable.py │ └── test_copyright_header.py ├── NOTICE ├── src └── openjd │ ├── adaptor_runtime │ ├── py.typed │ ├── configuration.json │ ├── __init__.py │ ├── _utils │ │ ├── __init__.py │ │ ├── _constants.py │ │ └── _logging.py │ ├── app_handlers │ │ ├── __init__.py │ │ └── _regex_callback_handler.py │ ├── _named_pipe │ │ └── __init__.py │ ├── _http │ │ ├── __init__.py │ │ └── exceptions.py │ ├── adaptors │ │ ├── configuration │ │ │ ├── _adaptor_configuration.schema.json │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── _adaptor_states.py │ │ ├── _command_adaptor.py │ │ ├── _adaptor.py │ │ ├── _versioning.py │ │ ├── _adaptor_runner.py │ │ └── _validator.py │ ├── process │ │ ├── __init__.py │ │ ├── _logging.py │ │ ├── _stream_logger.py │ │ └── _managed_process.py │ ├── _background │ │ ├── __init__.py │ │ ├── backend_named_pipe_server.py │ │ ├── loaders.py │ │ ├── model.py │ │ └── background_named_pipe_request_handler.py │ ├── configuration.schema.json │ ├── application_ipc │ │ ├── __init__.py │ │ ├── _actions_queue.py │ │ ├── _adaptor_server.py │ │ ├── _win_adaptor_server.py │ │ ├── _named_pipe_request_handler.py │ │ ├── _adaptor_server_response.py │ │ └── _http_request_handler.py │ └── _osname.py │ └── adaptor_runtime_client │ ├── py.typed │ ├── named_pipe │ ├── __init__.py │ └── named_pipe_config.py │ ├── __init__.py │ ├── action.py │ ├── win_client_interface.py │ ├── posix_client_interface.py │ └── connection.py ├── pipeline ├── publish.sh └── build.sh ├── requirements-testing.txt ├── CODE_OF_CONDUCT.md ├── .gitignore ├── hatch.toml ├── .semantic_release └── CHANGELOG.md.j2 ├── scripts └── add_copyright_headers.sh ├── THIRD-PARTY-LICENSES.txt ├── CHANGELOG.md └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @OpenJobDescription/Developers -------------------------------------------------------------------------------- /requirements-release.txt: -------------------------------------------------------------------------------- 1 | python-semantic-release == 10.5.* -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /requirements-development.txt: -------------------------------------------------------------------------------- 1 | hatch == 1.15.* 2 | hatch-vcs == 0.5.* -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/AdaptorExample/AdaptorExample.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/CommandAdaptorExample/CommandAdaptorExample.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file that indicates this package supports typing 2 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file that indicates this package supports typing 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "log_level": "INFO", 3 | "deactivate_telemetry": false 4 | } -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/http/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/adaptors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/process/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/background/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/process/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/integ/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/named_pipe/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/_named_pipe/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/background/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/application_ipc/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/application_ipc/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/adaptors/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /pipeline/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Set the -e option 3 | set -e 4 | 5 | ./pipeline/build.sh 6 | twine upload --repository codeartifact dist/* --verbose -------------------------------------------------------------------------------- /test/openjd/test_importable.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | 4 | def test_openjd_importable(): 5 | import openjd # noqa: F401 6 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._entrypoint import EntryPoint 4 | 5 | __all__ = [ 6 | "EntryPoint", 7 | ] 8 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._secure_open import secure_open 4 | 5 | __all__ = [ 6 | "secure_open", 7 | ] 8 | -------------------------------------------------------------------------------- /pipeline/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Set the -e option 3 | set -e 4 | 5 | pip install --upgrade pip 6 | pip install --upgrade hatch 7 | pip install --upgrade twine 8 | hatch run lint 9 | hatch run test 10 | hatch build 11 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/AdaptorExample/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from .adaptor import AdaptorExample 4 | 5 | __all__ = [ 6 | "AdaptorExample", 7 | ] 8 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/app_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._regex_callback_handler import RegexCallback, RegexHandler 4 | 5 | __all__ = ["RegexCallback", "RegexHandler"] 6 | -------------------------------------------------------------------------------- /.github/workflows/record_pr.yml: -------------------------------------------------------------------------------- 1 | name: Record PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | call-record-workflow: 9 | uses: OpenJobDescription/.github/.github/workflows/reusable_record_pr_details.yml@mainline -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/CommandAdaptorExample/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from .__main__ import main 4 | from .adaptor import CommandAdaptorExample 5 | 6 | __all__ = ["CommandAdaptorExample", "main"] 7 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/test_importable.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | 4 | def test_openjd_importable(): 5 | import openjd # noqa: F401 6 | 7 | 8 | def test_importable(): 9 | import openjd.adaptor_runtime # noqa: F401 10 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage[toml] == 7.* 2 | coverage-conditional-plugin == 0.9.0 3 | pytest == 8.4.* 4 | pytest-cov == 7.0.* 5 | pytest-timeout == 2.4.* 6 | pytest-xdist == 3.8.* 7 | black == 25.* 8 | ruff == 0.14.* 9 | mypy == 1.19.* 10 | psutil == 7.1.* 11 | types-PyYAML ~= 6.0 12 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/test_importable.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | 4 | def test_openjd_importable(): 5 | import openjd # noqa: F401 6 | 7 | 8 | def test_importable(): 9 | import openjd.adaptor_runtime # noqa: F401 10 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_named_pipe/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from .named_pipe_server import NamedPipeServer 4 | from .named_pipe_request_handler import ResourceRequestHandler 5 | 6 | __all__ = ["NamedPipeServer", "ResourceRequestHandler"] 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_http/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from .request_handler import HTTPResponse, RequestHandler, ResourceRequestHandler 4 | from .sockets import SocketPaths 5 | 6 | __all__ = ["HTTPResponse", "RequestHandler", "ResourceRequestHandler", "SocketPaths"] 7 | -------------------------------------------------------------------------------- /.github/workflows/responded.yml: -------------------------------------------------------------------------------- 1 | name: Contributor Responded 2 | on: 3 | issue_comment: 4 | types: [created, edited] 5 | 6 | jobs: 7 | check-for-response: 8 | uses: OpenJobDescription/.github/.github/workflows/reusable_responded.yml@mainline 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *# 3 | *.swp 4 | 5 | *.DS_Store 6 | 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | *.egg-info/ 11 | 12 | /.coverage 13 | /.coverage.* 14 | /.cache 15 | /.pytest_cache 16 | /.mypy_cache 17 | /.ruff_cache 18 | /.attach_pid* 19 | /.venv 20 | 21 | /doc/_apidoc/ 22 | /build 23 | /dist 24 | _version.py 25 | .vscode 26 | .coverage 27 | .idea/ 28 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/configuration/_adaptor_configuration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "log_level": { 6 | "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 7 | "default": "INFO" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/process/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._logging_subprocess import LoggingSubprocess 4 | from ._managed_process import ManagedProcess 5 | from ._stream_logger import StreamLogger 6 | 7 | __all__ = [ 8 | "LoggingSubprocess", 9 | "ManagedProcess", 10 | "StreamLogger", 11 | ] 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "mainline" ] 6 | pull_request: 7 | branches: [ "mainline" ] 8 | schedule: 9 | - cron: '0 8 * * MON' 10 | 11 | jobs: 12 | Analysis: 13 | name: Analysis 14 | uses: OpenJobDescription/.github/.github/workflows/reusable_codeql.yml@mainline 15 | permissions: 16 | security-events: write 17 | -------------------------------------------------------------------------------- /.github/workflows/stale_prs_and_issues.yml: -------------------------------------------------------------------------------- 1 | name: 'Check stale issues/PRs.' 2 | on: 3 | schedule: 4 | # Run every hour on the hour 5 | - cron: '0 * * * *' 6 | 7 | jobs: 8 | check-for-stales: 9 | uses: OpenJobDescription/.github/.github/workflows/reusable_stale_prs_and_issues.yml@mainline 10 | permissions: 11 | contents: read 12 | issues: write 13 | pull-requests: write 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc.yml: -------------------------------------------------------------------------------- 1 | 2 | name: "📕 Documentation Issue" 3 | description: Issue in the documentation 4 | title: "Docs: (short description of the issue)" 5 | labels: ["documentation", "needs triage"] 6 | body: 7 | - type: textarea 8 | id: documentation_issue 9 | attributes: 10 | label: Documentation Issue 11 | description: Describe the issue 12 | validations: 13 | required: true -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/process/_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import logging 4 | 5 | _STDOUT_LEVEL = logging.ERROR + 1 6 | logging.addLevelName(_STDOUT_LEVEL, "STDOUT") 7 | 8 | _STDERR_LEVEL = logging.ERROR + 2 9 | logging.addLevelName(_STDERR_LEVEL, "STDERR") 10 | 11 | _ADAPTOR_OUTPUT_LEVEL = logging.ERROR + 3 12 | logging.addLevelName(_ADAPTOR_OUTPUT_LEVEL, "ADAPTOR_OUTPUT") 13 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_background/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from .backend_runner import BackendRunner 4 | from .frontend_runner import FrontendRunner 5 | from .log_buffers import InMemoryLogBuffer, FileLogBuffer, LogBufferHandler 6 | 7 | __all__ = [ 8 | "BackendRunner", 9 | "FrontendRunner", 10 | "InMemoryLogBuffer", 11 | "FileLogBuffer", 12 | "LogBufferHandler", 13 | ] 14 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/configuration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "type": "object", 4 | "properties": { 5 | "log_level": { 6 | "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 7 | "default": "INFO" 8 | }, 9 | "deactivate_telemetry": { 10 | "type": "boolean", 11 | "default": false 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_http/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | 4 | class UnsupportedPlatformException(Exception): 5 | pass 6 | 7 | 8 | class NonvalidSocketPathException(Exception): 9 | """Raised when a socket path is not valid""" 10 | 11 | pass 12 | 13 | 14 | class NoSocketPathFoundException(Exception): 15 | """Raised when a valid socket path could not be found""" 16 | 17 | pass 18 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._actions_queue import ActionsQueue 4 | from .._osname import OSName 5 | 6 | if OSName.is_posix(): # pragma: is-windows 7 | from ._adaptor_server import AdaptorServer 8 | else: # pragma: is-posix 9 | from ._win_adaptor_server import WinAdaptorServer as AdaptorServer # type: ignore 10 | 11 | __all__ = ["ActionsQueue", "AdaptorServer"] 12 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_utils/_constants.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import re 4 | 5 | _OPENJD_LOG_PATTERN = r"^openjd_\S+: " 6 | _OPENJD_LOG_REGEX = re.compile(_OPENJD_LOG_PATTERN) 7 | 8 | _OPENJD_FAIL_STDOUT_PREFIX = "openjd_fail: " 9 | _OPENJD_PROGRESS_STDOUT_PREFIX = "openjd_progress: " 10 | _OPENJD_STATUS_STDOUT_PREFIX = "openjd_status: " 11 | _OPENJD_ENV_STDOUT_PREFIX = "openjd_env: " 12 | 13 | _OPENJD_ADAPTOR_SOCKET_ENV = "OPENJD_ADAPTOR_SOCKET" 14 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/process/scripts/echo_sleep_n_times.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import sys 4 | import time 5 | 6 | 7 | def repeat_message(message, count): 8 | for _ in range(count): 9 | print(message) 10 | print(message, file=sys.stderr) 11 | time.sleep(0.01) 12 | 13 | 14 | if __name__ == "__main__": 15 | message = sys.argv[1] 16 | num_times = int(sys.argv[2]) 17 | repeat_message(message, num_times) 18 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/AdaptorExample/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import sys 4 | 5 | from openjd.adaptor_runtime import EntryPoint 6 | 7 | from .adaptor import AdaptorExample 8 | 9 | 10 | def main(): 11 | package_name = vars(sys.modules[__name__])["__package__"] 12 | if not package_name: 13 | raise RuntimeError(f"Must be run as a module. Do not run {__file__} directly") 14 | 15 | EntryPoint(AdaptorExample).start() 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/named_pipe/named_pipe_config.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | # Windows Named Pipe Server Configuration 4 | NAMED_PIPE_BUFFER_SIZE = 8192 5 | DEFAULT_NAMED_PIPE_TIMEOUT_MILLISECONDS = 5000 6 | # This number must be >= 2, one instance is for normal operation communication 7 | # and the other one is for immediate shutdown communication 8 | DEFAULT_MAX_NAMED_PIPE_INSTANCES = 4 9 | # The maximum time in seconds to wait for the server pipe to become available before raising an error. 10 | DEFAULT_NAMED_PIPE_SERVER_TIMEOUT_IN_SECONDS = 60 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/maintenance.yml: -------------------------------------------------------------------------------- 1 | name: "🛠️ Maintenance" 2 | description: Some type of improvement 3 | title: "Maintenance: (short description of the issue)" 4 | labels: ["maintenance", "needs triage"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: Describe the improvement and why it is important to do. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: solution 15 | attributes: 16 | label: Solution 17 | description: Provide any ideas you have for how the suggestion can be implemented. 18 | validations: 19 | required: true 20 | -------------------------------------------------------------------------------- /.github/workflows/auto_approve.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-approve 2 | on: pull_request 3 | 4 | permissions: 5 | pull-requests: write 6 | 7 | jobs: 8 | dependabot: 9 | runs-on: ubuntu-latest 10 | if: ${{ github.actor == 'dependabot[bot]' }} 11 | steps: 12 | - name: Dependabot metadata 13 | id: metadata 14 | uses: dependabot/fetch-metadata@v2 15 | with: 16 | github-token: "${{ secrets.GITHUB_TOKEN }}" 17 | - name: Approve a PR 18 | run: gh pr review --approve "$PR_URL" 19 | env: 20 | PR_URL: ${{ github.event.pull_request.html_url }} 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release_bump.yml: -------------------------------------------------------------------------------- 1 | name: "Release: Bump" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | force_version_bump: 7 | required: false 8 | default: "" 9 | type: choice 10 | options: 11 | - "" 12 | - patch 13 | - minor 14 | - major 15 | 16 | concurrency: 17 | group: release 18 | 19 | jobs: 20 | Bump: 21 | name: Version Bump 22 | uses: OpenJobDescription/.github/.github/workflows/reusable_bump.yml@mainline 23 | permissions: 24 | contents: write 25 | pull-requests: write 26 | secrets: inherit 27 | with: 28 | force_version_bump: ${{ inputs.force_version_bump }} -------------------------------------------------------------------------------- /.github/workflows/code_quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | pull_request: 5 | branches: [ mainline, release, 'patch_*' ] 6 | workflow_call: 7 | inputs: 8 | branch: 9 | required: false 10 | type: string 11 | tag: 12 | required: false 13 | type: string 14 | 15 | jobs: 16 | Test: 17 | name: Python 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest, windows-latest, macos-latest] 21 | python-version: ['3.9', '3.10', '3.11', '3.12'] 22 | uses: OpenJobDescription/.github/.github/workflows/reusable_python_build.yml@mainline 23 | with: 24 | os: ${{ matrix.os }} 25 | python-version: ${{ matrix.python-version }} 26 | ref: ${{inputs.tag}} 27 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import os 4 | 5 | from .action import Action 6 | from .base_client_interface import ( 7 | PathMappingRule, 8 | ) 9 | 10 | if os.name == "posix": 11 | from .posix_client_interface import HTTPClientInterface as ClientInterface 12 | 13 | # This is just for backward compatible 14 | from .posix_client_interface import HTTPClientInterface 15 | 16 | __all__ = ["Action", "PathMappingRule", "HTTPClientInterface", "ClientInterface"] 17 | 18 | else: 19 | from .win_client_interface import WinClientInterface as ClientInterface # type: ignore 20 | 21 | __all__ = ["Action", "PathMappingRule", "ClientInterface"] 22 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/fake_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from openjd.adaptor_runtime.adaptors import BaseAdaptor, SemanticVersion 6 | 7 | __all__ = ["FakeAdaptor"] 8 | 9 | 10 | class FakeAdaptor(BaseAdaptor): 11 | def __init__(self, init_data: dict, **kwargs): 12 | super().__init__(init_data, **kwargs) 13 | 14 | @property 15 | def integration_data_interface_version(self) -> SemanticVersion: 16 | return SemanticVersion(major=0, minor=1) 17 | 18 | def _start(self): 19 | pass 20 | 21 | def _run(self, run_data: dict): 22 | pass 23 | 24 | def _cleanup(self): 25 | pass 26 | 27 | def _stop(self): 28 | pass 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | day: "monday" 13 | commit-message: 14 | prefix: "chore(deps):" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" # Location of package manifests 17 | schedule: 18 | interval: "weekly" 19 | day: "monday" 20 | commit-message: 21 | prefix: "chore(github):" -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from ._adaptor import Adaptor 4 | from ._adaptor_runner import AdaptorRunner 5 | from ._adaptor_states import AdaptorState 6 | from ._base_adaptor import AdaptorConfigurationOptions, BaseAdaptor 7 | from ._command_adaptor import CommandAdaptor 8 | from ._path_mapping import PathMappingRule 9 | from ._validator import AdaptorDataValidator, AdaptorDataValidators 10 | from ._versioning import SemanticVersion 11 | 12 | __all__ = [ 13 | "Adaptor", 14 | "AdaptorConfigurationOptions", 15 | "AdaptorDataValidator", 16 | "AdaptorDataValidators", 17 | "AdaptorRunner", 18 | "AdaptorState", 19 | "BaseAdaptor", 20 | "CommandAdaptor", 21 | "PathMappingRule", 22 | "SemanticVersion", 23 | ] 24 | -------------------------------------------------------------------------------- /hatch.toml: -------------------------------------------------------------------------------- 1 | [envs.default] 2 | pre-install-commands = [ 3 | "pip install -r requirements-testing.txt" 4 | ] 5 | 6 | [envs.default.scripts] 7 | sync = "pip install -r requirements-testing.txt" 8 | test = "pytest --cov-config pyproject.toml {args:test} -vv" 9 | typing = "mypy {args:src test}" 10 | style = [ 11 | "ruff check {args:.}", 12 | "black --check --diff {args:.}", 13 | ] 14 | fmt = [ 15 | "black {args:.}", 16 | "style", 17 | ] 18 | lint = [ 19 | "style", 20 | "typing", 21 | ] 22 | 23 | [[envs.all.matrix]] 24 | python = ["3.9", "3.10", "3.11"] 25 | 26 | [envs.release] 27 | detached = true 28 | 29 | [envs.release.scripts] 30 | deps = "pip install -r requirements-release.txt" 31 | bump = "semantic-release -v --strict version --no-push --no-commit --no-tag --skip-build {args}" 32 | version = "semantic-release -v --strict version --print {args}" 33 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/application_ipc/fake_app_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any as _Any 6 | from typing import Dict as _Dict 7 | from typing import Optional as _Optional 8 | 9 | from openjd.adaptor_runtime_client import ClientInterface as _ClientInterface 10 | 11 | 12 | class FakeAppClient(_ClientInterface): 13 | def __init__(self, socket_path: str) -> None: 14 | super().__init__(socket_path) 15 | self.actions.update({"hello_world": self.hello_world}) 16 | 17 | def close(self, args: _Optional[_Dict[str, _Any]]) -> None: 18 | print("closing") 19 | 20 | def hello_world(self, args: _Optional[_Dict[str, _Any]]) -> None: 21 | print(f"args = {args}") 22 | 23 | def graceful_shutdown(self): 24 | print("Gracefully shutting down.") 25 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/CommandAdaptorExample/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import logging as _logging 4 | import sys as _sys 5 | 6 | from openjd.adaptor_runtime import EntryPoint as _EntryPoint 7 | 8 | from .adaptor import CommandAdaptorExample 9 | 10 | __all__ = ["main"] 11 | _logger = _logging.getLogger(__name__) 12 | 13 | 14 | def main(): 15 | _logger.info("About to start the IntegCommandAdaptor") 16 | 17 | package_name = vars(_sys.modules[__name__])["__package__"] 18 | if not package_name: 19 | raise RuntimeError(f"Must be run as a module. Do not run {__file__} directly") 20 | 21 | try: 22 | _EntryPoint(CommandAdaptorExample).start() 23 | except Exception as e: 24 | _logger.error(f"Entrypoint failed: {e}") 25 | _sys.exit(1) 26 | 27 | _logger.info("Done IntegCommandAdaptor main") 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /.github/workflows/on_opened_pr.yml: -------------------------------------------------------------------------------- 1 | name: On Opened PR 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Record PR"] 6 | types: 7 | - completed 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | get-pr-details: 14 | permissions: 15 | actions: read # download PR artifact 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | uses: OpenJobDescription/.github/.github/workflows/reusable_extract_pr_details.yml@mainline 18 | with: 19 | record_pr_workflow_id: ${{ github.event.workflow_run.id }} 20 | artifact_name: "pr-info" 21 | workflow_origin: ${{ github.repository }} 22 | 23 | label-pr: 24 | needs: get-pr-details 25 | if: ${{ needs.get-pr-details.outputs.pr_action == 'opened' || needs.get-pr-details.outputs.pr_action == 'reopened' }} 26 | uses: OpenJobDescription/.github/.github/workflows/reusable_label_pr.yml@mainline 27 | with: 28 | pr_number: ${{ needs.get-pr-details.outputs.pr_number }} 29 | label_name: "waiting-on-maintainers" 30 | permissions: 31 | pull-requests: write -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_utils/_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | import logging 3 | import re 4 | from typing import ( 5 | List, 6 | Optional, 7 | ) 8 | 9 | 10 | class ConditionalFormatter(logging.Formatter): 11 | """ 12 | A Formatter subclass that applies formatting conditionally. 13 | """ 14 | 15 | def __init__( 16 | self, 17 | *args, 18 | ignore_patterns: Optional[List[re.Pattern[str]]], 19 | **kwargs, 20 | ): 21 | """ 22 | Args: 23 | ignore_patterns (Optional[List[re.Pattern[str]]]): List of patterns that, when matched, 24 | indicate a log message must not be formatted (it is "ignored" by the formatter) 25 | """ 26 | self._ignore_patterns = ignore_patterns or [] 27 | super().__init__(*args, **kwargs) 28 | 29 | def format(self, record: logging.LogRecord) -> str: 30 | for ignore_pattern in self._ignore_patterns: 31 | if ignore_pattern.match(record.msg): 32 | return record.getMessage() 33 | 34 | return super().format(record) 35 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/process/scripts/signals_test.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import signal 4 | import sys 5 | import time 6 | from datetime import datetime 7 | import logging 8 | from openjd.adaptor_runtime._osname import OSName 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def flush_logging(): 15 | for handler in logger.handlers: 16 | handler.flush() 17 | 18 | 19 | def func_trap(signum, frame): 20 | logger.info(f"Trapped: {signal.Signals(signum).name}") 21 | flush_logging() 22 | if exit_after_signal: 23 | exit(0) 24 | 25 | 26 | def set_signal_handlers(): 27 | signals = [signal.SIGINT, signal.SIGTERM if OSName.is_posix() else signal.SIGBREAK] # type: ignore[attr-defined] 28 | for sig in signals: 29 | signal.signal(sig, func_trap) 30 | 31 | 32 | if __name__ == "__main__": 33 | exit_after_signal = sys.argv[1] == "True" 34 | logger.info("Starting signals_test.py Script") 35 | flush_logging() 36 | set_signal_handlers() 37 | 38 | while True: 39 | logger.info(datetime.now().strftime("%Y-%m-%d_%H:%M:%S")) 40 | time.sleep(1) 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: Request a new feature 3 | title: "Feature request: (short description of the feature)" 4 | labels: ["enhancement", "needs triage"] 5 | body: 6 | - type: textarea 7 | id: problem 8 | attributes: 9 | label: Describe the problem 10 | description: | 11 | Help us understand the problem that you are trying to solve, and why it is important to you. 12 | Provide as much detail as you are able. 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: proposed_solution 18 | attributes: 19 | label: Proposed Solution 20 | description: | 21 | Describe your proposed feature that you see solving this problem for you. If you have a 22 | full or partial prototype implementation then please open a draft pull request and link to 23 | it here as well. 24 | validations: 25 | required: true 26 | 27 | - type: textarea 28 | id: use_case 29 | attributes: 30 | label: Example Use Cases 31 | description: | 32 | Provide some sample code snippets or shell scripts that show how **you** would use this feature as 33 | you have proposed it. 34 | validations: 35 | required: true 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/scripts/get_latest_changelog.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | """ 3 | This script gets the changelog notes for the latest version of this package. It makes the following assumptions 4 | 1. A file called CHANGELOG.md is in the current directory that has the changelog 5 | 2. The changelog file is formatted in a way such that level 2 headers are: 6 | a. The only indication of the beginning of a version's changelog notes. 7 | b. Always begin with `## ` 8 | 3. The changelog file contains the newest version's changelog notes at the top of the file. 9 | 10 | Example CHANGELOG.md: 11 | ``` 12 | ## 1.0.0 (2024-02-06) 13 | 14 | ### BREAKING CHANGES 15 | * **api**: rename all APIs 16 | 17 | ## 0.1.0 (2024-02-06) 18 | 19 | ### Features 20 | * **api**: add new api 21 | ``` 22 | 23 | Running this script on the above CHANGELOG.md should return the following contents: 24 | ``` 25 | ## 1.0.0 (2024-02-06) 26 | 27 | ### BREAKING CHANGES 28 | * **api**: rename all APIs 29 | 30 | ``` 31 | """ 32 | import re 33 | 34 | h2 = r"^##\s.*$" 35 | with open("CHANGELOG.md", encoding="utf-8") as f: 36 | contents = f.read() 37 | matches = re.findall(h2, contents, re.MULTILINE) 38 | changelog = contents[: contents.find(matches[1]) - 1] if len(matches) > 1 else contents 39 | print(changelog) 40 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/test_basic_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from unittest.mock import MagicMock, patch 6 | 7 | from openjd.adaptor_runtime.adaptors import CommandAdaptor, SemanticVersion 8 | from openjd.adaptor_runtime.process import ManagedProcess 9 | 10 | 11 | class FakeCommandAdaptor(CommandAdaptor): 12 | """ 13 | Test implementation of a CommandAdaptor 14 | """ 15 | 16 | def __init__(self, init_data: dict): 17 | super().__init__(init_data) 18 | 19 | def get_managed_process(self, run_data: dict) -> ManagedProcess: 20 | return MagicMock() 21 | 22 | @property 23 | def integration_data_interface_version(self) -> SemanticVersion: 24 | return SemanticVersion(major=0, minor=1) 25 | 26 | 27 | class TestRun: 28 | """ 29 | Tests for the CommandAdaptor.run method 30 | """ 31 | 32 | @patch.object(FakeCommandAdaptor, "get_managed_process") 33 | def test_runs_managed_process(self, get_managed_process_mock: MagicMock): 34 | # GIVEN 35 | run_data = {"run": "data"} 36 | adaptor = FakeCommandAdaptor({}) 37 | 38 | # WHEN 39 | adaptor._run(run_data) 40 | 41 | # THEN 42 | get_managed_process_mock.assert_called_once_with(run_data) 43 | get_managed_process_mock.return_value.run.assert_called_once() 44 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_adaptor_states.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from abc import ABC, abstractmethod 6 | from enum import Enum 7 | 8 | __all__ = [ 9 | "AdaptorState", 10 | "AdaptorStates", 11 | ] 12 | 13 | 14 | class AdaptorState(str, Enum): 15 | """ 16 | Enumeration of the different states an adaptor can be in. 17 | """ 18 | 19 | NOT_STARTED = "not_started" 20 | START = "start" 21 | RUN = "run" 22 | STOP = "stop" 23 | CLEANUP = "cleanup" 24 | CANCELED = "canceled" 25 | 26 | 27 | class AdaptorStates(ABC): 28 | """ 29 | Abstract class containing functions to transition an adaptor between states. 30 | """ 31 | 32 | @abstractmethod 33 | def _start(self): # pragma: no cover 34 | """ 35 | Starts the adaptor. 36 | """ 37 | pass 38 | 39 | @abstractmethod 40 | def _run(self, run_data: dict): # pragma: no cover 41 | """ 42 | Runs the adaptor. 43 | 44 | Args: 45 | run_data (dict): The data required to run the adaptor. 46 | """ 47 | pass 48 | 49 | @abstractmethod 50 | def _stop(self): # pragma: no cover 51 | """ 52 | Stops the adaptor run. 53 | """ 54 | pass 55 | 56 | @abstractmethod 57 | def _cleanup(self): # pragma: no cover 58 | """ 59 | Performs any cleanup the adaptor may need. 60 | """ 61 | pass 62 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes: ** 2 | 3 | ### What was the problem/requirement? (What/Why) 4 | 5 | ### What was the solution? (How) 6 | 7 | ### What is the impact of this change? 8 | 9 | ### How was this change tested? 10 | 11 | See [DEVELOPMENT.md](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/blob/mainline/DEVELOPMENT.md#testing) for information on running tests. 12 | 13 | - Have you run the unit tests? 14 | 15 | ### Was this change documented? 16 | 17 | - Are relevant docstrings in the code base updated? 18 | 19 | ### Is this a breaking change? 20 | 21 | A breaking change is one that modifies a public contract in a way that is not backwards compatible. See the 22 | [Public Interfaces](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/blob/mainline/DEVELOPMENT.md#the-packages-public-interface) section 23 | of the DEVELOPMENT.md for more information on the public contracts. 24 | 25 | If so, then please describe the changes that users of this package must make to update their scripts, or Python applications. 26 | 27 | ### Does this change impact security? 28 | 29 | - Does the change need to be threat modeled? For example, does it create or modify files/directories that must only be readable by the process owner? 30 | - If so, then please label this pull request with the "security" label. We'll work with you to analyze the threats. 31 | 32 | ---- 33 | 34 | *By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.* -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/integ/fake_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from argparse import ArgumentParser as _ArgumentParser 6 | from signal import Signals 7 | from time import sleep as _sleep 8 | from types import FrameType as _FrameType 9 | from threading import Thread as _Thread 10 | from typing import Any as _Any 11 | from typing import Dict as _Dict 12 | from typing import Optional as _Optional 13 | 14 | from openjd.adaptor_runtime_client import ClientInterface as _ClientInterface 15 | 16 | 17 | class FakeClient(_ClientInterface): 18 | shutdown: bool 19 | 20 | def __init__(self, port: str) -> None: 21 | super().__init__(port) 22 | self.shutdown = False 23 | 24 | def close(self, args: _Optional[_Dict[str, _Any]]) -> None: 25 | print("closing") 26 | 27 | def graceful_shutdown(self, signum: int, frame: _Optional[_FrameType]) -> None: 28 | print(f"Received {Signals(signum).name} signal.") 29 | self.shutdown = True 30 | 31 | def run(self): 32 | count = 0 33 | while not self.shutdown: 34 | _sleep(0.25) 35 | count += 1 36 | 37 | 38 | def run_client(): 39 | test_client = FakeClient("1234") 40 | test_client.run() 41 | 42 | 43 | if __name__ == "__main__": 44 | parser = _ArgumentParser() 45 | parser.add_argument("--run-in-thread", action="store_true") 46 | args = parser.parse_args() 47 | 48 | if args.run_in_thread: 49 | threaded_client = _Thread(target=run_client) 50 | threaded_client.start() 51 | else: 52 | run_client() 53 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_actions_queue.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from collections import deque 6 | from typing import TYPE_CHECKING 7 | from typing import Deque 8 | from typing import Optional 9 | 10 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 11 | from openjd.adaptor_runtime_client import Action 12 | 13 | 14 | class ActionsQueue: 15 | """This class will manage the Queue of Actions. This class will be responsible for 16 | enqueueing, or dequeueing Actions, and converting actions to and from json strings.""" 17 | 18 | _actions_queue: Deque[Action] 19 | 20 | def __init__(self) -> None: 21 | self._actions_queue = deque() 22 | 23 | def enqueue_action(self, a: Action, front: bool = False) -> None: 24 | """This function will enqueue the action to the end of the queue. 25 | 26 | Args: 27 | a (Action): The action to be enqueued. 28 | front (bool, optional): Whether we want to append to the front of the queue. 29 | Defaults to False. 30 | """ 31 | if front: 32 | self._actions_queue.appendleft(a) 33 | else: 34 | self._actions_queue.append(a) 35 | 36 | def dequeue_action(self) -> Optional[Action]: 37 | if len(self) > 0: 38 | return self._actions_queue.popleft() 39 | else: 40 | return None 41 | 42 | def __bool__(self) -> bool: 43 | return bool(self._actions_queue) 44 | 45 | def __len__(self) -> int: 46 | return len(self._actions_queue) 47 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/action.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import json as _json 6 | import sys as _sys 7 | from dataclasses import asdict as _asdict 8 | from dataclasses import dataclass as _dataclass 9 | from typing import Any as _Any 10 | from typing import Dict as _Dict 11 | from typing import Optional as _Optional 12 | 13 | 14 | @_dataclass(frozen=True) 15 | class Action: 16 | """This is the class representation of the Actions to be performed on the DCC.""" 17 | 18 | name: str 19 | args: _Optional[_Dict[str, _Any]] = None 20 | 21 | def __str__(self) -> str: 22 | return _json.dumps(_asdict(self)) 23 | 24 | @staticmethod 25 | def from_json_string(json_str: str) -> _Optional[Action]: 26 | try: 27 | ad = _json.loads(json_str) 28 | except Exception as e: 29 | print( 30 | f'ERROR: Unable to convert "{json_str}" to json. The following exception was ' 31 | f"raised:\n{e}", 32 | file=_sys.stderr, 33 | flush=True, 34 | ) 35 | return None 36 | 37 | try: 38 | return Action(ad["name"], ad["args"]) 39 | except Exception as e: 40 | print( 41 | f"ERROR: Unable to convert the json dictionary ({ad}) to an action. The following " 42 | f"exception was raised:\n{e}", 43 | file=_sys.stderr, 44 | flush=True, 45 | ) 46 | return None 47 | 48 | @staticmethod 49 | def from_bytes(s: bytes) -> _Optional[Action]: 50 | return Action.from_json_string(s.decode()) 51 | -------------------------------------------------------------------------------- /.semantic_release/CHANGELOG.md.j2: -------------------------------------------------------------------------------- 1 | {% for version, release in context.history.released.items() %} 2 | ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) 3 | 4 | {% if "breaking" in release["elements"] %} 5 | ### BREAKING CHANGES 6 | {% for commit in release["elements"]["breaking"] %} 7 | * {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.commit.summary[commit.commit.summary.find(": ")+1:].strip() }} ([`{{ commit.short_hash }}`]({{ commit.commit.hexsha | commit_hash_url }})) 8 | {% endfor %} 9 | {% endif %} 10 | 11 | {% if "features" in release["elements"] %} 12 | ### Features 13 | {% for commit in release["elements"]["features"] %} 14 | * {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.commit.summary[commit.commit.summary.find(": ")+1:].strip() }} ([`{{ commit.short_hash }}`]({{ commit.commit.hexsha | commit_hash_url }})) 15 | {% endfor %} 16 | {% endif %} 17 | 18 | {% if "bug fixes" in release["elements"] %} 19 | ### Bug Fixes 20 | {% for commit in release["elements"]["bug fixes"] %} 21 | * {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.commit.summary[commit.commit.summary.find(":")+1:].strip() }} ([`{{ commit.short_hash }}`]({{ commit.commit.hexsha | commit_hash_url }})) 22 | {% endfor %} 23 | {% endif %} 24 | 25 | {% if "performance improvements" in release["elements"] %} 26 | ### Performance Improvements 27 | {% for commit in release["elements"]["performance improvements"] %} 28 | * {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.commit.summary[commit.commit.summary.find(":")+1:].strip() }} ([`{{ commit.short_hash }}`]({{ commit.commit.hexsha | commit_hash_url }})) 29 | {% endfor %} 30 | {% endif %} 31 | 32 | {% endfor %} -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/utils/test_logging.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import logging 4 | import re 5 | from typing import List 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | 10 | import openjd.adaptor_runtime._utils._logging as logging_mod 11 | from openjd.adaptor_runtime._utils._logging import ConditionalFormatter 12 | 13 | 14 | class TestConditionalFormatter: 15 | @pytest.mark.parametrize( 16 | ["patterns", "message", "should_be_ignored"], 17 | [ 18 | [ 19 | [ 20 | re.compile(r"^IGNORE:"), 21 | ], 22 | "IGNORE: This should be ignored", 23 | True, 24 | ], 25 | [ 26 | [re.compile(r"^IGNORE:"), re.compile(r"^IGNORE_TWO:")], 27 | "IGNORE_TWO: This should also be ignored", 28 | True, 29 | ], 30 | [ 31 | [ 32 | re.compile(r"^IGNORE:"), 33 | ], 34 | "INFO: This should not be ignored", 35 | False, 36 | ], 37 | ], 38 | ) 39 | def test_ignores_patterns( 40 | self, 41 | patterns: List[re.Pattern[str]], 42 | message: str, 43 | should_be_ignored: bool, 44 | ) -> None: 45 | # GIVEN 46 | record = logging.LogRecord("NAME", 0, "", 0, message, None, None) 47 | formatter = ConditionalFormatter(ignore_patterns=patterns) 48 | 49 | # WHEN 50 | with patch.object(logging_mod.logging.Formatter, "format") as mock_format: 51 | formatter.format(record) 52 | 53 | # THEN 54 | if should_be_ignored: 55 | mock_format.assert_not_called() 56 | else: 57 | mock_format.assert_called_once_with(record) 58 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/win_client_interface.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from .base_client_interface import Response as _Response 6 | import http.client 7 | import signal as _signal 8 | 9 | 10 | from .base_client_interface import BaseClientInterface 11 | 12 | from .named_pipe.named_pipe_helper import NamedPipeHelper 13 | 14 | # Set timeout to None so our requests are blocking calls with no timeout. 15 | _REQUEST_TIMEOUT = None 16 | 17 | 18 | class WinClientInterface(BaseClientInterface): 19 | def __init__(self, server_path: str) -> None: 20 | """When the client is created, we need the port number to connect to the server. 21 | 22 | Args: 23 | server_path (str): Used as pipe name in Named Pipe Server. 24 | """ 25 | super().__init__(server_path) 26 | try: 27 | _signal.signal(_signal.SIGBREAK, self.graceful_shutdown) # type: ignore[attr-defined] 28 | except ValueError: 29 | pass 30 | 31 | def _send_request( 32 | self, 33 | method: str, 34 | path: str, 35 | *, 36 | query_string_params: dict | None = None, 37 | ): 38 | if query_string_params: 39 | # This is used for aligning to the Linux's behavior in order to reuse the code in handler. 40 | # In linux, query string params will always be put in a list. 41 | query_string_params = {key: [value] for key, value in query_string_params.items()} 42 | json_result = NamedPipeHelper.send_named_pipe_request( 43 | self.server_path, 44 | _REQUEST_TIMEOUT, 45 | method, 46 | path, 47 | params=query_string_params, 48 | ) 49 | return _Response( 50 | json_result["status"], 51 | json_result["body"], 52 | http.client.responses[json_result["status"]], 53 | len(json_result["body"]), 54 | ) 55 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/background/test_model.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import dataclasses 4 | 5 | from enum import Enum 6 | 7 | import pytest 8 | 9 | from openjd.adaptor_runtime._background.model import DataclassMapper 10 | 11 | 12 | class StrEnum(str, Enum): 13 | TEST = "test_str" 14 | TEST_UNI = "test_☃" 15 | 16 | 17 | class NormalEnum(Enum): 18 | ONE = 1 19 | 20 | 21 | # Define two dataclasses to use for tests 22 | @dataclasses.dataclass 23 | class Inner: 24 | key: str 25 | 26 | 27 | @dataclasses.dataclass 28 | class Outer: 29 | outer_key: str 30 | inner: Inner 31 | test_str: StrEnum 32 | test_str_unicode: StrEnum 33 | normal_enum: NormalEnum 34 | 35 | 36 | class TestDataclassMapper: 37 | """ 38 | Tests for the DataclassMapper class 39 | """ 40 | 41 | def test_maps_nested_dataclass(self): 42 | # GIVEN 43 | input = { 44 | "outer_key": "outer_value", 45 | "inner": { 46 | "key": "value", 47 | }, 48 | "test_str": "test_str", 49 | "test_str_unicode": "test_☃", 50 | "normal_enum": 1, 51 | } 52 | mapper = DataclassMapper(Outer) 53 | 54 | # WHEN 55 | result = mapper.map(input) 56 | 57 | # THEN 58 | expected_dataclass = Outer( 59 | outer_key="outer_value", 60 | inner=Inner(key="value"), 61 | test_str=StrEnum.TEST, 62 | test_str_unicode=StrEnum.TEST_UNI, 63 | normal_enum=NormalEnum.ONE, 64 | ) 65 | assert result == expected_dataclass 66 | 67 | def test_raises_when_field_is_missing(self): 68 | # GIVEN 69 | input = {"outer_key": "outer_value"} 70 | mapper = DataclassMapper(Outer) 71 | 72 | # WHEN 73 | with pytest.raises(ValueError) as raised_err: 74 | mapper.map(input) 75 | 76 | # THEN 77 | assert raised_err.match("Dataclass field inner not found in dict " + str(input)) 78 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_adaptor_server.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import warnings 7 | 8 | from socketserver import UnixStreamServer # type: ignore[attr-defined] 9 | from typing import TYPE_CHECKING 10 | 11 | from .._http import SocketPaths 12 | from ._http_request_handler import AdaptorHTTPRequestHandler 13 | 14 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 15 | from ..adaptors import BaseAdaptor 16 | from ._actions_queue import ActionsQueue 17 | 18 | SOCKET_PATH_DUPLICATED_MESSAGE = ( 19 | "The 'socket_path' parameter is deprecated; use 'server_path' instead" 20 | ) 21 | 22 | 23 | class AdaptorServer(UnixStreamServer): 24 | """ 25 | This is the Adaptor server which will be passed the populated ActionsQueue from the Adaptor. 26 | """ 27 | 28 | actions_queue: ActionsQueue 29 | adaptor: BaseAdaptor 30 | 31 | def __init__( 32 | self, 33 | actions_queue: ActionsQueue, 34 | adaptor: BaseAdaptor, 35 | ) -> None: # pragma: no cover 36 | socket_path = SocketPaths.for_os().get_process_socket_path( 37 | ".openjd_adaptor_server", 38 | create_dir=True, 39 | ) 40 | super().__init__(socket_path, AdaptorHTTPRequestHandler) 41 | 42 | self.actions_queue = actions_queue 43 | self.adaptor = adaptor 44 | self.server_path = socket_path 45 | 46 | @property 47 | def socket_path(self): 48 | warnings.warn(SOCKET_PATH_DUPLICATED_MESSAGE, DeprecationWarning) 49 | return self.server_path 50 | 51 | @socket_path.setter 52 | def socket_path(self, value): 53 | warnings.warn(SOCKET_PATH_DUPLICATED_MESSAGE, DeprecationWarning) 54 | self.server_path = value 55 | 56 | def shutdown(self) -> None: # pragma: no cover 57 | super().shutdown() 58 | 59 | try: 60 | os.remove(self.socket_path) 61 | except FileNotFoundError: 62 | pass 63 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_command_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from abc import abstractmethod 6 | from typing import TypeVar 7 | 8 | from .configuration import AdaptorConfiguration 9 | from ..process import ManagedProcess 10 | from ._base_adaptor import BaseAdaptor 11 | 12 | __all__ = [ 13 | "CommandAdaptor", 14 | ] 15 | 16 | _T = TypeVar("_T", bound=AdaptorConfiguration) 17 | 18 | 19 | class CommandAdaptor(BaseAdaptor[_T]): 20 | """ 21 | Base class for command adaptors that utilize a ManagedProcess. 22 | 23 | Derived classes must override the get_managed_process method, and 24 | may optionally override the on_prerun and on_postrun methods. 25 | """ 26 | 27 | def _start(self): # pragma: no cover 28 | pass 29 | 30 | def _run(self, run_data: dict): 31 | process = self.get_managed_process(run_data) 32 | 33 | self.on_prerun() 34 | process.run() 35 | self.on_postrun() 36 | 37 | def _stop(self): # pragma: no cover 38 | pass 39 | 40 | def _cleanup(self): # pragma: no cover 41 | pass 42 | 43 | @abstractmethod 44 | def get_managed_process(self, run_data: dict) -> ManagedProcess: # pragma: no cover 45 | """ 46 | Gets the ManagedProcess for this adaptor to run. 47 | 48 | Args: 49 | run_data (dict): The data required by the ManagedProcess. 50 | 51 | Returns: 52 | ManagedProcess: The ManagedProcess to run. 53 | """ 54 | pass 55 | 56 | def on_prerun(self): # pragma: no cover 57 | """ 58 | Method that is invoked before the ManagedProcess is run. 59 | You can override this method to run code before the ManagedProcess is run. 60 | """ 61 | pass 62 | 63 | def on_postrun(self): # pragma: no cover 64 | """ 65 | Method that is invoked after the ManagedProcess is run. 66 | You can override this method to run code after the ManagedProcess is run. 67 | """ 68 | pass 69 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_background/backend_named_pipe_server.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | import logging 5 | 6 | from threading import Event 7 | from typing import cast 8 | 9 | from pywintypes import HANDLE 10 | 11 | 12 | from .background_named_pipe_request_handler import WinBackgroundResourceRequestHandler 13 | from .server_response import AsyncFutureRunner 14 | from .._named_pipe import NamedPipeServer 15 | 16 | 17 | from ..adaptors import AdaptorRunner 18 | from .log_buffers import LogBuffer 19 | 20 | 21 | _logger = logging.getLogger(__name__) 22 | 23 | 24 | class WinBackgroundNamedPipeServer(NamedPipeServer): 25 | """ 26 | A class to manage a Windows Named Pipe Server in background mode for the adaptor runtime communication. 27 | 28 | This class encapsulates stateful information of the adaptor backend and provides methods 29 | for server initialization, operation, and shutdown. 30 | """ 31 | 32 | def __init__( 33 | self, 34 | pipe_name: str, 35 | adaptor_runner: AdaptorRunner, 36 | shutdown_event: Event, 37 | *, 38 | log_buffer: LogBuffer | None = None, 39 | ): # pragma: no cover 40 | """ 41 | Args: 42 | pipe_name (str): Name of the pipe for the NamedPipe Server. 43 | adaptor_runner (AdaptorRunner): Adaptor runner instance for operation execution. 44 | shutdown_event (Event): An Event used for signaling server shutdown. 45 | log_buffer (LogBuffer|None, optional): Buffer for logging activities, defaults to None. 46 | """ 47 | super().__init__(pipe_name, shutdown_event) 48 | self._adaptor_runner = adaptor_runner 49 | self._shutdown_event = shutdown_event 50 | self._future_runner = AsyncFutureRunner() 51 | self._log_buffer = log_buffer 52 | 53 | def request_handler(self, server: "NamedPipeServer", pipe_handle: HANDLE): 54 | return WinBackgroundResourceRequestHandler( 55 | cast("WinBackgroundNamedPipeServer", server), pipe_handle 56 | ) 57 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | """ 3 | This module contains the configuration classes used for the adaptor runtime and the adaptors 4 | themselves. 5 | 6 | The base Configuration class exposes a "config" property that returns a dictionary of the loaded 7 | config values with defaults injected where applicable. To do this, the following pattern is used: 8 | 9 | 1. The "config" property obtains a function with a "registry" attribute that maps function names to 10 | functions from the virtual class method "_get_defaults_decorator". 11 | 2. For each key in the "registry" that is not in the loaded configuration, the corresponding 12 | function is invoked to obtain the "default" value to inject into the returned configuration. 13 | 14 | By default, "_get_defaults_decorator" returns a no-op decorator that has an empty registry. 15 | Classes that derive the base Configuration class can override this class method to return a 16 | decorator created by the "_make_function_register_decorator" function. This decorator actually 17 | registers functions it is applied to, so it can be used to mark properties that should have default 18 | values injected if none are loaded. For example, the following subclass uses this pattern to mark 19 | the "my_config_key" property as one that uses a default value: 20 | 21 | Note: The property name must match the corresponding key in the configuration dictionary. 22 | 23 | class MyConfiguration(Configuration): 24 | 25 | _defaults = _make_function_register_decorator() 26 | 27 | @classmethod 28 | def _get_defaults_decorator(cls) -> Any: 29 | return cls._defaults 30 | 31 | @property 32 | @_defaults 33 | def my_config_key(self) -> str: 34 | return self._config.get("my_config_key", "default_value") 35 | """ 36 | 37 | from ._configuration import ( 38 | AdaptorConfiguration, 39 | Configuration, 40 | RuntimeConfiguration, 41 | ) 42 | from ._configuration_manager import ConfigurationManager 43 | 44 | __all__ = ["AdaptorConfiguration", "Configuration", "ConfigurationManager", "RuntimeConfiguration"] 45 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/configuration/stubs.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import Literal 6 | 7 | from openjd.adaptor_runtime.adaptors.configuration import ( 8 | AdaptorConfiguration, 9 | Configuration, 10 | ConfigurationManager, 11 | RuntimeConfiguration, 12 | ) 13 | 14 | 15 | class RuntimeConfigurationStub(RuntimeConfiguration): 16 | """ 17 | Stub implementation of RuntimeConfiguration 18 | """ 19 | 20 | def __init__(self) -> None: 21 | super().__init__({}) 22 | 23 | @property 24 | def log_level(self) -> Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: 25 | return "DEBUG" 26 | 27 | @property 28 | def deactivate_telemetry(self) -> bool: 29 | return True 30 | 31 | @property 32 | def plugin_configuration(self) -> dict | None: 33 | return None 34 | 35 | 36 | class AdaptorConfigurationStub(AdaptorConfiguration): 37 | """ 38 | Stub implementation of AdaptorConfiguration 39 | """ 40 | 41 | def __init__(self) -> None: 42 | super().__init__({}) 43 | 44 | @property 45 | def log_level( 46 | self, 47 | ) -> Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None: 48 | return "DEBUG" 49 | 50 | 51 | class ConfigurationManagerMock(ConfigurationManager): 52 | """ 53 | Mock implementation of ConfigurationManager with empty defaults. 54 | """ 55 | 56 | def __init__( 57 | self, 58 | *, 59 | schema_path="", 60 | default_config_path="", 61 | system_config_path="", 62 | user_config_rel_path="", 63 | additional_config_paths=None, 64 | ) -> None: 65 | if additional_config_paths is None: 66 | additional_config_paths = [] 67 | super().__init__( 68 | config_cls=Configuration, 69 | schema_path=schema_path, 70 | default_config_path=default_config_path, 71 | system_config_path=system_config_path, 72 | user_config_rel_path=user_config_rel_path, 73 | additional_config_paths=additional_config_paths, 74 | ) 75 | -------------------------------------------------------------------------------- /scripts/add_copyright_headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | if [ $# -eq 0 ]; then 5 | echo "Usage: add-copyright-headers ..." >&2 6 | exit 1 7 | fi 8 | 9 | for file in "$@"; do 10 | if ! head -1 | grep 'Copyright ' "$file" >/dev/null; then 11 | case "$file" in 12 | *.java) 13 | CONTENT=$(cat "$file") 14 | cat > "$file" </dev/null; then 23 | CONTENT=$(tail -n +2 "$file") 24 | cat > "$file" < 27 | $CONTENT 28 | EOF 29 | else 30 | CONTENT=$(cat "$file") 31 | cat > "$file" < 33 | $CONTENT 34 | EOF 35 | fi 36 | ;; 37 | *.py) 38 | CONTENT=$(cat "$file") 39 | cat > "$file" < "$file" < "$file" < "$file" <&2 71 | exit 1 72 | ;; 73 | esac 74 | fi 75 | done -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from abc import abstractmethod 4 | from typing import TypeVar 5 | 6 | from .configuration import AdaptorConfiguration 7 | from ._base_adaptor import BaseAdaptor 8 | 9 | __all__ = ["Adaptor"] 10 | 11 | _T = TypeVar("_T", bound=AdaptorConfiguration) 12 | 13 | 14 | class Adaptor(BaseAdaptor[_T]): 15 | """An Adaptor. 16 | 17 | Derived classes must override the on_run method, and may also optionally 18 | override the on_start, on_end, on_cleanup, and on_cancel methods. 19 | """ 20 | 21 | # =============================================== 22 | # Callbacks / virtual functions. 23 | # =============================================== 24 | 25 | def on_start(self): # pragma: no cover 26 | """ 27 | For job stickiness. Will start everything required for the Task. Will be used for all 28 | SubTasks. 29 | """ 30 | pass 31 | 32 | @abstractmethod 33 | def on_run(self, run_data: dict): # pragma: no cover 34 | """ 35 | This will run for every task and will setup everything needed to render (including calling 36 | any managed processes). This will be overridden and defined in each advanced plugin. 37 | """ 38 | pass 39 | 40 | def on_stop(self): # pragma: no cover 41 | """ 42 | For job stickiness. Will stop everything required for the Task before moving on to a new 43 | Task. 44 | """ 45 | pass 46 | 47 | def on_cleanup(self): # pragma: no cover 48 | """ 49 | This callback will be any additional cleanup required by the adaptor. 50 | """ 51 | pass 52 | 53 | # =============================================== 54 | # =============================================== 55 | 56 | def _start(self): # pragma: no cover 57 | self.on_start() 58 | 59 | def _run(self, run_data: dict): 60 | """ 61 | :param run_data: This is the data that changes between the different Tasks. Eg. frame 62 | number. 63 | """ 64 | self.on_run(run_data) 65 | 66 | def _stop(self): # pragma: no cover 67 | self.on_stop() 68 | 69 | def _cleanup(self): # pragma: no cover 70 | self.on_cleanup() 71 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/AdaptorExample/adaptor_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from typing import TYPE_CHECKING 7 | 8 | from openjd.adaptor_runtime_client import ClientInterface 9 | 10 | if TYPE_CHECKING: 11 | from types import FrameType 12 | from typing import Any 13 | 14 | 15 | class AdaptorClient(ClientInterface): 16 | """ 17 | This class uses the Adaptor Runtime's Python implementation for a client that can request and handle 18 | actions from the server in adaptor_example.py. 19 | 20 | In this case, the contract between the server in AdaptorExample.py and this client consists of a single 21 | "print" command, in addition to the built-in "close" command. 22 | """ 23 | 24 | def __init__(self, server_path: str) -> None: 25 | super().__init__(server_path) 26 | # All customized actions needed to be put in this dict 27 | # There is a key value pair that is pre-defined in this dict: {"close": self.close} 28 | self.actions.update( 29 | { 30 | "print": self.print, 31 | } 32 | ) 33 | 34 | def print(self, args: dict[str, Any] | None) -> None: 35 | """ 36 | This function prints a message fetched from an action queue when `print` action is fetched from the action queue. 37 | """ 38 | if args is None: 39 | print("App: 'args' in print action is None", flush=True) 40 | else: 41 | print(f"App: {args.get('message')}", flush=True) 42 | 43 | def close(self, args: dict[str, Any] | None) -> None: 44 | print("'close' function is called", flush=True) 45 | 46 | def graceful_shutdown(self, signum: int, frame: FrameType | None) -> None: 47 | """ 48 | This function will be called when the application got the SIGTERM in the Linux or SIGBREAK in Windows. 49 | """ 50 | print(f"received signal: {signum}\ngracefully shutting down", flush=True) 51 | 52 | 53 | def main(): 54 | if len(sys.argv) < 2: 55 | print("Argument for server path required, but no arguments were passed") 56 | sys.exit(1) 57 | 58 | client = AdaptorClient(str(sys.argv[1])) 59 | client.poll() 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_win_adaptor_server.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from threading import Event 6 | from pywintypes import HANDLE 7 | 8 | from ._named_pipe_request_handler import WinAdaptorServerResourceRequestHandler 9 | from .._named_pipe import ResourceRequestHandler 10 | from ...adaptor_runtime_client.named_pipe.named_pipe_helper import NamedPipeHelper 11 | from .._named_pipe.named_pipe_server import NamedPipeServer 12 | 13 | 14 | from typing import TYPE_CHECKING, cast 15 | 16 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 17 | from ..adaptors import BaseAdaptor 18 | from ._actions_queue import ActionsQueue 19 | 20 | 21 | class WinAdaptorServer(NamedPipeServer): 22 | """ 23 | This is the Adaptor server which will be passed the populated ActionsQueue from the Adaptor. 24 | """ 25 | 26 | actions_queue: ActionsQueue 27 | adaptor: BaseAdaptor 28 | 29 | def __init__( 30 | self, 31 | actions_queue: ActionsQueue, 32 | adaptor: BaseAdaptor, 33 | ) -> None: 34 | """ 35 | Adaptor Server class in Windows. 36 | 37 | Args: 38 | actions_queue: A queue used for storing all actions sent by the client. 39 | adaptor: The adaptor class used for reacting to the request. 40 | """ 41 | self.server_path = NamedPipeHelper.generate_pipe_name("AdaptorServerNamedPipe") 42 | 43 | shutdown_event = Event() 44 | super().__init__(self.server_path, shutdown_event) 45 | 46 | self.actions_queue = actions_queue 47 | self.adaptor = adaptor 48 | 49 | def request_handler( 50 | self, server: NamedPipeServer, pipe_handle: HANDLE 51 | ) -> ResourceRequestHandler: 52 | """ 53 | Initializes the handler for handling the request from the client. 54 | 55 | Args: 56 | server: The NamedPipeServer that maintains the lifecycle of all resources. 57 | pipe_handle: The pip handle used for communication between client and server. 58 | 59 | Returns: 60 | ResourceRequestHandler: The Handler that handle the request. 61 | """ 62 | return WinAdaptorServerResourceRequestHandler(cast("WinAdaptorServer", server), pipe_handle) 63 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/process/_stream_logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | """Module for the StreamLogger class""" 4 | from __future__ import annotations 5 | 6 | import logging 7 | import os 8 | from threading import Thread 9 | from typing import IO, Sequence 10 | 11 | 12 | class StreamLogger(Thread): 13 | """A thread that reads a text stream line-by-line and logs each line to a specified logger""" 14 | 15 | def __init__( 16 | self, 17 | *args, 18 | # Required keyword-only arguments 19 | stream: IO[str], 20 | loggers: Sequence[logging.Logger], 21 | # Optional keyword-only arguments 22 | level: int = logging.INFO, 23 | **kwargs, 24 | ): 25 | super(StreamLogger, self).__init__(*args, **kwargs) 26 | self._stream = stream 27 | self._loggers = list(loggers) 28 | self._level = level 29 | 30 | # Without setting daemon to False, we run into an issue in which all output may NOT be 31 | # printed. From the python docs: 32 | # > The entire Python program exits when no alive non-daemon threads are left. 33 | # Reference: https://docs.python.org/3/library/threading.html#threading.Thread.daemon 34 | self.daemon = False 35 | 36 | def _log(self, line: str, level: int | None = None): 37 | """ 38 | Logs a line to each logger at the provided level or self._level is no level is provided. 39 | Args: 40 | line (str): The line to log 41 | level (int): The level to log the line at 42 | """ 43 | if level is None: 44 | level = self._level 45 | 46 | for logger in self._loggers: 47 | logger.log(level, line) 48 | 49 | def run(self): 50 | try: 51 | for line in iter(self._stream.readline, ""): 52 | line = line.rstrip(os.linesep) 53 | self._log(line) 54 | except ValueError as e: 55 | if "I/O operation on closed file" in str(e): 56 | self._log( 57 | "The StreamLogger could not read from the stream. This is most likely because " 58 | "the stream was closed before the stream logger.", 59 | logging.WARNING, 60 | ) 61 | else: 62 | raise 63 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/test_versioning.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | from __future__ import annotations 3 | 4 | import pytest 5 | from openjd.adaptor_runtime.adaptors import SemanticVersion 6 | 7 | 8 | class TestSemanticVersion: 9 | @pytest.mark.parametrize( 10 | ("version_a", "version_b", "expected_result"), 11 | [ 12 | (SemanticVersion(0, 1), SemanticVersion(0, 2), False), 13 | (SemanticVersion(0, 1), SemanticVersion(0, 1), True), 14 | (SemanticVersion(0, 1), SemanticVersion(0, 0), False), 15 | (SemanticVersion(1, 5), SemanticVersion(1, 4), True), 16 | (SemanticVersion(1, 5), SemanticVersion(1, 5), True), 17 | (SemanticVersion(1, 5), SemanticVersion(1, 6), False), 18 | (SemanticVersion(1, 5), SemanticVersion(2, 0), False), 19 | (SemanticVersion(1, 5), SemanticVersion(2, 5), False), 20 | (SemanticVersion(1, 5), SemanticVersion(2, 6), False), 21 | ], 22 | ) 23 | def test_has_compatibility_with( 24 | self, version_a: SemanticVersion, version_b: SemanticVersion, expected_result: bool 25 | ): 26 | # WHEN 27 | result = version_a.has_compatibility_with(version_b) 28 | 29 | # THEN 30 | assert result == expected_result 31 | 32 | @pytest.mark.parametrize( 33 | ("version_str", "expected_result"), 34 | [ 35 | ("1.0.0", ValueError), 36 | ("1.zero", ValueError), 37 | ("three.five", ValueError), 38 | ("1. 5", ValueError), 39 | (" 1.5", ValueError), 40 | ("a version", ValueError), 41 | ("-1.5", ValueError), 42 | ("1.-5", ValueError), 43 | ("-1.-5", ValueError), 44 | ("1.5", SemanticVersion(1, 5)), 45 | ("10.50", SemanticVersion(10, 50)), 46 | ], 47 | ) 48 | def test_parse(self, version_str: str, expected_result: SemanticVersion | ValueError): 49 | if expected_result is ValueError: 50 | # WHEN/THEN 51 | with pytest.raises(ValueError): 52 | SemanticVersion.parse(version_str) 53 | else: 54 | # WHEN 55 | result = SemanticVersion.parse(version_str) 56 | 57 | # THEN 58 | assert result == expected_result 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: Report a bug 3 | title: "Bug: (short bug description)" 4 | labels: ["bug", "needs triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to fill out this bug report! 10 | 11 | ⚠️ If the bug that you are reporting is a security-related issue or security vulnerability, 12 | then please do not create a report via this template. Instead please 13 | notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) 14 | or directly via email to [AWS Security](aws-security@amazon.com). 15 | 16 | - type: textarea 17 | id: description 18 | attributes: 19 | label: Describe the bug 20 | description: What is the problem? A clear and concise description of the bug. 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: expected_behaviour 26 | attributes: 27 | label: Expected Behaviour 28 | description: What did you expect to happen? 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: current_behaviour 34 | attributes: 35 | label: Current Behaviour 36 | description: What actually happened? Please include as much detail as you can. 37 | validations: 38 | required: true 39 | 40 | - type: textarea 41 | id: reproduction_steps 42 | attributes: 43 | label: Reproduction Steps 44 | description: | 45 | Please provide as much detail as you can to help us understand how we can reproduce the bug. 46 | Step by step instructions and self-contained code snippets are ideal. 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: environment 52 | attributes: 53 | label: Environment 54 | description: Please provide information on the environment and software versions that you are using to reproduce the bug. 55 | value: | 56 | At minimum: 57 | 1. Operating system (e.g. Windows Server 2022; Amazon Linux 2023; etc.) 58 | 2. Output of `python3 --version` 59 | 3. Version of this library 60 | 61 | Please share other details about your environment that you think might be relevant to reproducing the bug. 62 | validations: 63 | required: true 64 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/process/_managed_process.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | """Module for the ManagedProcess class""" 4 | from __future__ import annotations 5 | 6 | from abc import ABC as ABC, abstractmethod 7 | from typing import List 8 | 9 | from ..app_handlers import RegexHandler 10 | from ._logging_subprocess import LoggingSubprocess 11 | 12 | __all__ = ["ManagedProcess"] 13 | 14 | 15 | class ManagedProcess(ABC): 16 | def __init__( 17 | self, 18 | run_data: dict, 19 | *, 20 | stdout_handler: RegexHandler | None = None, 21 | stderr_handler: RegexHandler | None = None, 22 | ): 23 | self.run_data = run_data 24 | self.stdout_handler = stdout_handler 25 | self.stderr_handler = stderr_handler 26 | 27 | # =============================================== 28 | # Callbacks / virtual functions. 29 | # =============================================== 30 | 31 | @abstractmethod 32 | def get_executable(self) -> str: # pragma: no cover 33 | """ 34 | Return the path of the executable to run. 35 | """ 36 | raise NotImplementedError() 37 | 38 | def get_arguments(self) -> List[str]: # pragma: no cover 39 | """ 40 | Return the args (as a list) to be used with the executable. 41 | """ 42 | return [] 43 | 44 | def get_startup_directory(self) -> str | None: # pragma: no cover 45 | """ 46 | Returns The directory that the executable should be run from. 47 | Note: Does not require that spaces be escaped 48 | """ 49 | # This defaults to None because that is the default for Popen. 50 | return None 51 | 52 | # =============================================== 53 | # Render Control 54 | # =============================================== 55 | 56 | def run(self): 57 | """ 58 | Create a LoggingSubprocess to run the command. 59 | """ 60 | exec = self.get_executable() 61 | args = self.get_arguments() 62 | args = [exec] + args 63 | startup_directory = self.get_startup_directory() 64 | 65 | subproc = LoggingSubprocess( 66 | args=args, 67 | startup_directory=startup_directory, 68 | stdout_handler=self.stdout_handler, 69 | stderr_handler=self.stderr_handler, 70 | ) 71 | subproc.wait() 72 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/adaptors/test_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | from unittest.mock import Mock, patch 6 | 7 | from openjd.adaptor_runtime.adaptors import Adaptor, SemanticVersion 8 | 9 | 10 | class FakeAdaptor(Adaptor): 11 | """ 12 | Test implementation of a Adaptor 13 | """ 14 | 15 | def __init__(self, init_data: dict): 16 | super().__init__(init_data) 17 | 18 | def on_run(self, run_data: dict): 19 | pass 20 | 21 | @property 22 | def integration_data_interface_version(self) -> SemanticVersion: 23 | return SemanticVersion(major=0, minor=1) 24 | 25 | 26 | class TestRun: 27 | """ 28 | Tests for the Adaptor._run method 29 | """ 30 | 31 | @patch.object(FakeAdaptor, "on_run", autospec=True) 32 | @patch.object(FakeAdaptor, "__init__", return_value=None, autospec=True) 33 | def test_run(self, mocked_init: Mock, mocked_on_run: Mock) -> None: 34 | # GIVEN 35 | init_data: dict = {} 36 | run_data: dict = {} 37 | adaptor = FakeAdaptor(init_data) 38 | 39 | # WHEN 40 | adaptor._run(run_data) 41 | 42 | # THEN 43 | mocked_init.assert_called_once_with(adaptor, init_data) 44 | mocked_on_run.assert_called_once_with(adaptor, run_data) 45 | 46 | @patch.object(FakeAdaptor, "on_start", autospec=True) 47 | def test_start(self, mocked_on_start: Mock) -> None: 48 | # GIVEN 49 | init_data: dict = {} 50 | adaptor = FakeAdaptor(init_data) 51 | 52 | # WHEN 53 | adaptor._start() 54 | 55 | # THEN 56 | mocked_on_start.assert_called_once_with(adaptor) 57 | 58 | @patch.object(FakeAdaptor, "on_stop", autospec=True) 59 | def test_stop(self, mocked_on_stop: Mock) -> None: 60 | # GIVEN 61 | init_data: dict = {} 62 | adaptor = FakeAdaptor(init_data) 63 | 64 | # WHEN 65 | adaptor._stop() 66 | 67 | # THEN 68 | mocked_on_stop.assert_called_once_with(adaptor) 69 | 70 | @patch.object(FakeAdaptor, "on_cleanup", autospec=True) 71 | def test_cleanup(self, mocked_on_cleanup: Mock) -> None: 72 | # GIVEN 73 | init_data: dict = {} 74 | adaptor = FakeAdaptor(init_data) 75 | 76 | # WHEN 77 | adaptor._cleanup() 78 | 79 | # THEN 80 | mocked_on_cleanup.assert_called_once_with(adaptor) 81 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_versioning.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | import re 3 | 4 | from functools import total_ordering 5 | from typing import Any, NamedTuple 6 | 7 | VERSION_RE = re.compile(r"^\d*\.\d*$") 8 | 9 | 10 | @total_ordering 11 | class SemanticVersion(NamedTuple): 12 | major: int 13 | minor: int 14 | 15 | def __str__(self): 16 | return f"{self.major}.{self.minor}" 17 | 18 | def __lt__(self, other: Any): 19 | if not isinstance(other, SemanticVersion): 20 | raise TypeError(f"Cannot compare SemanticVersion with {type(other)}") 21 | if self.major < other.major: 22 | return True 23 | elif self.major == other.major: 24 | if self.minor < other.minor: 25 | return True 26 | return False 27 | 28 | def __eq__(self, other: Any) -> bool: 29 | if not isinstance(other, SemanticVersion): 30 | raise TypeError(f"Cannot compare SemanticVersion with {type(other).__name__}") 31 | return self.major == other.major and self.minor == other.minor 32 | 33 | def has_compatibility_with(self, other: "SemanticVersion") -> bool: 34 | """ 35 | Returns a boolean representing if the version of self has compatibility with other. 36 | 37 | This check is NOT commutative. 38 | """ 39 | if not isinstance(other, SemanticVersion): 40 | raise TypeError( 41 | f"Cannot check compatibility of SemanticVersion with {type(other).__name__}" 42 | ) 43 | if self.major == other.major == 0: 44 | return self.minor == other.minor # Pre-release versions treat minor as breaking 45 | return self.major == other.major and self.minor >= other.minor 46 | 47 | @classmethod 48 | def parse(cls, version_str: str) -> "SemanticVersion": 49 | """ 50 | Parses a version string into a SemanticVersion object. 51 | 52 | Raises ValueError if the version string is not valid. 53 | """ 54 | try: 55 | if not VERSION_RE.match(version_str): 56 | raise ValueError 57 | major_str, minor_str = version_str.split(".") 58 | major = int(major_str) 59 | minor = int(minor_str) 60 | except ValueError: 61 | raise ValueError(f'Provided version "{version_str}" was not of form Major.Minor') 62 | return SemanticVersion(major, minor) 63 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/unit/test_action.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import json as _json 4 | from dataclasses import asdict as _asdict 5 | 6 | import pytest 7 | from _pytest.capture import CaptureFixture as _CaptureFixture 8 | 9 | from openjd.adaptor_runtime_client import Action as _Action 10 | 11 | 12 | class TestAction: 13 | def test_action_dict_cast(self) -> None: 14 | """Tests the action can be converted to a dictionary we expect.""" 15 | name = "test" 16 | args = None 17 | expected_dict = {"name": name, "args": args} 18 | 19 | a = _Action(name) 20 | 21 | assert _asdict(a) == expected_dict 22 | 23 | def test_action_to_from_string(self) -> None: 24 | """Test that the action can be turned into a string and a string can be converted to an 25 | action.""" 26 | name = "test" 27 | args = None 28 | expected_dict_str = _json.dumps({"name": name, "args": args}) 29 | 30 | a = _Action(name) 31 | 32 | # Testing the action can be converted to a string as expected 33 | assert str(a) == expected_dict_str 34 | 35 | # Testing that we can convert bytes to an Action 36 | # This also tests Action.from_json_string. 37 | a2 = _Action.from_bytes(expected_dict_str.encode()) 38 | 39 | assert a2 is not None 40 | if a2 is not None: # This is just for mypy 41 | assert a.name == a2.name 42 | assert a.args == a2.args 43 | 44 | json_errors = [ 45 | pytest.param( 46 | "action_1", 47 | 'Unable to convert "action_1" to json. The following exception was raised:', 48 | id="NonvalidJSON", 49 | ), 50 | pytest.param( 51 | '{"foo": "bar"}', 52 | "Unable to convert the json dictionary ({'foo': 'bar'}) to an action. The following " 53 | "exception was raised:", 54 | id="NonvalidKeys", 55 | ), 56 | ] 57 | 58 | @pytest.mark.parametrize("json_str, expected_error", json_errors) 59 | def test_action_from_nonvalid_string( 60 | self, json_str: str, expected_error: str, capsys: _CaptureFixture 61 | ) -> None: 62 | """Testing that exceptions were raised properly when attempting to convert a string to an 63 | action.""" 64 | a = _Action.from_json_string(json_str) 65 | 66 | assert a is None 67 | assert expected_error in capsys.readouterr().err 68 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | ** Python-jsonschema; version 4.17 -- https://github.com/python-jsonschema/jsonschema 2 | Copyright (c) 2013 Julian Berman 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | ------ 23 | 24 | ** PyYAML; version 6.0 -- https://pyyaml.org/ 25 | Copyright (c) 2017-2021 Ingy döt Net 26 | Copyright (c) 2006-2016 Kirill Simonov 27 | 28 | Copyright (c) 2017-2021 Ingy döt Net 29 | Copyright (c) 2006-2016 Kirill Simonov 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy of 32 | this software and associated documentation files (the "Software"), to deal in 33 | the Software without restriction, including without limitation the rights to 34 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 35 | of the Software, and to permit persons to whom the Software is furnished to do 36 | so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_background/loaders.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import abc 6 | import dataclasses 7 | import logging 8 | import json 9 | import os 10 | from pathlib import Path 11 | 12 | from .model import ( 13 | ConnectionSettings, 14 | DataclassMapper, 15 | ) 16 | 17 | _logger = logging.getLogger(__name__) 18 | 19 | 20 | class ConnectionSettingsLoadingError(Exception): 21 | """Raised when the connection settings cannot be loaded""" 22 | 23 | pass 24 | 25 | 26 | class ConnectionSettingsLoader(abc.ABC): 27 | @abc.abstractmethod 28 | def load(self) -> ConnectionSettings: 29 | pass 30 | 31 | 32 | @dataclasses.dataclass 33 | class ConnectionSettingsFileLoader(ConnectionSettingsLoader): 34 | file_path: Path 35 | 36 | def load(self) -> ConnectionSettings: 37 | try: 38 | with open(self.file_path, encoding="utf-8") as conn_file: 39 | loaded_settings = json.load(conn_file) 40 | except OSError as e: 41 | errmsg = f"Failed to open connection file '{self.file_path}': {e}" 42 | _logger.error(errmsg) 43 | raise ConnectionSettingsLoadingError(errmsg) from e 44 | except json.JSONDecodeError as e: 45 | errmsg = f"Failed to decode connection file '{self.file_path}': {e}" 46 | _logger.error(errmsg) 47 | raise ConnectionSettingsLoadingError(errmsg) from e 48 | return DataclassMapper(ConnectionSettings).map(loaded_settings) 49 | 50 | 51 | @dataclasses.dataclass 52 | class ConnectionSettingsEnvLoader(ConnectionSettingsLoader): 53 | env_map: dict[str, tuple[str, bool]] = dataclasses.field( 54 | default_factory=lambda: {"socket": ("OPENJD_ADAPTOR_SOCKET", True)} 55 | ) 56 | """Mapping of environment variable to a tuple of ConnectionSettings attribute name, and whether it is required""" 57 | 58 | def load(self) -> ConnectionSettings: 59 | kwargs = {} 60 | for attr_name, (env_name, required) in self.env_map.items(): 61 | env_val = os.environ.get(env_name) 62 | if not env_val: 63 | if required: 64 | raise ConnectionSettingsLoadingError( 65 | f"Required attribute '{attr_name}' does not have its corresponding environment variable '{env_name}' set" 66 | ) 67 | else: 68 | kwargs[attr_name] = env_val 69 | return ConnectionSettings(**kwargs) 70 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_adaptor_runner.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from ._adaptor_states import AdaptorState, AdaptorStates 8 | from ._base_adaptor import BaseAdaptor as BaseAdaptor 9 | from .._utils._constants import _OPENJD_FAIL_STDOUT_PREFIX 10 | 11 | __all__ = ["AdaptorRunner"] 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | 16 | class AdaptorRunner(AdaptorStates): 17 | """ 18 | Class that is responsible for running adaptors. 19 | """ 20 | 21 | def __init__(self, *, adaptor: BaseAdaptor): 22 | self.adaptor = adaptor 23 | self.state = AdaptorState.NOT_STARTED 24 | 25 | def _start(self): 26 | _logger.debug("Starting...") 27 | self.state = AdaptorState.START 28 | 29 | try: 30 | self.adaptor._start() 31 | except Exception as e: 32 | _fail(f"Error encountered while starting adaptor: {e}") 33 | raise 34 | 35 | def _run(self, run_data: dict): 36 | _logger.debug("Running task") 37 | self.state = AdaptorState.RUN 38 | 39 | try: 40 | self.adaptor._run(run_data) 41 | except Exception as e: 42 | _fail(f"Error encountered while running adaptor: {e}") 43 | raise 44 | 45 | _logger.debug("Task complete") 46 | 47 | def _stop(self): 48 | _logger.debug("Stopping...") 49 | self.state = AdaptorState.STOP 50 | 51 | try: 52 | self.adaptor._stop() 53 | except Exception as e: 54 | _fail(f"Error encountered while stopping adaptor: {e}") 55 | raise 56 | 57 | def _cleanup(self): 58 | _logger.debug("Cleaning up...") 59 | self.state = AdaptorState.CLEANUP 60 | 61 | try: 62 | self.adaptor._cleanup() 63 | except Exception as e: 64 | _fail(f"Error encountered while cleaning up adaptor: {e}") 65 | raise 66 | 67 | _logger.debug("Cleanup complete") 68 | 69 | def _cancel(self): 70 | _logger.debug("Canceling...") 71 | self.state = AdaptorState.CANCELED 72 | 73 | try: 74 | self.adaptor.cancel() 75 | except Exception as e: 76 | _fail(f"Error encountered while canceling the adaptor: {e}") 77 | raise 78 | 79 | _logger.debug("Cancel complete") 80 | 81 | 82 | def _fail(reason: str): 83 | _logger.error(f"{_OPENJD_FAIL_STDOUT_PREFIX}{reason}") 84 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/test_osname.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from typing import Callable 4 | from unittest.mock import Mock, patch 5 | 6 | import pytest 7 | 8 | import openjd.adaptor_runtime._osname as osname 9 | from openjd.adaptor_runtime._osname import OSName 10 | 11 | 12 | class TestOSName: 13 | @pytest.mark.parametrize("platform", ["Windows", "Darwin", "Linux"]) 14 | @patch.object(osname, "platform") 15 | def test_empty_init_returns_osname(self, mock_platform: Mock, platform: str): 16 | # GIVEN 17 | mock_platform.system.return_value = platform 18 | 19 | # WHEN 20 | osname = OSName() 21 | 22 | # THEN 23 | assert isinstance(osname, OSName) 24 | if platform == "Darwin": 25 | assert str(osname) == OSName.MACOS 26 | else: 27 | assert str(osname) == platform 28 | 29 | alias_params = [ 30 | pytest.param( 31 | ( 32 | "Darwin", 33 | "darwin", 34 | "MacOS", 35 | "macos", 36 | "mac", 37 | "Mac", 38 | "mac os", 39 | "MAC OS", 40 | "os x", 41 | "OS X", 42 | ), 43 | OSName.MACOS, 44 | OSName.is_macos, 45 | id="macOS", 46 | ), 47 | pytest.param( 48 | ("Windows", "win", "win32", "nt", "windows"), 49 | OSName.WINDOWS, 50 | OSName.is_windows, 51 | id="windows", 52 | ), 53 | pytest.param(("linux", "linux2"), OSName.LINUX, OSName.is_linux, id="linux"), 54 | pytest.param(("posix", "Posix", "POSIX"), OSName.POSIX, OSName.is_posix, id="posix"), 55 | ] 56 | 57 | @pytest.mark.parametrize("aliases, expected, is_os_func", alias_params) 58 | def test_aliases(self, aliases: list[str], expected: str, is_os_func: Callable): 59 | for alias in aliases: 60 | # WHEN 61 | osname = OSName(alias) 62 | 63 | # THEN 64 | assert isinstance( 65 | osname, OSName 66 | ), f"OSName('{alias}') did not return object of type OSName" 67 | assert str(osname) == expected, f"OSName('{alias}') did not resolve to '{expected}'" 68 | assert ( 69 | osname == alias 70 | ), f"OSName.__eq__ failed comparison with OSName('{alias}') and '{alias}'" 71 | assert is_os_func(alias), f"OSName.is_{expected.lower()}() failed for '{alias}'" 72 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/process/test_managed_process.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from typing import List, Optional 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | import openjd.adaptor_runtime.process._logging_subprocess as logging_subprocess 9 | import openjd.adaptor_runtime.process._managed_process as managed_process 10 | from openjd.adaptor_runtime.process import ManagedProcess 11 | 12 | 13 | class TestManagedProcess(object): 14 | """Unit tests for ManagedProcess""" 15 | 16 | @pytest.fixture(autouse=True) 17 | def mock_popen(self): 18 | with mock.patch.object(logging_subprocess.subprocess, "Popen") as popen_mock: 19 | yield popen_mock 20 | 21 | @pytest.fixture(autouse=True) 22 | def mock_stream_logger(self): 23 | with mock.patch.object(logging_subprocess, "StreamLogger") as stream_logger: 24 | stdout_logger_mock = mock.Mock() 25 | stderr_logger_mock = mock.Mock() 26 | stream_logger.side_effect = [stdout_logger_mock, stderr_logger_mock] 27 | yield stream_logger 28 | 29 | startup_dirs = [ 30 | pytest.param("", ["Hello World!"], "/path/for/startup", id="EmptyExecutable"), 31 | pytest.param("echo", ["Hello World!"], "/path/for/startup", id="EchoExecutable"), 32 | pytest.param("echo", [""], "/path/for/startup", id="EmptyArguments"), 33 | pytest.param("echo", ["Hello World!"], "", id="EmptyStartupDir"), 34 | pytest.param("echo", ["Hello World!"], None, id="NoStartupDir"), 35 | pytest.param( 36 | "echo", 37 | ["Hello World!"], 38 | "/path/for/startup", 39 | id="RandomStartupDir", 40 | ), 41 | ] 42 | 43 | @pytest.mark.parametrize("executable, arguments, startup_dir", startup_dirs) 44 | @mock.patch.object(managed_process, "LoggingSubprocess", autospec=True) 45 | def test_run( 46 | self, 47 | mock_LoggingSubprocess: mock.Mock, 48 | executable: str, 49 | arguments: List[str], 50 | startup_dir: str, 51 | ): 52 | class FakeManagedProcess(ManagedProcess): 53 | def get_executable(self) -> str: 54 | return executable 55 | 56 | def get_arguments(self) -> List[str]: 57 | return arguments 58 | 59 | def get_startup_directory(self) -> Optional[str]: 60 | return startup_dir 61 | 62 | mp = FakeManagedProcess({}) 63 | mp.run() 64 | 65 | mock_LoggingSubprocess.assert_called_once_with( 66 | args=[executable] + arguments, 67 | startup_directory=startup_dir, 68 | stdout_handler=None, 69 | stderr_handler=None, 70 | ) 71 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/_utils/test_secure_open.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from openjd.adaptor_runtime._osname import OSName 4 | import pytest 5 | import os 6 | import tempfile 7 | import random 8 | import string 9 | 10 | from openjd.adaptor_runtime._utils import secure_open 11 | 12 | if OSName.is_windows(): 13 | import win32security 14 | 15 | 16 | @pytest.fixture 17 | def create_file(): 18 | """ 19 | This fixture will create a file which can be only read / written by the file owner 20 | """ 21 | characters = string.ascii_letters + string.digits + string.punctuation 22 | file_content = "".join(random.choice(characters) for _ in range(10)) 23 | test_file_name = ( 24 | f"secure_open_test_{''.join(random.choice(string.ascii_letters) for _ in range(10))}.txt" 25 | ) 26 | test_file_path = os.path.join(tempfile.gettempdir(), test_file_name) 27 | with secure_open(test_file_path, open_mode="w", encoding="utf-8") as test_file: 28 | test_file.write(file_content) 29 | yield test_file_path, file_content 30 | os.remove(test_file_path) 31 | 32 | 33 | class TestSecureOpen: 34 | def test_secure_open_write_and_read(self, create_file): 35 | """ 36 | Test if the file owner can write and read the file 37 | """ 38 | test_file_path, file_content = create_file 39 | with secure_open(test_file_path, open_mode="r", encoding="utf-8") as test_file: 40 | result = test_file.read() 41 | assert result == file_content 42 | 43 | @pytest.mark.skipif(not OSName.is_windows(), reason="Windows-specific tests") 44 | @pytest.mark.skipif( 45 | os.getenv("GITHUB_ACTIONS") != "true", 46 | reason="Skip this test in local env to avoid user creation with elevated privilege.", 47 | ) 48 | def test_secure_open_file_windows_permission(self, create_file, win_test_user): 49 | """ 50 | Test if only the file owner has the permission to read the file. 51 | """ 52 | test_file_path, file_content = create_file 53 | user_name, password = win_test_user 54 | logon_type = win32security.LOGON32_LOGON_INTERACTIVE 55 | provider = win32security.LOGON32_PROVIDER_DEFAULT 56 | 57 | # Log on with the user's credentials and get the token handle 58 | token_handle = win32security.LogonUser(user_name, "", password, logon_type, provider) 59 | # Impersonate the user 60 | win32security.ImpersonateLoggedOnUser(token_handle) 61 | 62 | try: 63 | with pytest.raises(PermissionError): 64 | with open(test_file_path, "r", encoding="utf-8") as f: 65 | f.read() 66 | finally: 67 | # Revert the impersonation 68 | win32security.RevertToSelf() 69 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/adaptors/configuration/test_configuration.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | import pathlib as _pathlib 8 | import tempfile 9 | 10 | import pytest 11 | 12 | from openjd.adaptor_runtime.adaptors.configuration import Configuration 13 | 14 | 15 | class TestFromFile: 16 | """ 17 | Integration tests for the Configuration.from_file method 18 | """ 19 | 20 | def test_loads_config(self): 21 | # GIVEN 22 | json_schema = { 23 | "$schema": "https://json-schema.org/draft/2020-12/schema", 24 | "type": "object", 25 | "properties": {"key": {"enum": ["value"]}}, 26 | } 27 | config = {"key": "value"} 28 | 29 | try: 30 | # GIVEN 31 | with ( 32 | tempfile.NamedTemporaryFile(mode="w+", delete=False) as schema_file, 33 | tempfile.NamedTemporaryFile(mode="w+", delete=False) as config_file, 34 | ): 35 | json.dump(json_schema, schema_file.file) 36 | json.dump(config, config_file.file) 37 | schema_file.seek(0) 38 | config_file.seek(0) 39 | 40 | # WHEN 41 | result = Configuration.from_file( 42 | config_path=config_file.name, 43 | schema_path=schema_file.name, 44 | ) 45 | 46 | # THEN 47 | assert result._config == config 48 | 49 | finally: 50 | if os.path.exists(config_file.name): 51 | os.remove(config_file.name) 52 | if os.path.exists(schema_file.name): 53 | os.remove(schema_file.name) 54 | 55 | def test_raises_when_config_file_fails_to_open( 56 | self, tmp_path: _pathlib.Path, caplog: pytest.LogCaptureFixture 57 | ): 58 | try: 59 | # GIVEN 60 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as schema_file: 61 | json.dump({}, schema_file.file) 62 | schema_file.seek(0) 63 | non_existent_filepath = os.path.join(tmp_path.absolute(), "non_existent_file") 64 | 65 | # WHEN 66 | with pytest.raises(OSError) as raised_err: 67 | Configuration.from_file( 68 | schema_path=schema_file.name, 69 | config_path=non_existent_filepath, 70 | ) 71 | 72 | # THEN 73 | assert isinstance(raised_err.value, OSError) 74 | assert f"Failed to open configuration at {non_existent_filepath}: " in caplog.text 75 | 76 | finally: 77 | if os.path.exists(schema_file.name): 78 | os.remove(schema_file.name) 79 | -------------------------------------------------------------------------------- /.github/workflows/release_publish.yml: -------------------------------------------------------------------------------- 1 | name: "Release: Publish" 2 | run-name: "Release: ${{ github.event.head_commit.message || inputs.tag }}" 3 | 4 | on: 5 | push: 6 | branches: 7 | - mainline 8 | paths: 9 | - CHANGELOG.md 10 | workflow_dispatch: 11 | inputs: 12 | tag: 13 | required: true 14 | type: string 15 | description: Specify a tag to re-run a release. 16 | 17 | concurrency: 18 | group: release 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | TagRelease: 25 | uses: OpenJobDescription/.github/.github/workflows/reusable_tag_release.yml@mainline 26 | secrets: inherit 27 | with: 28 | tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }} 29 | 30 | UnitTests: 31 | name: Unit Tests 32 | needs: TagRelease 33 | uses: ./.github/workflows/code_quality.yml 34 | with: 35 | tag: ${{ needs.TagRelease.outputs.tag }} 36 | 37 | PreRelease: 38 | needs: [TagRelease, UnitTests] 39 | uses: OpenJobDescription/.github/.github/workflows/reusable_prerelease.yml@mainline 40 | permissions: 41 | id-token: write 42 | contents: write 43 | secrets: inherit 44 | with: 45 | tag: ${{ needs.TagRelease.outputs.tag }} 46 | 47 | Release: 48 | needs: [TagRelease, PreRelease] 49 | uses: OpenJobDescription/.github/.github/workflows/reusable_release.yml@mainline 50 | secrets: inherit 51 | permissions: 52 | id-token: write 53 | contents: write 54 | with: 55 | tag: ${{ needs.TagRelease.outputs.tag }} 56 | 57 | Publish: 58 | needs: [TagRelease, Release] 59 | uses: OpenJobDescription/.github/.github/workflows/reusable_publish_python.yml@mainline 60 | permissions: 61 | id-token: write 62 | secrets: inherit 63 | with: 64 | tag: ${{ needs.TagRelease.outputs.tag }} 65 | 66 | # PyPI does not support reusable workflows yet 67 | # # See https://github.com/pypi/warehouse/issues/11096 68 | PublishToPyPI: 69 | needs: [TagRelease, Publish] 70 | runs-on: ubuntu-latest 71 | environment: release 72 | permissions: 73 | id-token: write 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v6 77 | with: 78 | ref: ${{ needs.TagRelease.outputs.tag }} 79 | fetch-depth: 0 80 | - name: Set up Python 81 | uses: actions/setup-python@v6 82 | with: 83 | python-version: '3.9' 84 | - name: Install dependencies 85 | run: | 86 | pip install --upgrade hatch 87 | - name: Build 88 | run: hatch -v build 89 | # # See https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-pypi 90 | - name: Publish to PyPI 91 | uses: pypa/gh-action-pypi-publish@release/v1 92 | 93 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/application_ipc/test_actions_queue.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from collections import deque as _deque 4 | 5 | from openjd.adaptor_runtime_client import Action as _Action 6 | 7 | from openjd.adaptor_runtime.application_ipc import ActionsQueue as _ActionsQueue 8 | 9 | 10 | class TestActionsQueue: 11 | def test_actions_queue(self) -> None: 12 | """Testing that we can enqueue correctly.""" 13 | aq = _ActionsQueue() 14 | 15 | # Confirming the actions queue has been initialized. 16 | assert aq._actions_queue == _deque() 17 | 18 | # Testing enqueue_action works as expected. 19 | aq.enqueue_action(_Action("a1")) 20 | aq.enqueue_action(_Action("a2")) 21 | aq.enqueue_action(_Action("a3")) 22 | 23 | # Asserting actions were enqueued in order. 24 | assert len(aq) == 3 25 | assert aq.dequeue_action() == _Action("a1") 26 | assert aq.dequeue_action() == _Action("a2") 27 | assert aq.dequeue_action() == _Action("a3") 28 | assert aq.dequeue_action() is None 29 | 30 | def test_actions_queue_append_start(self) -> None: 31 | aq = _ActionsQueue() 32 | 33 | # Testing enqueue_action works as expected. 34 | aq.enqueue_action(_Action("a1")) 35 | aq.enqueue_action(_Action("a4"), front=True) 36 | 37 | # Asserting actions were enqueued in order. 38 | assert len(aq) == 2 39 | assert aq.dequeue_action() == _Action("a4") 40 | assert aq.dequeue_action() == _Action("a1") 41 | assert aq.dequeue_action() is None 42 | 43 | def test_len(self) -> None: 44 | """Testing that our overridden __len__ works as expected.""" 45 | aq = _ActionsQueue() 46 | 47 | # Starting off with an empty queue. 48 | assert len(aq) == 0 49 | 50 | # Adding 1 item to the queue. 51 | aq.enqueue_action(_Action("a1")) 52 | assert len(aq) == 1 53 | 54 | # Adding a second item to the queue. 55 | aq.enqueue_action(_Action("a2")) 56 | assert len(aq) == 2 57 | 58 | # Removing the first items from the queue. 59 | aq.dequeue_action() 60 | assert len(aq) == 1 61 | 62 | # Removing the last from the queue. 63 | aq.dequeue_action() 64 | assert len(aq) == 0 65 | 66 | def test_bool(self) -> None: 67 | """Testing that our overridden __bool__ works as expected.""" 68 | aq = _ActionsQueue() 69 | 70 | # Starting off with an empty queue. 71 | assert not bool(aq) 72 | 73 | # Adding 1 item to the queue. 74 | aq.enqueue_action(_Action("a1")) 75 | assert bool(aq) 76 | 77 | # Adding a second item to the queue. 78 | aq.enqueue_action(_Action("a2")) 79 | assert bool(aq) 80 | 81 | # Removing the first items from the queue. 82 | aq.dequeue_action() 83 | assert bool(aq) 84 | 85 | # Removing the last from the queue. 86 | aq.dequeue_action() 87 | assert not bool(aq) 88 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/background/test_server_response.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from unittest.mock import MagicMock 4 | import pytest 5 | from openjd.adaptor_runtime._osname import OSName 6 | 7 | if OSName.is_windows(): 8 | from openjd.adaptor_runtime._background.backend_named_pipe_server import ( 9 | WinBackgroundNamedPipeServer, 10 | ) 11 | else: 12 | from openjd.adaptor_runtime._background.http_server import BackgroundHTTPServer 13 | 14 | from openjd.adaptor_runtime._background.server_response import ServerResponseGenerator 15 | from http import HTTPStatus 16 | 17 | 18 | class TestServerResponseGenerator: 19 | def test_submits_work(self): 20 | # GIVEN 21 | def my_fn(): 22 | pass 23 | 24 | args = ("one", "two") 25 | kwargs = {"three": 3, "four": 4} 26 | 27 | mock_future_runner = MagicMock() 28 | if OSName.is_windows(): 29 | mock_server = MagicMock(spec=WinBackgroundNamedPipeServer) 30 | else: 31 | mock_server = MagicMock(spec=BackgroundHTTPServer) 32 | mock_server._future_runner = mock_future_runner 33 | mock_response_method = MagicMock() 34 | mock_server_response = MagicMock() 35 | mock_server_response.server = mock_server 36 | mock_server_response.response_method = mock_response_method 37 | 38 | # WHEN 39 | ServerResponseGenerator.submit(mock_server_response, my_fn, *args, **kwargs) 40 | 41 | # THEN 42 | mock_future_runner.submit.assert_called_once_with(my_fn, *args, **kwargs) 43 | mock_future_runner.wait_for_start.assert_called_once() 44 | # assert mock_response_method.assert_called_once_with(HTTPStatus.OK) 45 | mock_response_method.assert_called_once_with(HTTPStatus.OK) 46 | 47 | def test_returns_500_if_fails_to_submit_work(self, caplog: pytest.LogCaptureFixture): 48 | # GIVEN 49 | def my_fn(): 50 | pass 51 | 52 | args = ("one", "two") 53 | kwargs = {"three": 3, "four": 4} 54 | 55 | if OSName.is_windows(): 56 | mock_server = MagicMock(spec=WinBackgroundNamedPipeServer) 57 | else: 58 | mock_server = MagicMock(spec=BackgroundHTTPServer) 59 | mock_future_runner = MagicMock() 60 | exc = Exception() 61 | mock_future_runner.submit.side_effect = exc 62 | mock_server._future_runner = mock_future_runner 63 | mock_response_method = MagicMock() 64 | mock_server_response = MagicMock() 65 | mock_server_response.server = mock_server 66 | mock_server_response.response_method = mock_response_method 67 | 68 | # WHEN 69 | ServerResponseGenerator.submit(mock_server_response, my_fn, *args, **kwargs) 70 | 71 | # THEN 72 | mock_future_runner.submit.assert_called_once_with(my_fn, *args, **kwargs) 73 | mock_response_method.assert_called_once_with( 74 | HTTPStatus.INTERNAL_SERVER_ERROR, body=str(exc) 75 | ) 76 | 77 | assert "Failed to submit work: " in caplog.text 78 | -------------------------------------------------------------------------------- /test/openjd/test_copyright_header.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import re 4 | from pathlib import Path 5 | 6 | # For distributed open source and proprietary code, we must include a copyright header in source every file: 7 | _copyright_header_re = re.compile( 8 | r"Copyright Amazon\.com, Inc\. or its affiliates\. All Rights Reserved\.", re.IGNORECASE 9 | ) 10 | _generated_by_scm = re.compile(r"# file generated by setuptools[_-]scm", re.IGNORECASE) 11 | 12 | 13 | def _check_file(filename: Path) -> None: 14 | with open(filename, "r", encoding="utf-8") as infile: 15 | lines_read = 0 16 | for line in infile: 17 | if _copyright_header_re.search(line): 18 | return # success 19 | lines_read += 1 20 | if lines_read > 10: 21 | raise Exception( 22 | f"Could not find a valid Amazon.com copyright header in the top of {filename}." 23 | " Please add one." 24 | ) 25 | else: 26 | # __init__.py files are usually empty, this is to catch that. 27 | raise Exception( 28 | f"Could not find a valid Amazon.com copyright header in the top of {filename}." 29 | " Please add one." 30 | ) 31 | 32 | 33 | def _is_version_file(filename: Path) -> bool: 34 | if filename.name != "_version.py": 35 | return False 36 | with open(filename, encoding="utf-8") as infile: 37 | lines_read = 0 38 | for line in infile: 39 | if _generated_by_scm.search(line): 40 | return True 41 | lines_read += 1 42 | if lines_read > 10: 43 | break 44 | return False 45 | 46 | 47 | def test_copyright_headers(): 48 | """Verifies every .py file has an Amazon copyright header.""" 49 | root_project_dir = Path(__file__) 50 | # The root of the project is the directory that contains the test directory. 51 | while not (root_project_dir / "test").exists(): 52 | root_project_dir = root_project_dir.parent 53 | # Choose only a few top level directories to test. 54 | # That way we don't snag any virtual envs a developer might create, at the risk of missing 55 | # some top level .py files. 56 | # Additionally, ignore any files in the `node_modules` directory that we use in the VS Code 57 | # extension. 58 | top_level_dirs = [ 59 | "src", 60 | "test", 61 | "scripts", 62 | "testing_containers", 63 | "openjdvscode!(/node_modules)", 64 | ] 65 | file_count = 0 66 | for top_level_dir in top_level_dirs: 67 | for glob_pattern in ("**/*.py", "**/*.sh", "**/Dockerfile", "**/*.ts"): 68 | for path in Path(root_project_dir / top_level_dir).glob(glob_pattern): 69 | print(path) 70 | if not _is_version_file(path): 71 | _check_file(path) 72 | file_count += 1 73 | 74 | print(f"test_copyright_headers checked {file_count} files successfully.") 75 | assert file_count > 0, "Test misconfiguration" 76 | 77 | 78 | if __name__ == "__main__": 79 | test_copyright_headers() 80 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/process/test_integration_managed_process.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | import sys 8 | import time 9 | from logging import INFO 10 | from typing import List 11 | from unittest import mock 12 | 13 | import pytest 14 | 15 | from openjd.adaptor_runtime._osname import OSName 16 | from openjd.adaptor_runtime.app_handlers import RegexCallback, RegexHandler 17 | from openjd.adaptor_runtime.process import ManagedProcess 18 | 19 | 20 | class TestManagedProcess(object): 21 | """Integration tests for ManagedProcess""" 22 | 23 | def test_run(self, caplog): 24 | """Testing a success case for the managed process.""" 25 | 26 | class FakeManagedProcess(ManagedProcess): 27 | def get_executable(self) -> str: 28 | if OSName.is_windows(): 29 | return "powershell.exe" 30 | else: 31 | return "echo" 32 | 33 | def get_arguments(self) -> List[str]: 34 | if OSName.is_windows(): 35 | return ["echo", "Hello World!"] 36 | else: 37 | return ["Hello World!"] 38 | 39 | def get_startup_directory(self) -> str | None: 40 | return None 41 | 42 | caplog.set_level(INFO) 43 | 44 | mp = FakeManagedProcess({}) 45 | mp.run() 46 | 47 | assert "Hello World!" in caplog.text 48 | 49 | 50 | class TestIntegrationRegexHandlerManagedProcess(object): 51 | """Integration tests for LoggingSubprocess""" 52 | 53 | invoked_regex_list = [ 54 | pytest.param( 55 | re.compile(".*"), 56 | "Test output", 57 | 5, 58 | ), 59 | ] 60 | 61 | @pytest.mark.parametrize("stdout, stderr", [(1, 0), (0, 1), (1, 1)]) 62 | @pytest.mark.parametrize("regex, output, echo_count", invoked_regex_list) 63 | def test_regexhandler_invoked(self, regex, output, echo_count, stdout, stderr): 64 | # GIVEN 65 | class FakeManagedProcess(ManagedProcess): 66 | def get_executable(self) -> str: 67 | return sys.executable 68 | 69 | def get_arguments(self) -> List[str]: 70 | test_file = os.path.join( 71 | os.path.abspath(os.path.dirname(__file__)), "scripts", "echo_sleep_n_times.py" 72 | ) 73 | return [test_file, output, str(echo_count)] 74 | 75 | def get_startup_directory(self) -> str | None: 76 | return None 77 | 78 | callback = mock.Mock() 79 | regex_callbacks = [RegexCallback([regex], callback)] 80 | regex_handler = RegexHandler(regex_callbacks) 81 | 82 | # WHEN 83 | 84 | mp = FakeManagedProcess( 85 | {}, 86 | stdout_handler=regex_handler if stdout else None, 87 | stderr_handler=regex_handler if stderr else None, 88 | ) 89 | mp.run() 90 | time.sleep(0.01) # magic sleep - logging handler has a delay and test can exit too fast 91 | 92 | # THEN 93 | assert callback.call_count == echo_count * (stdout + stderr) 94 | assert all(c[0][0].re == regex for c in callback.call_args_list) 95 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_named_pipe_request_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import json 4 | from http import HTTPStatus 5 | from typing import TYPE_CHECKING, cast, Dict, List 6 | 7 | from ._adaptor_server_response import AdaptorServerResponseGenerator 8 | from .._named_pipe import ResourceRequestHandler 9 | 10 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 11 | from ._win_adaptor_server import WinAdaptorServer 12 | 13 | from pywintypes import HANDLE 14 | import logging 15 | 16 | _logger = logging.getLogger(__name__) 17 | 18 | 19 | class WinAdaptorServerResourceRequestHandler(ResourceRequestHandler): 20 | """ 21 | A handler for managing requests sent to a NamedPipe instance within a Windows environment. 22 | 23 | This class handles incoming requests, processes them, and sends back appropriate responses. 24 | It is designed to work in conjunction with a WinAdaptorServer that manages the 25 | lifecycle of the NamedPipe server and other associated resources. 26 | """ 27 | 28 | def __init__(self, server: "WinAdaptorServer", pipe_handle: HANDLE): 29 | """ 30 | Initializes the WinBackgroundResourceRequestHandler with a server and pipe handle. 31 | 32 | Args: 33 | server(WinAdaptorServer): The server instance that created this handler. 34 | It is responsible for managing the lifecycle of the NamedPipe server and other resources. 35 | pipe_handle(pipe_handle): The handle to the NamedPipe instance created and managed by the server. 36 | """ 37 | super().__init__(server, pipe_handle) 38 | 39 | @property 40 | def request_path_and_method_dict(self) -> Dict[str, List[str]]: 41 | return { 42 | "/path_mapping": ["GET"], 43 | "/path_mapping_rules": ["GET"], 44 | "/action": ["GET"], 45 | } 46 | 47 | def handle_request(self, data: str): 48 | """ 49 | Processes an incoming request and routes it to the correct response handler based on the method 50 | and request path. 51 | 52 | Args: 53 | data: A string containing the message sent from the client. 54 | """ 55 | request_dict = json.loads(data) 56 | path = request_dict["path"] 57 | method: str = request_dict["method"] 58 | if not self.validate_request_path_and_method(path, method): 59 | return 60 | 61 | if "params" in request_dict and request_dict["params"] != "null": 62 | query_string_params = json.loads(request_dict["params"]) 63 | else: 64 | query_string_params = {} 65 | 66 | server_operation = AdaptorServerResponseGenerator( 67 | cast("WinAdaptorServer", self.server), self.send_response, query_string_params 68 | ) 69 | try: 70 | # Ignore the leading `/` in path 71 | method_name = f"generate_{path[1:]}_{method.lower()}_response" 72 | getattr(server_operation, method_name)() 73 | except Exception as e: 74 | error_message = ( 75 | f"Error encountered in request handling. " 76 | f"Path: '{path}', Method: '{method}', Error: '{str(e)}'" 77 | ) 78 | _logger.error(error_message) 79 | self.send_response(HTTPStatus.BAD_REQUEST, error_message) 80 | raise 81 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/CommandAdaptorExample/adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import os 4 | from typing import List 5 | from logging import getLogger 6 | 7 | from openjd.adaptor_runtime._osname import OSName 8 | from openjd.adaptor_runtime.adaptors import CommandAdaptor, SemanticVersion 9 | from openjd.adaptor_runtime.process import ManagedProcess 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | class IntegManagedProcess(ManagedProcess): 15 | @property 16 | def integration_data_interface_version(self) -> SemanticVersion: 17 | return SemanticVersion(major=0, minor=1) 18 | 19 | def get_executable(self) -> str: 20 | """ 21 | Defines the executable to be used by the process. This method must be implemented, as it's abstract in 22 | ManagedProcess. In this example, it returns 'powershell.exe' for Windows to run PowerShell scripts, 23 | and '/bin/echo' for other operating systems. 24 | """ 25 | if OSName.is_windows(): 26 | # In Windows, we cannot directly run the powershell script. 27 | # Need to use PowerShell.exe to run the command. 28 | return "powershell.exe" 29 | else: 30 | return os.path.abspath(os.path.join(os.path.sep, "bin", "echo")) 31 | 32 | def get_arguments(self) -> List[str]: 33 | """ 34 | Specifies the arguments for the executable. Override to provide specific arguments; 35 | defaults to an empty list if not overridden. 36 | """ 37 | return self.run_data.get("args", [""]) 38 | 39 | 40 | class CommandAdaptorExample(CommandAdaptor): 41 | """ 42 | This class demonstrates how an adaptor operates within the adaptor runtime environment by invoking specific 43 | lifecycle methods (on_*) to communicate with an application process. 44 | This example uses PowerShell on Windows and the 'echo' command on other OSes as executables. 45 | 46 | Implement the get_managed_process method to define command execution. Optionally, on_prerun and 47 | on_postrun can be overridden to run code before and after the managed process. 48 | """ 49 | 50 | @property 51 | def integration_data_interface_version(self) -> SemanticVersion: 52 | return SemanticVersion(major=0, minor=1) 53 | 54 | def get_managed_process(self, run_data: dict) -> ManagedProcess: 55 | """ 56 | This method provides the primary functionality of the *run* phase of the Adaptor's lifecycle. 57 | It must be implemented to specify how commands are run through use of a ManagedProcess. 58 | """ 59 | return IntegManagedProcess(run_data) 60 | 61 | def on_prerun(self): 62 | """ 63 | This method is run first during the *run* phase of the Adaptor's lifecycle; before the ManagedProcess runs. 64 | Useful for setup operations or logging. 65 | """ 66 | # Print only goes to stdout and is not captured in daemon mode. 67 | print("prerun-print") 68 | # Logging is captured in daemon mode. 69 | logger.info(str(self.init_data.get("on_prerun", ""))) 70 | 71 | def on_postrun(self): 72 | """ 73 | This method is run last during the *run* phase of the Adaptor's lifecycle; after the ManagedProcess has 74 | exited. 75 | Can be used for cleanup or further processing. 76 | """ 77 | # Print only goes to stdout and is not captured in daemon mode. 78 | print("postrun-print") 79 | # Logging is captured in daemon mode. 80 | logger.info(str(self.init_data.get("on_postrun", ""))) 81 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/posix_client_interface.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import signal as _signal 6 | import threading as _threading 7 | import warnings 8 | 9 | from .base_client_interface import Response as _Response 10 | from typing import Dict as _Dict 11 | 12 | from .base_client_interface import BaseClientInterface 13 | from .connection import UnixHTTPConnection as _UnixHTTPConnection 14 | from urllib.parse import urlencode as _urlencode 15 | 16 | 17 | # Set timeout to None so our requests are blocking calls with no timeout. 18 | # See socket.settimeout 19 | _REQUEST_TIMEOUT = None 20 | 21 | SOCKET_PATH_DEPRECATED_MESSAGE = ( 22 | "The 'socket_path' parameter is deprecated; use 'server_path' instead" 23 | ) 24 | 25 | 26 | class HTTPClientInterface(BaseClientInterface): 27 | def __init__(self, server_path: str, **kwargs) -> None: 28 | """When the client is created, we need the port number to connect to the server. 29 | 30 | Args: 31 | server_path (str): The path to the UNIX domain socket to use. 32 | """ 33 | 34 | socket_path = kwargs.get("socket_path") 35 | if socket_path is not None: 36 | warnings.warn(SOCKET_PATH_DEPRECATED_MESSAGE, DeprecationWarning) 37 | if server_path is not None: 38 | raise ValueError("Cannot use both 'server_path' and 'socket_path'") 39 | server_path = socket_path 40 | 41 | super().__init__(server_path) 42 | 43 | if _threading.current_thread() is _threading.main_thread(): 44 | # NOTE: The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. 45 | # Reference: https://man7.org/linux/man-pages/man7/signal.7.html 46 | # SIGTERM graceful shutdown. 47 | _signal.signal(_signal.SIGTERM, self.graceful_shutdown) 48 | 49 | @property 50 | def socket_path(self): 51 | warnings.warn(SOCKET_PATH_DEPRECATED_MESSAGE, DeprecationWarning) 52 | return self.server_path 53 | 54 | @socket_path.setter 55 | def socket_path(self, value): 56 | warnings.warn(SOCKET_PATH_DEPRECATED_MESSAGE, DeprecationWarning) 57 | self.server_path = value 58 | 59 | def _send_request( 60 | self, method: str, request_path: str, *, query_string_params: _Dict | None = None 61 | ) -> _Response: 62 | """ 63 | Send a request to the server and return the response. 64 | 65 | Args: 66 | method (str): The HTTP method, e.g. 'GET', 'POST'. 67 | request_path (str): The path for the request. 68 | query_string_params (_Dict | None, optional): Query string parameters to include in the request. 69 | Defaults to None. In Linux, the query string parameters will be added to the URL 70 | 71 | Returns: 72 | Response: The response from the server. 73 | """ 74 | headers = { 75 | "Content-type": "application/json", 76 | } 77 | connection = _UnixHTTPConnection(self.socket_path, timeout=_REQUEST_TIMEOUT) 78 | if query_string_params: 79 | request_path += "?" + _urlencode(query_string_params) 80 | connection.request(method, request_path, headers=headers) 81 | response = connection.getresponse() 82 | connection.close() 83 | length = response.length if response.length else 0 84 | body = response.read().decode() if length else "" 85 | return _Response(response.status, body, response.reason, length) 86 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_background/model.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import dataclasses as dataclasses 4 | import json as json 5 | from enum import Enum as Enum 6 | from enum import EnumMeta 7 | from typing import Any, ClassVar, Dict, Type, TypeVar, cast, Generic 8 | 9 | from ..adaptors import AdaptorState 10 | 11 | _T = TypeVar("_T") 12 | 13 | 14 | @dataclasses.dataclass 15 | class ConnectionSettings: 16 | socket: str 17 | 18 | 19 | class AdaptorStatus(str, Enum): 20 | IDLE = "idle" 21 | WORKING = "working" 22 | 23 | 24 | @dataclasses.dataclass 25 | class BufferedOutput: 26 | EMPTY: ClassVar[str] = "EMPTY" 27 | 28 | id: str 29 | output: str 30 | 31 | 32 | @dataclasses.dataclass 33 | class HeartbeatResponse: 34 | state: AdaptorState 35 | status: AdaptorStatus 36 | output: BufferedOutput 37 | failed: bool = False 38 | 39 | 40 | class DataclassJSONEncoder(json.JSONEncoder): # pragma: no cover 41 | def default(self, o: Any) -> Dict: 42 | if dataclasses.is_dataclass(o) and not isinstance(o, type): 43 | return dataclasses.asdict(o) 44 | else: 45 | return super().default(o) 46 | 47 | 48 | class DataclassMapper(Generic[_T]): 49 | """ 50 | Class that maps a dictionary to a dataclass. 51 | 52 | The main reason this exists is to support nested dataclasses. Dataclasses are represented as 53 | dict when serialized, and when they are nested we get a nested dictionary structure. For a 54 | simple dataclass, we can easily go from a dict to a dataclass instance by expanding the 55 | dictionary into keyword arguments for the dataclass' __init__ function. e.g. 56 | 57 | ``` 58 | @dataclass 59 | class FullName: 60 | first: str 61 | last: str 62 | 63 | my_dict = {"first": "John", "last": "Doe"} 64 | name_instance = FullName(**my_dict) 65 | ``` 66 | 67 | However, in a nested structure, this will not work because the parent dataclass' __init__ 68 | function expects instance(s) of the nested dataclass(es), not a dictionary. For example, 69 | building on the previous code snippet: 70 | 71 | ``` 72 | @dataclass 73 | class Person: 74 | age: int 75 | name: FullName 76 | 77 | my_dict = { 78 | "age": 30, 79 | "name": { 80 | "first": "John", 81 | "last": "Doe", 82 | }, 83 | } 84 | person_instance = Person(**my_dict) 85 | ``` 86 | 87 | The above code is not valid because Person.__init__ expects an instance of FullName for the 88 | "name" argument, not a dict with the keyword args. This class handles this case by checking 89 | each field to see if it is a dataclass and instantiating that dataclass for you. 90 | """ 91 | 92 | def __init__(self, cls: Type[_T]) -> None: 93 | self._cls = cls 94 | super().__init__() 95 | 96 | def map(self, o: Dict) -> _T: 97 | args: Dict = {} 98 | for field in dataclasses.fields(self._cls): # type: ignore 99 | if field.name not in o: 100 | raise ValueError(f"Dataclass field {field.name} not found in dict {o}") 101 | 102 | value = o[field.name] 103 | if dataclasses.is_dataclass(field.type): 104 | # The init function expects a type, so any dataclasses in cls 105 | # will be a type. 106 | value = DataclassMapper(cast(type, field.type)).map(value) 107 | elif isinstance(field.type, EnumMeta): 108 | value = field.type(value) 109 | 110 | args[field.name] = value 111 | 112 | return self._cls(**args) 113 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_background/background_named_pipe_request_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import json 4 | from typing import TYPE_CHECKING, cast, Dict, List 5 | 6 | from .._named_pipe import ResourceRequestHandler 7 | 8 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 9 | from .backend_named_pipe_server import WinBackgroundNamedPipeServer 10 | 11 | from openjd.adaptor_runtime._background.server_response import ServerResponseGenerator 12 | 13 | from pywintypes import HANDLE 14 | import logging 15 | 16 | _logger = logging.getLogger(__name__) 17 | 18 | 19 | class WinBackgroundResourceRequestHandler(ResourceRequestHandler): 20 | """ 21 | A handler for managing requests sent to a NamedPipe instance within a Windows environment. 22 | 23 | This class handles incoming requests, processes them, and sends back appropriate responses. 24 | It is designed to work in conjunction with a WinBackgroundNamedPipeServer that manages the 25 | lifecycle of the NamedPipe server and other associated resources. 26 | """ 27 | 28 | def __init__(self, server: "WinBackgroundNamedPipeServer", pipe_handle: HANDLE): 29 | """ 30 | Initializes the WinBackgroundResourceRequestHandler with a server and pipe handle. 31 | 32 | Args: 33 | server(WinBackgroundNamedPipeServer): The server instance that created this handler. 34 | It is responsible for managing the lifecycle of the NamedPipe server and other resources. 35 | pipe_handle(pipe_handle): The handle to the NamedPipe instance created and managed by the server. 36 | pipe_handle(HANDLE): pipe_handle(HANDLE): Handle for the NamedPipe, established by the instance. 37 | Utilized for message read/write operations. 38 | """ 39 | super().__init__(server, pipe_handle) 40 | 41 | @property 42 | def request_path_and_method_dict(self) -> Dict[str, List[str]]: 43 | return { 44 | "/run": ["PUT"], 45 | "/shutdown": ["PUT"], 46 | "/heartbeat": ["GET"], 47 | "/start": ["PUT"], 48 | "/stop": ["PUT"], 49 | "/cancel": ["PUT"], 50 | } 51 | 52 | def handle_request(self, data: str): 53 | """ 54 | Processes an incoming request and routes it to the correct response handler based on the method 55 | and request path. 56 | 57 | Args: 58 | data: A string containing the message sent from the client. 59 | """ 60 | request_dict = json.loads(data) 61 | path = request_dict["path"] 62 | body = None 63 | if "body" in request_dict: 64 | body = json.loads(request_dict["body"]) 65 | method = request_dict["method"] 66 | 67 | if "params" in request_dict and request_dict["params"] != "null": 68 | query_string_params = json.loads(request_dict["params"]) 69 | else: 70 | query_string_params = {} 71 | 72 | server_operation = ServerResponseGenerator( 73 | cast("WinBackgroundNamedPipeServer", self.server), 74 | self.send_response, 75 | body, 76 | query_string_params, 77 | ) 78 | try: 79 | if not self.validate_request_path_and_method(path, method): 80 | return 81 | # Ignore the leading `/` in the path 82 | method_name = f"generate_{path[1:]}_{method.lower()}_response" 83 | getattr(server_operation, method_name)() 84 | except Exception as e: 85 | _logger.error( 86 | f"Error encountered in request handling. " 87 | f"Path: '{path}', Method: '{method}', Error: '{str(e)}'" 88 | ) 89 | raise 90 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/_osname.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import platform 4 | from typing import Optional 5 | 6 | 7 | class OSName(str): 8 | """ 9 | OS Name Utility Class. 10 | 11 | Calling the constructor without any parameters will create an OSName object initialized with the 12 | OS python is running on (one of Linux, macOS, Windows). 13 | 14 | Calling the constructor with a string will result in an OSName object with the string resolved 15 | to one of Linux, macOS, Windows. If the string could not be resolved to an OS, then a ValueError 16 | will be raised. 17 | 18 | This class also has an override __eq__ which can be used to compare against string types for OS 19 | Name equality. For example OSName('Windows') == 'nt' will evaluate to True. 20 | """ 21 | 22 | LINUX = "Linux" 23 | MACOS = "macOS" 24 | WINDOWS = "Windows" 25 | POSIX = "Posix" 26 | 27 | __hash__ = str.__hash__ # needed because we define __eq__ 28 | 29 | def __init__(self, *args, **kw): 30 | super().__init__() 31 | 32 | def __new__(cls, *args, **kw): 33 | if len(args) > 0: 34 | args = (OSName.resolve_os_name(args[0]), *args[1:]) 35 | else: 36 | args = (OSName._get_os_name(),) 37 | return str.__new__(cls, *args, **kw) 38 | 39 | @staticmethod 40 | def is_macos(name: Optional[str] = None) -> bool: 41 | name = OSName._get_os_name() if name is None else name 42 | return OSName.resolve_os_name(name) == OSName.MACOS 43 | 44 | @staticmethod 45 | def is_windows(name: Optional[str] = None) -> bool: 46 | name = OSName._get_os_name() if name is None else name 47 | return OSName.resolve_os_name(name) == OSName.WINDOWS 48 | 49 | @staticmethod 50 | def is_linux(name: Optional[str] = None) -> bool: 51 | name = OSName._get_os_name() if name is None else name 52 | return OSName.resolve_os_name(name) == OSName.LINUX 53 | 54 | @staticmethod 55 | def is_posix(name: Optional[str] = None) -> bool: 56 | name = OSName._get_os_name() if name is None else name 57 | return ( 58 | OSName.resolve_os_name(name) == OSName.POSIX 59 | or OSName.is_macos(name) 60 | or OSName.is_linux(name) 61 | ) 62 | 63 | @staticmethod 64 | def _get_os_name() -> str: 65 | return OSName.resolve_os_name(platform.system()) 66 | 67 | @staticmethod 68 | def resolve_os_name(name: str) -> str: 69 | """ 70 | Resolves an OS Name from an alias. In general this works as follows: 71 | - macOS will resolve from: {'darwin', 'macos', 'mac', 'mac os', 'os x'} 72 | - Windows will resolve from {'nt', 'windows'} or any string starting with 'win' like 'win32' 73 | - Linux will resolve from any string starting with 'linux', like 'linux' or 'linux2' 74 | """ 75 | name = name.lower().strip() 76 | if os_name := _osname_alias_map.get(name): 77 | return os_name 78 | elif name.startswith("win"): 79 | return OSName.WINDOWS 80 | elif name.startswith("linux"): 81 | return OSName.LINUX 82 | elif name.lower() == "posix": 83 | return OSName.POSIX 84 | else: 85 | raise ValueError(f"The operating system '{name}' is unknown and could not be resolved.") 86 | 87 | def __eq__(self, __x: object) -> bool: 88 | return OSName.resolve_os_name(self) == OSName.resolve_os_name(str(__x)) 89 | 90 | 91 | _osname_alias_map: dict[str, str] = { 92 | "darwin": OSName.MACOS, 93 | "macos": OSName.MACOS, 94 | "mac": OSName.MACOS, 95 | "mac os": OSName.MACOS, 96 | "os x": OSName.MACOS, 97 | "nt": OSName.WINDOWS, 98 | "windows": OSName.WINDOWS, 99 | "posix": OSName.POSIX, 100 | } 101 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/adaptors/test_integration_adaptor.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import shutil 7 | from pathlib import Path 8 | 9 | from openjd.adaptor_runtime.adaptors import Adaptor, SemanticVersion 10 | 11 | 12 | class TestRun: 13 | """ 14 | Tests for the Adaptor._run method 15 | """ 16 | 17 | _OPENJD_PROGRESS_STDOUT_PREFIX: str = "openjd_progress: " 18 | _OPENJD_STATUS_STDOUT_PREFIX: str = "openjd_status: " 19 | 20 | def test_run(self, capsys) -> None: 21 | first_progress = 0.0 22 | first_status_message = "Starting the printing of run_data" 23 | second_progress = 100.0 24 | second_status_message = "Finished printing" 25 | 26 | class PrintAdaptor(Adaptor): 27 | """ 28 | Test implementation of an Adaptor. 29 | """ 30 | 31 | def on_run(self, run_data: dict): 32 | # This run funciton will simply print the run_data. 33 | self.update_status(progress=first_progress, status_message=first_status_message) 34 | print("run_data:") 35 | for key, value in run_data.items(): 36 | print(f"\t{key} = {value}") 37 | self.update_status(progress=second_progress, status_message=second_status_message) 38 | 39 | @property 40 | def integration_data_interface_version(self) -> SemanticVersion: 41 | return SemanticVersion(major=0, minor=1) 42 | 43 | # GIVEN 44 | init_data: dict = {} 45 | run_data: dict = {"key1": "value1", "key2": "value2", "key3": "value3"} 46 | adaptor = PrintAdaptor(init_data) 47 | 48 | # WHEN 49 | adaptor._run(run_data) 50 | result = capsys.readouterr().out.strip() 51 | 52 | # THEN 53 | assert f"{self._OPENJD_PROGRESS_STDOUT_PREFIX}{first_progress}" in result 54 | assert f"{self._OPENJD_STATUS_STDOUT_PREFIX}{first_status_message}" in result 55 | assert f"{self._OPENJD_PROGRESS_STDOUT_PREFIX}{second_progress}" in result 56 | assert f"{self._OPENJD_STATUS_STDOUT_PREFIX}{second_status_message}" in result 57 | assert "run_data:\n\tkey1 = value1\n\tkey2 = value2\n\tkey3 = value3" in result 58 | 59 | def test_start_end_cleanup(self, tmpdir, capsys) -> None: 60 | """ 61 | We are going to test the start and end methods 62 | """ 63 | 64 | class FileAdaptor(Adaptor): 65 | def on_start(self): 66 | # Open a temp file 67 | self.f = tmpdir.mkdir("test").join("hello.txt") 68 | 69 | def on_run(self, run_data: dict): 70 | # Write hello world to temp file 71 | self.f.write("Hello World from FileAdaptor!") 72 | 73 | def on_stop(self): 74 | # Read from temp file 75 | print(self.f.read()) 76 | 77 | def on_cleanup(self): 78 | # Delete temp file 79 | path = Path(str(self.f)) 80 | parent_dir = path.parent.absolute() 81 | os.remove(str(self.f)) 82 | shutil.rmtree(parent_dir) 83 | 84 | @property 85 | def integration_data_interface_version(self) -> SemanticVersion: 86 | return SemanticVersion(major=0, minor=1) 87 | 88 | init_dict: dict = {} 89 | fa = FileAdaptor(init_dict) 90 | 91 | # Creates the path for the temp file. 92 | fa._start() 93 | 94 | # Writes to the temp file 95 | fa._run({}) 96 | 97 | # The file exists after writing. 98 | assert os.path.exists(str(fa.f)) 99 | 100 | # Printing the contents of the file. 101 | fa._stop() 102 | assert capsys.readouterr().out.strip() == "Hello World from FileAdaptor!" 103 | 104 | # Deleting the file created before. 105 | fa._cleanup() 106 | assert not os.path.exists(str(fa.f)) 107 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | import platform 3 | import random 4 | import string 5 | from typing import Generator 6 | from unittest.mock import MagicMock, patch 7 | 8 | import pytest 9 | 10 | from openjd.adaptor_runtime._osname import OSName 11 | from openjd.adaptor_runtime._http import sockets 12 | 13 | if OSName.is_windows(): 14 | import win32net 15 | import win32netcon 16 | 17 | 18 | # List of platforms that can be used to mark tests as specific to that platform 19 | # See [tool.pytest.ini_options] -> markers in pyproject.toml 20 | _PLATFORMS = {"Linux", "Windows", "Darwin"} 21 | 22 | 23 | def pytest_runtest_setup(item: pytest.Item): 24 | """ 25 | Hook that is run for each test. 26 | """ 27 | 28 | # Skip platform-specific tests that don't apply to current platform 29 | supported_platforms = set(_PLATFORMS).intersection(mark.name for mark in item.iter_markers()) 30 | plat = platform.system() 31 | if supported_platforms and plat not in supported_platforms: 32 | pytest.skip(f"Skipping non-{plat} test: {item.name}") 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | def win_test_user() -> Generator: 37 | def generate_strong_password() -> str: 38 | password_length = 14 39 | 40 | # Generate at least one character from each category 41 | uppercase = random.choice(string.ascii_uppercase) 42 | lowercase = random.choice(string.ascii_lowercase) 43 | digit = random.choice(string.digits) 44 | special_char = random.choice(string.punctuation) 45 | 46 | # Ensure the rest of the password is made up of a random mix of characters 47 | remaining_length = password_length - 4 48 | other_chars = "".join( 49 | random.choice(string.ascii_letters + string.digits + string.punctuation) 50 | for _ in range(remaining_length) 51 | ) 52 | 53 | # Combine and shuffle 54 | password_characters = list(uppercase + lowercase + digit + special_char + other_chars) 55 | random.shuffle(password_characters) 56 | return "".join(password_characters) 57 | 58 | username = "RuntimeAdaptorTester" 59 | # No one need to know this password. So we will generate it randomly. 60 | password = generate_strong_password() 61 | 62 | def create_user() -> None: 63 | try: 64 | win32net.NetUserGetInfo(None, username, 1) 65 | print(f"User '{username}' already exists. Skip the User Creation") 66 | except win32net.error: 67 | # https://learn.microsoft.com/en-us/windows/win32/api/lmaccess/nf-lmaccess-netuseradd#examples 68 | user_info = { 69 | "name": username, 70 | "password": password, 71 | # The privilege level of the user. USER_PRIV_USER is a standard user. 72 | "priv": win32netcon.USER_PRIV_USER, 73 | "home_dir": None, 74 | "comment": None, 75 | # Account control flags. UF_SCRIPT is required here. 76 | "flags": win32netcon.UF_SCRIPT, 77 | "script_path": None, 78 | } 79 | try: 80 | win32net.NetUserAdd(None, 1, user_info) 81 | print(f"User '{username}' created successfully.") 82 | except Exception as e: 83 | print(f"Failed to create user '{username}': {e}") 84 | raise e 85 | 86 | def delete_user() -> None: 87 | try: 88 | win32net.NetUserDel(None, username) 89 | print(f"User '{username}' deleted successfully.") 90 | except win32net.error as e: 91 | print(f"Failed to delete user '{username}': {e}") 92 | raise e 93 | 94 | create_user() 95 | yield username, password 96 | # Delete the user after test completes 97 | delete_user() 98 | 99 | 100 | @pytest.fixture(scope="session", autouse=OSName().is_macos()) 101 | def mock_sockets_py_tempfile_gettempdir_to_slash_tmp() -> Generator[MagicMock, None, None]: 102 | """ 103 | Mock that is automatically used on Mac to override the tempfile.gettempdir() usages in sockets.py 104 | because the folder returned by it on Mac is too long for the socket name to fit (max 104 bytes, see sockets.py) 105 | """ 106 | with patch.object(sockets.tempfile, "gettempdir", return_value="/tmp") as m: 107 | yield m 108 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime_client/connection.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import socket as _socket 6 | import ctypes as _ctypes 7 | import os as _os 8 | from sys import platform 9 | from typing import Any 10 | from http.client import HTTPConnection as _HTTPConnection 11 | 12 | 13 | class UnrecognizedBackgroundConnectionError(Exception): 14 | pass 15 | 16 | 17 | class UCred(_ctypes.Structure): 18 | """ 19 | Represents the ucred struct returned from the SO_PEERCRED socket option. 20 | 21 | For more info, see SO_PASSCRED in the unix(7) man page 22 | """ 23 | 24 | _fields_ = [ 25 | ("pid", _ctypes.c_int), 26 | ("uid", _ctypes.c_int), 27 | ("gid", _ctypes.c_int), 28 | ] 29 | 30 | def __str__(self): # pragma: no cover 31 | return f"pid:{self.pid} uid:{self.uid} gid:{self.gid}" 32 | 33 | 34 | class XUCred(_ctypes.Structure): 35 | """ 36 | Represents the xucred struct returned from the LOCAL_PEERCRED socket option. 37 | 38 | For more info, see LOCAL_PEERCRED in the unix(4) man page 39 | """ 40 | 41 | _fields_ = [ 42 | ("version", _ctypes.c_uint), 43 | ("uid", _ctypes.c_uint), 44 | ("ngroups", _ctypes.c_short), 45 | # cr_groups is a uint array of NGROUPS elements, which is defined as 16 46 | # source: 47 | # - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/ucred.h#L207 48 | # - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/param.h#L100 49 | # - https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/syslimits.h#L100 50 | ("groups", _ctypes.c_uint * 16), 51 | ] 52 | 53 | 54 | class UnixHTTPConnection(_HTTPConnection): # pragma: no cover 55 | """ 56 | Specialization of http.client.HTTPConnection class that uses a UNIX domain socket. 57 | """ 58 | 59 | def __init__(self, host, **kwargs): 60 | kwargs.pop("strict", None) # Removed in py3 61 | super(UnixHTTPConnection, self).__init__(host, **kwargs) 62 | 63 | def connect(self): 64 | sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) # type: ignore[attr-defined] 65 | sock.settimeout(self.timeout) 66 | sock.connect(self.host) 67 | self.sock = sock 68 | 69 | # Verify that the socket belongs to the same user 70 | if not self._authenticate(): 71 | sock.detach(self.sock) 72 | raise UnrecognizedBackgroundConnectionError( 73 | "Attempted to make a connection to a background server owned by another user." 74 | ) 75 | 76 | def _authenticate(self) -> bool: 77 | # Verify we have a UNIX socket. 78 | if not ( 79 | isinstance(self.sock, _socket.socket) 80 | and self.sock.family == _socket.AddressFamily.AF_UNIX # type: ignore[attr-defined] 81 | ): 82 | raise NotImplementedError( 83 | "Failed to handle request because it was not made through a UNIX socket" 84 | ) 85 | 86 | peercred_opt_level: Any 87 | peercred_opt: Any 88 | cred_cls: Any 89 | if platform == "darwin": 90 | # SOL_LOCAL is not defined in Python's socket module, need to hardcode it 91 | # source: https://github.com/apple-oss-distributions/xnu/blob/1031c584a5e37aff177559b9f69dbd3c8c3fd30a/bsd/sys/un.h#L85 92 | peercred_opt_level = 0 # type: ignore[attr-defined] 93 | peercred_opt = _socket.LOCAL_PEERCRED # type: ignore[attr-defined] 94 | cred_cls = XUCred 95 | else: 96 | peercred_opt_level = _socket.SOL_SOCKET # type: ignore[attr-defined] 97 | peercred_opt = _socket.SO_PEERCRED # type: ignore[attr-defined] 98 | cred_cls = UCred 99 | 100 | # Get the credentials of the peer process 101 | cred_buffer = self.sock.getsockopt( 102 | peercred_opt_level, 103 | peercred_opt, 104 | _socket.CMSG_SPACE(_ctypes.sizeof(cred_cls)), # type: ignore[attr-defined] 105 | ) 106 | peer_cred = cred_cls.from_buffer_copy(cred_buffer) 107 | 108 | # Only allow connections from a process running as the same user 109 | return peer_cred.uid == _os.getuid() # type: ignore[attr-defined] 110 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime_client/integ/test_integration_client_interface.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import signal 6 | import sys 7 | import subprocess as _subprocess 8 | from os.path import dirname as _dirname, join as _join, realpath as _realpath 9 | from time import sleep as _sleep 10 | from typing import Dict, Any 11 | 12 | from openjd.adaptor_runtime._osname import OSName 13 | 14 | 15 | class TestIntegrationClientInterface: 16 | """These are the integration tests for the client interface.""" 17 | 18 | def test_graceful_shutdown(self) -> None: 19 | # Create the subprocess 20 | popen_params: Dict[str, Any] = dict( 21 | args=[ 22 | sys.executable, 23 | _join(_dirname(_realpath(__file__)), "fake_client.py"), 24 | ], 25 | stdin=_subprocess.PIPE, 26 | stderr=_subprocess.PIPE, 27 | stdout=_subprocess.PIPE, 28 | encoding="utf-8", 29 | ) 30 | if OSName.is_windows(): 31 | # In Windows, this is required for signal. SIGBREAK will be sent to the entire process group. 32 | # Without this one, current process will also get the SIGBREAK and may react incorrectly. 33 | popen_params.update(creationflags=_subprocess.CREATE_NEW_PROCESS_GROUP) # type: ignore[attr-defined] 34 | client_subprocess = _subprocess.Popen(**popen_params) 35 | 36 | # To avoid a race condition, giving some extra time for the logging subprocess to start. 37 | _sleep(0.5 if OSName.is_posix() else 4) 38 | signal_type: signal.Signals 39 | if OSName.is_windows(): 40 | signal_type = signal.CTRL_BREAK_EVENT # type: ignore[attr-defined] 41 | else: 42 | signal_type = signal.SIGTERM 43 | 44 | assert client_subprocess.returncode is None 45 | client_subprocess.send_signal(signal_type) 46 | 47 | # To avoid a race condition, giving some extra time for the log to be updated after 48 | # receiving the signal. 49 | _sleep(0.5 if OSName.is_posix() else 4) 50 | 51 | out, _ = client_subprocess.communicate() 52 | 53 | assert f"Received {'SIGBREAK' if OSName.is_windows() else 'SIGTERM'} signal." in out 54 | # Ensure the process actually shutdown 55 | assert client_subprocess.returncode is not None 56 | 57 | def test_client_in_thread_does_not_do_graceful_shutdown(self) -> None: 58 | """Ensures that a client running in a thread does not crash by attempting to register a signal, 59 | since they can only be created in the main thread. This means the graceful shutdown is effectively 60 | ignored.""" 61 | # Create the subprocess 62 | popen_params: Dict[str, Any] = dict( 63 | args=[ 64 | sys.executable, 65 | _join(_dirname(_realpath(__file__)), "fake_client.py"), 66 | "--run-in-thread", 67 | ], 68 | stdin=_subprocess.PIPE, 69 | stderr=_subprocess.PIPE, 70 | stdout=_subprocess.PIPE, 71 | encoding="utf-8", 72 | ) 73 | if OSName.is_windows(): 74 | # In Windows, this is required for signal. SIGBREAK will be sent to the entire process group. 75 | # Without this one, current process will also get the SIGBREAK and may react incorrectly. 76 | popen_params.update(creationflags=_subprocess.CREATE_NEW_PROCESS_GROUP) # type: ignore[attr-defined] 77 | client_subprocess = _subprocess.Popen(**popen_params) 78 | 79 | # To avoid a race condition, giving some extra time for the logging subprocess to start. 80 | _sleep(0.5 if OSName.is_posix() else 4) 81 | signal_type: signal.Signals 82 | if OSName.is_windows(): 83 | signal_type = signal.CTRL_BREAK_EVENT # type: ignore[attr-defined] 84 | else: 85 | signal_type = signal.SIGTERM 86 | 87 | client_subprocess.send_signal(signal_type) 88 | _sleep(0.5) 89 | # Ensure the process is still running 90 | assert client_subprocess.returncode is None 91 | out, err = client_subprocess.communicate() 92 | assert "ValueError: signal only works in main thread of the main interpreter" not in err 93 | assert f"Received {'SIGBREAK' if OSName.is_windows() else 'SIGTERM'} signal." not in out 94 | 95 | # Ensure the process stops 96 | client_subprocess.kill() 97 | assert client_subprocess.returncode not in (None, 0) 98 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/adaptors/configuration/test_configuration_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | import tempfile 8 | from typing import IO 9 | 10 | import pytest 11 | 12 | from openjd.adaptor_runtime.adaptors.configuration import Configuration, ConfigurationManager 13 | 14 | 15 | class TestConfigurationManager: 16 | """ 17 | Integration tests for ConfigurationManager 18 | """ 19 | 20 | @pytest.fixture 21 | def json_schema_file(self): 22 | json_schema = { 23 | "$schema": "https://json-schema.org/draft/2020-12/schema", 24 | "type": "object", 25 | "properties": { 26 | "key": {"enum": ["value"]}, 27 | "syskey": {"type": "string"}, 28 | "usrkey": {"type": "string"}, 29 | }, 30 | } 31 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as schema_file: 32 | json.dump(json_schema, schema_file.file) 33 | schema_file.seek(0) 34 | yield schema_file 35 | os.remove(schema_file.name) 36 | 37 | def test_gets_system_config(self, json_schema_file: IO[str]): 38 | # GIVEN 39 | config = {"key": "value"} 40 | 41 | try: 42 | with tempfile.NamedTemporaryFile(mode="w+", delete=False) as config_file: 43 | json.dump(config, config_file.file) 44 | config_file.seek(0) 45 | manager = ConfigurationManager( 46 | config_cls=Configuration, 47 | schema_path=json_schema_file.name, 48 | system_config_path=config_file.name, 49 | # These fields can be empty since they will not be used in this test 50 | default_config_path="", 51 | user_config_rel_path="", 52 | ) 53 | 54 | # WHEN 55 | sys_config = manager.get_system_config() 56 | 57 | # THEN 58 | assert sys_config is not None and sys_config._config == config 59 | 60 | finally: 61 | if os.path.exists(config_file.name): 62 | os.remove(config_file.name) 63 | 64 | def test_builds_config(self, json_schema_file: IO[str]): 65 | # GIVEN 66 | default_config = { 67 | "key": "value", 68 | "syskey": "value", 69 | "usrkey": "value", 70 | } 71 | system_config = {"syskey": "system"} 72 | user_config = {"usrkey": "user"} 73 | 74 | homedir = os.path.expanduser("~") 75 | 76 | try: 77 | with ( 78 | tempfile.NamedTemporaryFile(mode="w+", delete=False) as default_config_file, 79 | tempfile.NamedTemporaryFile(mode="w+", delete=False) as system_config_file, 80 | tempfile.NamedTemporaryFile( 81 | mode="w+", dir=homedir, delete=False 82 | ) as user_config_file, 83 | ): 84 | json.dump(default_config, default_config_file) 85 | json.dump(system_config, system_config_file) 86 | json.dump(user_config, user_config_file) 87 | default_config_file.seek(0) 88 | system_config_file.seek(0) 89 | user_config_file.seek(0) 90 | 91 | manager = ConfigurationManager( 92 | config_cls=Configuration, 93 | schema_path=json_schema_file.name, 94 | default_config_path=default_config_file.name, 95 | system_config_path=system_config_file.name, 96 | user_config_rel_path=os.path.relpath( 97 | user_config_file.name, 98 | start=os.path.expanduser("~"), 99 | ), 100 | ) 101 | 102 | # WHEN 103 | result = manager.build_config() 104 | 105 | # THEN 106 | assert result._config == {**default_config, **system_config, **user_config} 107 | 108 | finally: 109 | if os.path.exists(default_config_file.name): 110 | os.remove(default_config_file.name) 111 | if os.path.exists(system_config_file.name): 112 | os.remove(system_config_file.name) 113 | if os.path.exists(user_config_file.name): 114 | os.remove(user_config_file.name) 115 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/process/test_stream_logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | """Tests for LoggingSubprocess""" 4 | from __future__ import annotations 5 | 6 | import logging 7 | import os 8 | from typing import List 9 | from unittest import mock 10 | 11 | import pytest 12 | 13 | import openjd.adaptor_runtime.process._stream_logger as stream_logger 14 | from openjd.adaptor_runtime.process._stream_logger import StreamLogger 15 | 16 | 17 | class TestStreamLogger(object): 18 | """Tests for StreamLogger""" 19 | 20 | @pytest.fixture(autouse=True) 21 | def mock_thread(self): 22 | with mock.patch.object(stream_logger, "Thread") as mock_thread: 23 | yield mock_thread 24 | 25 | def test_not_daemon_default(self): 26 | # GIVEN 27 | stream = mock.Mock() 28 | logger = mock.Mock() 29 | 30 | # WHEN 31 | subject = StreamLogger(stream=stream, loggers=[logger]) 32 | 33 | # THEN 34 | assert not subject.daemon 35 | 36 | @pytest.mark.parametrize( 37 | ("lines",), 38 | ( 39 | (["foo", "bar"],), 40 | (["foo"],), 41 | ([],), 42 | ), 43 | ) 44 | def test_level_info_default(self, lines: List[str]): 45 | # GIVEN 46 | # stream.readline() includes newline characters 47 | readline_returns = [f"{line}{os.linesep}" for line in lines] 48 | stream = mock.Mock() 49 | stream.closed = False 50 | # stream.readline() returns an empty string on EOF 51 | stream.readline.side_effect = readline_returns + [""] 52 | logger = mock.Mock() 53 | subject = StreamLogger(stream=stream, loggers=[logger]) 54 | 55 | # WHEN 56 | subject.run() 57 | 58 | # THEN 59 | logger.log.assert_has_calls([mock.call(logging.INFO, line) for line in lines]) 60 | 61 | def test_supplied_logging_level(self): 62 | # GIVEN 63 | level = logging.CRITICAL 64 | log_line = "foo" 65 | # stream.readline() includes newline characters 66 | readline_returns = [f"{log_line}{os.linesep}"] 67 | stream = mock.Mock() 68 | stream.closed = False 69 | # stream.readline() returns an empty string on EOF 70 | stream.readline.side_effect = readline_returns + [""] 71 | logger = mock.Mock() 72 | subject = StreamLogger(stream=stream, loggers=[logger], level=level) 73 | 74 | # WHEN 75 | subject.run() 76 | 77 | # THEN 78 | logger.log.assert_has_calls([mock.call(level, log_line)]) 79 | 80 | def test_multiple_loggers(self): 81 | # GIVEN 82 | level = logging.INFO 83 | log_line = "foo" 84 | loggers = [mock.Mock() for _ in range(5)] 85 | # stream.readline() includes newline characters 86 | readline_returns = [f"{log_line}{os.linesep}"] 87 | stream = mock.Mock() 88 | stream.closed = False 89 | # stream.readline() returns an empty string on EOF 90 | stream.readline.side_effect = readline_returns + [""] 91 | subject = StreamLogger(stream=stream, loggers=loggers, level=level) 92 | 93 | # WHEN 94 | subject.run() 95 | 96 | # THEN 97 | for logger in loggers: 98 | logger.log.assert_has_calls([mock.call(level, log_line)]) 99 | 100 | def test_readline_failure_raises(self): 101 | # GIVEN 102 | err = ValueError() 103 | stream = mock.Mock() 104 | stream.readline.side_effect = err 105 | subject = StreamLogger(stream=stream, loggers=[mock.Mock()]) 106 | 107 | # WHEN 108 | with pytest.raises(ValueError) as raised_err: 109 | subject.run() 110 | 111 | # THEN 112 | assert raised_err.value is err 113 | stream.readline.assert_called_once() 114 | 115 | def test_io_failure_logs_error(self): 116 | # GIVEN 117 | err = ValueError("I/O operation on closed file") 118 | stream = mock.Mock() 119 | stream.readline.side_effect = err 120 | logger = mock.Mock() 121 | subject = StreamLogger(stream=stream, loggers=[logger]) 122 | 123 | # WHEN 124 | subject.run() 125 | 126 | # THEN 127 | stream.readline.assert_called_once() 128 | logger.log.assert_called_once_with( 129 | stream_logger.logging.WARNING, 130 | "The StreamLogger could not read from the stream. This is most likely because " 131 | "the stream was closed before the stream logger.", 132 | ) 133 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/utils/test_secure_open.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | import os 4 | import stat 5 | from unittest.mock import mock_open, patch 6 | 7 | import pytest 8 | 9 | from openjd.adaptor_runtime._osname import OSName 10 | from openjd.adaptor_runtime._utils import secure_open 11 | 12 | READ_FLAGS = os.O_RDONLY 13 | WRITE_FLAGS = os.O_WRONLY | os.O_TRUNC | os.O_CREAT 14 | APPEND_FLAGS = os.O_WRONLY | os.O_APPEND | os.O_CREAT 15 | EXCL_FLAGS = os.O_EXCL | os.O_CREAT | os.O_WRONLY 16 | UPDATE_FLAGS = os.O_RDWR | os.O_CREAT 17 | 18 | FLAG_DICT = { 19 | "r": READ_FLAGS, 20 | "w": WRITE_FLAGS, 21 | "a": APPEND_FLAGS, 22 | "x": EXCL_FLAGS, 23 | "+": UPDATE_FLAGS, 24 | "": 0, 25 | } 26 | 27 | 28 | @pytest.mark.parametrize( 29 | argnames=["path", "open_mode", "mask", "expected_os_open_kwargs"], 30 | argvalues=[ 31 | ( 32 | "/path/to/file", 33 | "".join((mode, update_flag)), 34 | mask, 35 | { 36 | "path": "/path/to/file", 37 | "flags": FLAG_DICT[mode] | FLAG_DICT[update_flag], 38 | "mode": stat.S_IWUSR | stat.S_IRUSR | mask, 39 | }, 40 | ) 41 | for mode in ("r", "w", "a", "x") 42 | for update_flag in ("", "+") 43 | for mask in (stat.S_IRGRP | stat.S_IWGRP, 0) 44 | ], 45 | ) 46 | @patch.object(os, "open") 47 | @pytest.mark.skipif(not OSName.is_posix(), reason="Posix-specific tests") 48 | def test_secure_open_in_posix(mock_os_open, path, open_mode, mask, expected_os_open_kwargs): 49 | # WHEN 50 | with patch("builtins.open", mock_open()) as mocked_open: 51 | secure_open_kwargs = {"mask": mask} if mask else {} 52 | with secure_open(path, open_mode, **secure_open_kwargs): 53 | pass 54 | 55 | # THEN 56 | if open_mode == "r": 57 | del expected_os_open_kwargs["mode"] 58 | mock_os_open.assert_called_once_with(**expected_os_open_kwargs) 59 | mocked_open.assert_called_once_with(mock_os_open.return_value, open_mode) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | argnames=["path", "open_mode", "expected_os_open_kwargs"], 64 | argvalues=[ 65 | ( 66 | "/path/to/file", 67 | "".join((mode, update_flag)), 68 | { 69 | "path": "/path/to/file", 70 | "flags": FLAG_DICT[mode] | FLAG_DICT[update_flag], 71 | }, 72 | ) 73 | for mode in ("r", "w", "a", "x") 74 | for update_flag in ("", "+") 75 | ], 76 | ) 77 | @patch.object(os, "open") 78 | @patch("openjd.adaptor_runtime._utils._secure_open.set_file_permissions_in_windows") 79 | @pytest.mark.skipif(not OSName.is_windows(), reason="Windows-specific tests") 80 | def test_secure_open_in_windows( 81 | mock_file_permission_setting, mock_os_open, path, open_mode, expected_os_open_kwargs 82 | ): 83 | # WHEN 84 | with patch("builtins.open", mock_open()) as mocked_open: 85 | secure_open_kwargs = {} 86 | with secure_open(path, open_mode, **secure_open_kwargs): 87 | pass 88 | 89 | # THEN 90 | mock_os_open.assert_called_once_with(**expected_os_open_kwargs) 91 | mocked_open.assert_called_once_with(mock_os_open.return_value, open_mode) 92 | 93 | 94 | @pytest.mark.parametrize( 95 | argnames=["path", "open_mode", "encoding", "newline"], 96 | argvalues=[ 97 | ( 98 | "/path/to/file", 99 | "w", 100 | encoding, 101 | newline, 102 | ) 103 | for encoding in ("utf-8", "utf-16", None) 104 | for newline in ("\n", "\r\n", None) 105 | ], 106 | ) 107 | @patch.object(os, "open") 108 | @patch("openjd.adaptor_runtime._utils._secure_open.set_file_permissions_in_windows") 109 | def test_secure_open_passes_open_kwargs( 110 | mock_file_permission_setting, mock_os_open, path, open_mode, encoding, newline 111 | ): 112 | # WHEN 113 | open_kwargs = {} 114 | if encoding: 115 | open_kwargs["encoding"] = encoding 116 | if newline: 117 | open_kwargs["newline"] = newline 118 | 119 | with patch("builtins.open", mock_open()) as mocked_open: 120 | with secure_open(path, open_mode, **open_kwargs): 121 | pass 122 | 123 | # THEN 124 | mocked_open.assert_called_once_with(mock_os_open.return_value, open_mode, **open_kwargs) 125 | 126 | 127 | def test_raises_when_nonvalid_mode(): 128 | # WHEN 129 | with pytest.raises(ValueError) as exc_info: 130 | with secure_open("/path/to/file", "something"): 131 | pass 132 | 133 | # THEN 134 | assert str(exc_info.value) == "Nonvalid mode: 'something'" 135 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/named_pipe/test_named_pipe_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from openjd.adaptor_runtime._osname import OSName 4 | from unittest.mock import patch, MagicMock 5 | import pytest 6 | import os 7 | import time 8 | 9 | pywintypes = pytest.importorskip("pywintypes") 10 | win32pipe = pytest.importorskip("win32pipe") 11 | win32file = pytest.importorskip("win32file") 12 | winerror = pytest.importorskip("winerror") 13 | named_pipe_helper = pytest.importorskip( 14 | "openjd.adaptor_runtime_client.named_pipe.named_pipe_helper" 15 | ) 16 | 17 | 18 | class MockReadFile: 19 | @staticmethod 20 | def ReadFile(handle: pywintypes.HANDLE, timeout_in_seconds: float): # type: ignore[name-defined] 21 | time.sleep(10) 22 | return winerror.NO_ERROR, bytes("fake_data", "utf-8") 23 | 24 | 25 | @pytest.mark.skipif(not OSName.is_windows(), reason="Windows-specific tests") 26 | class TestNamedPipeHelper: 27 | def test_named_pipe_read_timeout_exception(self): 28 | with pytest.raises( 29 | named_pipe_helper.NamedPipeReadTimeoutError, 30 | match="NamedPipe Server read timeout after 1.0 seconds.$", 31 | ): 32 | raise named_pipe_helper.NamedPipeReadTimeoutError(1.0) 33 | 34 | def test_named_pipe_connect_timeout_exception(self): 35 | exception_during_connect = Exception("Fake exception that occurred while connecting.") 36 | expected_error_message = os.linesep.join( 37 | [ 38 | "NamedPipe Server connect timeout after 1.0 seconds.", 39 | f"Original error: {exception_during_connect}", 40 | ] 41 | ) 42 | with pytest.raises( 43 | named_pipe_helper.NamedPipeConnectTimeoutError, match=expected_error_message 44 | ): 45 | raise named_pipe_helper.NamedPipeConnectTimeoutError(1.0, exception_during_connect) 46 | 47 | @patch.object( 48 | win32file, "CreateFile", side_effect=win32file.error(winerror.ERROR_FILE_NOT_FOUND) 49 | ) 50 | def test_establish_named_pipe_connection_timeout_raises_exception(self, mock_win32file): 51 | with pytest.raises( 52 | named_pipe_helper.NamedPipeConnectTimeoutError, 53 | match=os.linesep.join( 54 | [ 55 | "NamedPipe Server connect timeout after \\d\\.\\d+ seconds.", 56 | f"Original error: {win32file.error(winerror.ERROR_FILE_NOT_FOUND)}", 57 | ] 58 | ), 59 | ): 60 | named_pipe_helper.NamedPipeHelper.establish_named_pipe_connection("fakepipe", 1.0) 61 | 62 | @patch.object(win32file, "ReadFile", wraps=MockReadFile.ReadFile) 63 | def test_read_from_pipe_timeout_raises_exception(self, mock_win32file): 64 | mock_handle = MagicMock() 65 | with pytest.raises( 66 | named_pipe_helper.NamedPipeReadTimeoutError, 67 | match="NamedPipe Server read timeout after \\d\\.\\d+ seconds.$", 68 | ): 69 | named_pipe_helper.NamedPipeHelper.read_from_pipe(mock_handle, 1.0) 70 | 71 | mock_handle.close.assert_called_once() 72 | 73 | @patch("os.getpid", return_value=1) 74 | @patch( 75 | "openjd.adaptor_runtime_client.named_pipe.named_pipe_helper.NamedPipeHelper.check_named_pipe_exists", 76 | return_value=False, 77 | ) 78 | def test_generate_pipe_name(self, mock_check_named_pipe_exists, mock_getpid): 79 | name = named_pipe_helper.NamedPipeHelper.generate_pipe_name("AdaptorTest") 80 | assert name == r"\\.\pipe\AdaptorTest_1" 81 | 82 | @patch("os.getpid", return_value=1) 83 | @patch( 84 | "openjd.adaptor_runtime_client.named_pipe.named_pipe_helper.NamedPipeHelper.check_named_pipe_exists", 85 | side_effect=[True, False], 86 | ) 87 | def test_generate_pipe_name2(self, mock_check_named_pipe_exists, mock_getpid): 88 | # This test is to ensure that the pipe name will change when it already exists. 89 | name = named_pipe_helper.NamedPipeHelper.generate_pipe_name("AdaptorTest") 90 | assert r"\\.\pipe\AdaptorTest_1_0_" in name 91 | 92 | @patch("os.getpid", return_value=1) 93 | @patch( 94 | "openjd.adaptor_runtime_client.named_pipe.named_pipe_helper.NamedPipeHelper.check_named_pipe_exists", 95 | return_value=True, 96 | ) 97 | def test_failed_to_generate_pipe_name(self, mock_check_named_pipe_exists, mock_getpid): 98 | with pytest.raises( 99 | named_pipe_helper.NamedPipeNamingError, 100 | match="Cannot find an available pipe name.", 101 | ): 102 | named_pipe_helper.NamedPipeHelper.generate_pipe_name("AdaptorTest") 103 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_adaptor_server_response.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import sys 7 | from http import HTTPStatus 8 | from time import sleep 9 | from typing import TYPE_CHECKING, Callable, Dict, Any, Optional, Union 10 | 11 | from .._http import HTTPResponse 12 | 13 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 14 | from openjd.adaptor_runtime_client import Action 15 | from ._adaptor_server import AdaptorServer 16 | from ._win_adaptor_server import WinAdaptorServer 17 | 18 | 19 | class AdaptorServerResponseGenerator: 20 | """ 21 | This class is used for generating responses for all requests to the Adaptor server. 22 | Response methods follow format: `generate_{request_path}_{method}_response` 23 | """ 24 | 25 | def __init__( 26 | self, 27 | server: Union[AdaptorServer, WinAdaptorServer], 28 | response_fn: Callable, 29 | query_string_params: Dict[str, Any], 30 | ) -> None: 31 | """ 32 | Response generator 33 | 34 | Args: 35 | server: The server used for communication. For Linux, this will 36 | be a AdaptorServer instance. 37 | response_fn: The function used to return the result to the client. 38 | For Linux, this will be an HTTPResponse instance. 39 | query_string_params: The request parameters sent by the client. 40 | For Linux, these will be extracted from the URL. 41 | """ 42 | self.server = server 43 | self.response_method = response_fn 44 | self.query_string_params = query_string_params 45 | 46 | def generate_path_mapping_get_response(self) -> HTTPResponse: 47 | """ 48 | Handle GET request to /path_mapping path. 49 | 50 | Returns: 51 | HTTPResponse: A body and response code to send to the DCC Client 52 | """ 53 | 54 | if "path" in self.query_string_params: 55 | return self.response_method( 56 | HTTPStatus.OK, 57 | json.dumps( 58 | {"path": self.server.adaptor.map_path(self.query_string_params["path"][0])} 59 | ), 60 | ) 61 | else: 62 | return self.response_method(HTTPStatus.BAD_REQUEST, "Missing path in query string.") 63 | 64 | def generate_path_mapping_rules_get_response(self) -> HTTPResponse: 65 | """ 66 | Handle GET request to /path_mapping_rules path. 67 | 68 | Returns: 69 | HTTPResponse: A body and response code to send to the DCC Client 70 | """ 71 | return self.response_method( 72 | HTTPStatus.OK, 73 | json.dumps( 74 | { 75 | "path_mapping_rules": [ 76 | rule.to_dict() for rule in self.server.adaptor.path_mapping_rules 77 | ] 78 | } 79 | ), 80 | ) 81 | 82 | def generate_action_get_response(self) -> HTTPResponse: 83 | """ 84 | Handle GET request to /action path. 85 | 86 | Returns: 87 | HTTPResponse: A body and response code to send to the DCC Client 88 | """ 89 | action = self._dequeue_action() 90 | 91 | # We are going to wait until we have an action in the queue. This 92 | # could happen between tasks. 93 | while action is None: 94 | sleep(0.01) 95 | action = self._dequeue_action() 96 | 97 | return self.response_method(HTTPStatus.OK, str(action)) 98 | 99 | def _dequeue_action(self) -> Optional[Action]: 100 | """This function will dequeue the first action in the queue. 101 | 102 | Returns: 103 | Action: A tuple containing the next action structured: 104 | ("action_name", { "args1": "val1", "args2": "val2" }) 105 | 106 | None: If the Actions Queue is empty. 107 | 108 | Raises: 109 | TypeError: If the server isn't an AdaptorServer. 110 | """ 111 | # This condition shouldn't matter, because we have typehinted the server above. 112 | # This is only here for type hinting (as is the return None below). 113 | if hasattr(self, "server") and hasattr(self.server, "actions_queue"): 114 | return self.server.actions_queue.dequeue_action() 115 | 116 | print( 117 | "ERROR: Could not retrieve the next action because the server or actions queue " 118 | "wasn't set.", 119 | file=sys.stderr, 120 | flush=True, 121 | ) 122 | return None 123 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/app_handlers/_regex_callback_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import re 7 | from dataclasses import dataclass 8 | from typing import Callable, List, Sequence 9 | 10 | 11 | @dataclass 12 | class RegexCallback: 13 | """ 14 | Dataclass for regex callbacks 15 | """ 16 | 17 | regex_list: List[re.Pattern[str]] 18 | callback: Callable[[re.Match], None] 19 | exit_if_matched: bool = False 20 | only_run_if_first_matched: bool = False 21 | 22 | def __init__( 23 | self, 24 | regex_list: Sequence[re.Pattern[str]], 25 | callback: Callable[[re.Match], None], 26 | exit_if_matched: bool = False, 27 | only_run_if_first_matched: bool = False, 28 | ) -> None: 29 | """ 30 | Initializes a RegexCallback 31 | 32 | Args: 33 | regex_list (Sequence[re.Pattern[str]]): A sequence of regex patterns which will invoke 34 | the callback if any single regex matches a logged string. This will be stored as a 35 | separate list object than the sequence passed in the constructor. 36 | callback (Callable[[re.Match], None]): A callable which takes a re.Match object as the 37 | only argument. The re.Match object is from the pattern that matched the string 38 | tested against it. 39 | exit_if_matched (bool, optional): Indicates if the handler should exit early if this 40 | RegexCallback is matched. This will prevent future RegexCallbacks from being 41 | invoked if this RegexCallback matched first. Defaults to False. 42 | only_run_if_first_matched (bool, optional): Indicates if the handler should only 43 | call the callback if this RegexCallback was the first to have a regex match a logged 44 | line. 45 | """ 46 | self.regex_list = list(regex_list) 47 | self.callback = callback 48 | self.exit_if_matched = exit_if_matched 49 | self.only_run_if_first_matched = only_run_if_first_matched 50 | 51 | def get_match(self, msg: str) -> re.Match | None: 52 | """ 53 | Provides the first regex in self.regex_list that matches a given msg. 54 | 55 | Args: 56 | msg (str): A message to test against each regex in the regex_list 57 | 58 | Returns: 59 | re.Match | None: The match object from the first regex that matched the message, none 60 | if no regex matched. 61 | """ 62 | for regex in self.regex_list: 63 | if match := regex.search(msg): 64 | return match 65 | return None 66 | 67 | 68 | class RegexHandler(logging.Handler): 69 | """ 70 | A Logging Handler that adds the ability to call Callbacks based on Regex 71 | Matches of logged lines. 72 | """ 73 | 74 | regex_callbacks: List[RegexCallback] 75 | 76 | def __init__( 77 | self, regex_callbacks: Sequence[RegexCallback], level: int = logging.NOTSET 78 | ) -> None: 79 | """ 80 | Initializes a RegexHandler 81 | 82 | Args: 83 | regex_callbacks (Sequence[RegexCallback]): A sequence of RegexCallback objects which 84 | will be iterated through on each logged message. RegexCallbacks are tested and 85 | called in the same order as they are provided in the sequence. 86 | 87 | A new list object will be created from the provided sequence, if the callback list 88 | needs to be modified then you must access the new list through the regex_callbacks 89 | property. 90 | level (int, optional): A minimum level of message that will be handled. 91 | Defaults to logging.NOTSET. 92 | """ 93 | super().__init__(level) 94 | self.regex_callbacks = list(regex_callbacks) 95 | 96 | def emit(self, record: logging.LogRecord) -> None: 97 | """ 98 | Method which is called by the logger when a string is logged to a logger 99 | this handler has been added to. 100 | Args: 101 | record (logging.LogRecord): The log record of the logged string 102 | """ 103 | matched = False 104 | for regex_callback in self.regex_callbacks: 105 | if matched and regex_callback.only_run_if_first_matched: 106 | continue 107 | if match := regex_callback.get_match(record.msg): 108 | regex_callback.callback(match) 109 | if match and regex_callback.exit_if_matched: 110 | break 111 | matched = matched or match is not None 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.9.3 (2025-11-17) 2 | 3 | 4 | 5 | ### Bug Fixes 6 | * path mapping lru cache grows indefinitely (#239) ([`8937f21`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/8937f210a7f268146d12ff20924f7842ffa070c7)) 7 | 8 | 9 | ## 0.9.2 (2025-10-02) 10 | 11 | 12 | ### Features 13 | * Add user friendly message when the exectuable isn't found (#219) ([`e908f47`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/e908f47de2fcd7a250065da9815c9ec00350bdd8)) 14 | 15 | 16 | ## 0.9.1 (2025-07-09) 17 | 18 | 19 | 20 | ### Bug Fixes 21 | * sdist failed to install (#199) ([`71f192b`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/71f192b5f2d70ef89480d7da4c4a52857795c2a1)) 22 | 23 | ## 0.9.0 (2025-01-02) 24 | 25 | 26 | 27 | ### Bug Fixes 28 | * Ensure all open uses UTF-8 instead of default encoding. (#171) ([`74fe730`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/74fe7304d8f1033414cda1b0d4a14fec3ee80fa0)) 29 | 30 | ## 0.8.2 (2024-11-29) 31 | 32 | This release allows pywin32 version 307 and above to be used as a dependency for this package. Previously, only pywin32 version 308 was allowed. 33 | 34 | 35 | ## 0.8.1 (2024-11-13) 36 | 37 | 38 | ### Features 39 | * Adding optional override for adaptor request timeout default to EntryPoint start method (#149) ([`652164f`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/652164f672a8548ee1f89e430b90378d06bbea34)) 40 | 41 | 42 | ## 0.8.0 (2024-06-03) 43 | 44 | ### BREAKING CHANGES 45 | * handle socket name collisions (#125) ([`a123717`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/a1237171d2fe86e99b4eed5fd7f6f9578ff24aa9)) 46 | 47 | 48 | 49 | ## 0.7.2 (2024-04-24) 50 | 51 | ### CI 52 | * add PyPI publish job to publish workflow (#118) ([`1c7cfb7`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/1c7cfb77555e958cd6343bff445aacf657d32bbb)) 53 | 54 | 55 | ## 0.7.1 (2024-04-23) 56 | 57 | 58 | 59 | ### Bug Fixes 60 | * set correct permission for the test directory. (#107) ([`169f346`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/169f346df8efc5bbcc33eda9859c4bb20fe471f4)) 61 | 62 | ## 0.7.0 (2024-04-01) 63 | 64 | ### BREAKING CHANGES 65 | * public release (#101) ([`c9be773`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/c9be773e212f6d0b505a2eb52d9a4fde63a476c1)) 66 | 67 | 68 | 69 | ## 0.6.1 (2024-03-25) 70 | 71 | 72 | 73 | ### Bug Fixes 74 | * Add `delete` permission to the`secure_open` (#92) ([`6d066ad`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/6d066ad98f33e1b54a6934c1d244f5938a65ec90)) 75 | 76 | ## 0.6.0 (2024-03-22) 77 | 78 | 79 | ### BREAKING CHANGES 80 | * Move the `named_pipe_helper.py` under the folder `adaptor_runtime_client` (#87) ([`1398c56`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/1398c562fead13564705329838a377468e11c2c1)) 81 | 82 | 83 | ### Bug Fixes 84 | * Update request method in windows Client interface to blocking call. (#90) ([`e66d592`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/e66d5927f6f0ede574ae39bcd2616042265baa7c)) 85 | * increase max named pipe instances to 4 (#91) ([`7440c53`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/7440c531f3eadabd9496217ad37f7247e17f5358)) 86 | * specify max named pipe instances (#86) ([`d959381`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/d95938179b4f605d9315cecec8b80b52f23fb11d)) 87 | 88 | 89 | ## 0.5.1 (2024-03-05) 90 | 91 | 92 | ### Features 93 | * add macOS socket support and enable macOS CI (#65) ([`4da070d`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/4da070daef1c23be8c3be6e2fb5921b17a23c79a)) 94 | 95 | 96 | ## 0.5.0 (2024-02-21) 97 | 98 | ### BREAKING CHANGES 99 | * add adaptor interface/data versioning (#73) ([`9575aea`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/9575aeac589b70324d5e02c98c6c45dfb2a42fb6)) 100 | * show-config is now a command, remove "run" as default, refactor (#74) ([`8030890`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/8030890903b63b9007e00cd3b0ed2487afe990f3)) 101 | 102 | 103 | ### Bug Fixes 104 | * fix signal handling (#76) ([`bc38027`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/bc38027a4f572ea802663054fbc07b7164de3037)) 105 | 106 | ## 0.4.2 (2024-02-13) 107 | 108 | 109 | ### Features 110 | * Add the ability for the adaptor to specify its reentry executable (#69) ([`9647ec8`](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/commit/9647ec88b57af6830ca2892e996967bfeaf2eb9c)) 111 | 112 | 113 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/application_ipc/_http_request_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from http import HTTPStatus 7 | from typing import TYPE_CHECKING 8 | from typing import Optional 9 | 10 | from ._adaptor_server_response import AdaptorServerResponseGenerator 11 | from .._http import HTTPResponse, RequestHandler, ResourceRequestHandler 12 | 13 | if TYPE_CHECKING: # pragma: no cover because pytest will think we should test for this. 14 | from openjd.adaptor_runtime_client import Action 15 | 16 | from ._adaptor_server import AdaptorServer 17 | 18 | 19 | class AdaptorHTTPRequestHandler(RequestHandler): 20 | """This is the HTTPRequestHandler to be used by the Adaptor Server. This class is 21 | where we will dequeue the actions from the queue and pass it in a response to a client. 22 | """ 23 | 24 | server: AdaptorServer # This is here for type hinting. 25 | 26 | def __init__( 27 | self, 28 | request: bytes, 29 | client_address: str, 30 | server: AdaptorServer, 31 | ) -> None: 32 | super().__init__(request, client_address, server, AdaptorResourceRequestHandler) 33 | 34 | 35 | class AdaptorResourceRequestHandler(ResourceRequestHandler): 36 | """ 37 | Base class that handles HTTP requests for a specific resource. 38 | 39 | This class only works with an AdaptorServer. 40 | """ 41 | 42 | server: AdaptorServer # This is just for type hinting 43 | 44 | @property 45 | def server_response(self): 46 | """ 47 | This is required because the socketserver.BaseRequestHandler.__init__ method actually 48 | handles the request. This means the and self.query_string_params variable 49 | are not set until that init method is called, so we need to do this type check outside 50 | the init chain. 51 | """ 52 | if not hasattr(self, "_server_response"): 53 | self._server_response = AdaptorServerResponseGenerator( 54 | self.server, HTTPResponse, self.query_string_params 55 | ) 56 | return self._server_response 57 | 58 | 59 | class PathMappingEndpoint(AdaptorResourceRequestHandler): 60 | path = "/path_mapping" 61 | 62 | def get(self) -> HTTPResponse: 63 | """ 64 | GET Handler for the Path Mapping Endpoint 65 | 66 | Returns: 67 | HTTPResponse: A body and response code to send to the DCC Client 68 | """ 69 | try: 70 | return self.server_response.generate_path_mapping_get_response() 71 | except Exception as e: 72 | return HTTPResponse(HTTPStatus.INTERNAL_SERVER_ERROR, body=str(e)) 73 | 74 | 75 | class PathMappingRulesEndpoint(AdaptorResourceRequestHandler): 76 | path = "/path_mapping_rules" 77 | 78 | def get(self) -> HTTPResponse: 79 | """ 80 | GET Handler for the Path Mapping Rules Endpoint 81 | 82 | Returns: 83 | HTTPResponse: A body and response code to send to the DCC Client 84 | """ 85 | return self.server_response.generate_path_mapping_rules_get_response() 86 | 87 | 88 | class ActionEndpoint(AdaptorResourceRequestHandler): 89 | path = "/action" 90 | 91 | def get(self) -> HTTPResponse: 92 | """ 93 | GET handler for the Action end point of the Adaptor Server that communicates with the client 94 | spawned in the DCC. 95 | 96 | Returns: 97 | HTTPResponse: A body and response code to send to the DCC Client 98 | """ 99 | return self.server_response.generate_action_get_response() 100 | 101 | def _dequeue_action(self) -> Optional[Action]: 102 | """This function will dequeue the first action in the queue. 103 | 104 | Returns: 105 | Action: A tuple containing the next action structured: 106 | ("action_name", { "args1": "val1", "args2": "val2" }) 107 | 108 | None: If the Actions Queue is empty. 109 | 110 | Raises: 111 | TypeError: If the server isn't an AdaptorServer. 112 | """ 113 | # This condition shouldn't matter, because we have typehinted the server above. 114 | # This is only here for type hinting (as is the return None below). 115 | if hasattr(self, "server") and hasattr(self.server, "actions_queue"): 116 | return self.server.actions_queue.dequeue_action() 117 | 118 | print( 119 | "ERROR: Could not retrieve the next action because the server or actions queue " 120 | "wasn't set.", 121 | file=sys.stderr, 122 | flush=True, 123 | ) 124 | return None 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Job Description - Adaptor Runtime Library 2 | 3 | [![pypi](https://img.shields.io/pypi/v/openjd-adaptor-runtime.svg?style=flat)](https://pypi.python.org/pypi/openjd-adaptor-runtime) 4 | [![python](https://img.shields.io/pypi/pyversions/openjd-adaptor-runtime.svg?style=flat)](https://pypi.python.org/pypi/openjd-adaptor-runtime) 5 | [![license](https://img.shields.io/pypi/l/openjd-adaptor-runtime.svg?style=flat)](https://github.com/OpenJobDescription/openjd-adaptor-runtime/blob/mainline/LICENSE) 6 | 7 | This package provides a runtime library to help build application interfaces that simplify 8 | Open Job Description job templates. When implemented by a third party on behalf of 9 | an application, the result is a CLI command that acts as an adaptor. Application 10 | developers can also implement support for these CLI patterns directly in their 11 | applications, potentially using this library to simplify the work. 12 | 13 | Interface features that this library can assist with include: 14 | 15 | 1. Run as a background daemon to amortize application startup and scene load time. 16 | * Tasks run in the context of [Open Job Description Sessions], and this pattern lets a 17 | scheduling engine sequentially dispatch tasks to a single process that retains the 18 | application, loaded scene, and any acceleration data structures in memory. 19 | 2. Report progress and status messages. 20 | * Applications write progress information and status messages in many different ways. 21 | An adaptor can scan the output of an application and report it in the format specified 22 | for [Open Job Description Stdout Messages]. 23 | 3. Map file system paths in input data. 24 | * When running tasks on a different operating system, or when files are located at 25 | different locations compared to where they were at creation, an adaptor can take 26 | path mapping rules and perform [Open Job Description Path Mapping]. 27 | 4. Transform signals like cancelation requests from the Open Job Description runtime into 28 | the signal needed by the application. 29 | * Applications may require different mechanisms to receive these messages, an adaptor 30 | can handle any differences with what Open Job Description provides to give full 31 | feature support. 32 | 5. Adjust application default behaviors for batch processing. 33 | * When running applications that were built for interactive use within a batch processing 34 | system, some default behaviors may lead to unreliability of workload completion, such 35 | as using watermarks when a license could not be acquired or returning a success exit 36 | code when an input data file could not be read. The adaptor can monitor and detect 37 | these cases. 38 | 39 | [Open Job Description Sessions]: https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#sessions 40 | [Open Job Description Stdout Messages]: https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#stdoutstderr-messages 41 | [Open Job Description Path Mapping]: https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#path-mapping 42 | 43 | Read the [Library Documentation](docs/README.md) to learn more. 44 | 45 | ## Compatibility 46 | 47 | This library requires: 48 | 49 | 1. Python 3.9 or higher; and 50 | 2. Linux, MacOS, or Windows operating system. 51 | 52 | ## Versioning 53 | 54 | This package's version follows [Semantic Versioning 2.0](https://semver.org/), but is still considered to be in its 55 | initial development, thus backwards incompatible versions are denoted by minor version bumps. To help illustrate how 56 | versions will increment during this initial development stage, they are described below: 57 | 58 | 1. The MAJOR version is currently 0, indicating initial development. 59 | 2. The MINOR version is currently incremented when backwards incompatible changes are introduced to the public API. 60 | 3. The PATCH version is currently incremented when bug fixes or backwards compatible changes are introduced to the public API. 61 | 62 | ## Downloading 63 | 64 | You can download this package from: 65 | - [PyPI](https://pypi.org/project/openjd-adaptor-runtime/) 66 | - [GitHub releases](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python/releases) 67 | 68 | ### Verifying GitHub Releases 69 | 70 | See [Verifying GitHub Releases](https://github.com/OpenJobDescription/openjd-adaptor-runtime-for-python?tab=security-ov-file#verifying-github-releases) for more information. 71 | 72 | ## Security 73 | 74 | We take all security reports seriously. When we receive such reports, we will 75 | investigate and subsequently address any potential vulnerabilities as quickly 76 | as possible. If you discover a potential security issue in this project, please 77 | notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) 78 | or directly via email to [AWS Security](aws-security@amazon.com). Please do not 79 | create a public GitHub issue in this project. 80 | 81 | ## License 82 | 83 | This project is licensed under the Apache-2.0 License. 84 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/unit/background/test_loaders.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import dataclasses 6 | import pathlib 7 | import json 8 | import re 9 | import typing 10 | from unittest.mock import MagicMock, mock_open, patch 11 | 12 | import pytest 13 | 14 | from openjd.adaptor_runtime._background import loaders 15 | from openjd.adaptor_runtime._background.loaders import ( 16 | ConnectionSettingsEnvLoader, 17 | ConnectionSettingsFileLoader, 18 | ConnectionSettingsLoadingError, 19 | ) 20 | from openjd.adaptor_runtime._background.model import ( 21 | ConnectionSettings, 22 | ) 23 | 24 | 25 | class TestConnectionSettingsFileLoader: 26 | """ 27 | Tests for the ConnectionsettingsFileLoader class 28 | """ 29 | 30 | @pytest.fixture 31 | def connection_settings(self) -> ConnectionSettings: 32 | return ConnectionSettings(socket="socket") 33 | 34 | @pytest.fixture(autouse=True) 35 | def open_mock( 36 | self, connection_settings: ConnectionSettings 37 | ) -> typing.Generator[MagicMock, None, None]: 38 | with patch.object( 39 | loaders, 40 | "open", 41 | mock_open(read_data=json.dumps(dataclasses.asdict(connection_settings))), 42 | ) as m: 43 | yield m 44 | 45 | @pytest.fixture 46 | def connection_file_path(self) -> pathlib.Path: 47 | return pathlib.Path("test") 48 | 49 | @pytest.fixture 50 | def loader(self, connection_file_path: pathlib.Path) -> ConnectionSettingsFileLoader: 51 | return ConnectionSettingsFileLoader(connection_file_path) 52 | 53 | def test_loads_settings( 54 | self, 55 | connection_settings: ConnectionSettings, 56 | loader: ConnectionSettingsFileLoader, 57 | ): 58 | # WHEN 59 | result = loader.load() 60 | 61 | # THEN 62 | assert result == connection_settings 63 | 64 | def test_raises_when_file_open_fails( 65 | self, 66 | open_mock: MagicMock, 67 | loader: ConnectionSettingsFileLoader, 68 | connection_file_path: pathlib.Path, 69 | caplog: pytest.LogCaptureFixture, 70 | ): 71 | # GIVEN 72 | err = OSError() 73 | open_mock.side_effect = err 74 | 75 | # WHEN 76 | with pytest.raises(ConnectionSettingsLoadingError): 77 | loader.load() 78 | 79 | # THEN 80 | assert f"Failed to open connection file '{connection_file_path}': " in caplog.text 81 | 82 | def test_raises_when_json_decode_fails( 83 | self, 84 | loader: ConnectionSettingsFileLoader, 85 | connection_file_path: pathlib.Path, 86 | caplog: pytest.LogCaptureFixture, 87 | ): 88 | # GIVEN 89 | err = json.JSONDecodeError("", "", 0) 90 | 91 | with patch.object(loaders.json, "load", side_effect=err): 92 | with pytest.raises(ConnectionSettingsLoadingError): 93 | # WHEN 94 | loader.load() 95 | 96 | # THEN 97 | assert f"Failed to decode connection file '{connection_file_path}': " in caplog.text 98 | 99 | 100 | class TestConnectionSettingsEnvLoader: 101 | @pytest.fixture 102 | def connection_settings(self) -> ConnectionSettings: 103 | return ConnectionSettings(socket="socket") 104 | 105 | @pytest.fixture 106 | def mock_env(self, connection_settings: ConnectionSettings) -> dict[str, typing.Any]: 107 | return { 108 | env_name: getattr(connection_settings, attr_name) 109 | for attr_name, (env_name, _) in ConnectionSettingsEnvLoader().env_map.items() 110 | } 111 | 112 | @pytest.fixture(autouse=True) 113 | def mock_os_environ( 114 | self, mock_env: dict[str, typing.Any] 115 | ) -> typing.Generator[dict, None, None]: 116 | with patch.dict(loaders.os.environ, mock_env) as d: 117 | yield d 118 | 119 | @pytest.fixture 120 | def loader(self) -> ConnectionSettingsEnvLoader: 121 | return ConnectionSettingsEnvLoader() 122 | 123 | def test_loads_connection_settings( 124 | self, 125 | loader: ConnectionSettingsEnvLoader, 126 | connection_settings: ConnectionSettings, 127 | ) -> None: 128 | # WHEN 129 | settings = loader.load() 130 | 131 | # THEN 132 | assert connection_settings == settings 133 | 134 | def test_raises_error_when_required_not_provided( 135 | self, 136 | loader: ConnectionSettingsEnvLoader, 137 | ) -> None: 138 | # GIVEN 139 | with patch.object(loaders.os.environ, "get", return_value=None): 140 | with pytest.raises(loaders.ConnectionSettingsLoadingError) as raised_err: 141 | # WHEN 142 | loader.load() 143 | 144 | # THEN 145 | assert re.match( 146 | "^Required attribute '.*' does not have its corresponding environment variable '.*' set", 147 | str(raised_err.value), 148 | ) 149 | -------------------------------------------------------------------------------- /test/openjd/adaptor_runtime/integ/_named_pipe/test_named_pipe_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | import time 3 | 4 | from openjd.adaptor_runtime._osname import OSName 5 | import json 6 | import os 7 | import pytest 8 | import threading 9 | 10 | if OSName.is_windows(): 11 | import pywintypes 12 | import win32file 13 | import win32pipe 14 | import win32security 15 | import win32api 16 | from openjd.adaptor_runtime_client.named_pipe.named_pipe_helper import NamedPipeHelper 17 | else: 18 | # Cannot put this on the top of this file or mypy will complain 19 | pytest.mark.skip(reason="NamedPipe is only implemented in Windows.") 20 | 21 | PIPE_NAME = r"\\.\pipe\TestPipe" 22 | TIMEOUT_SECONDS = 5 23 | 24 | 25 | def pipe_server(pipe_name, message_to_send, return_message): 26 | """ 27 | A simple pipe server for testing. 28 | """ 29 | server_handle = NamedPipeHelper.create_named_pipe_server(pipe_name, TIMEOUT_SECONDS) 30 | win32pipe.ConnectNamedPipe(server_handle, None) 31 | received_message = NamedPipeHelper.read_from_pipe(server_handle) 32 | received_obj = json.loads(received_message) 33 | assert received_obj["method"] == message_to_send["method"] 34 | assert received_obj["path"] == message_to_send["path"] 35 | assert json.loads(received_obj["body"]) == message_to_send["json_body"] 36 | NamedPipeHelper.write_to_pipe(server_handle, return_message) 37 | win32file.CloseHandle(server_handle) 38 | 39 | 40 | @pytest.fixture 41 | def start_pipe_server(): 42 | """ 43 | Fixture to start the pipe server in a separate thread. 44 | """ 45 | message_to_send = dict(method="POST", path="/test", json_body={"message": "Hello from client"}) 46 | return_message = '{"Response":"Hello from server"}' 47 | server_thread = threading.Thread( 48 | target=pipe_server, args=(PIPE_NAME, message_to_send, return_message) 49 | ) 50 | server_thread.start() 51 | yield message_to_send, return_message 52 | server_thread.join() 53 | 54 | 55 | @pytest.mark.skipif(not OSName.is_windows(), reason="NamedPipe is only implemented in Windows.") 56 | class TestNamedPipeHelper: 57 | def test_named_pipe_communication(self, start_pipe_server): 58 | """ 59 | A test for validating basic NamedPipe functions, Connect, Read, Write 60 | """ 61 | # GIVEN 62 | message_to_send, expected_response = start_pipe_server 63 | 64 | # WHEN 65 | response = NamedPipeHelper.send_named_pipe_request( 66 | PIPE_NAME, TIMEOUT_SECONDS, **message_to_send 67 | ) 68 | 69 | # THEN 70 | assert response == json.loads(expected_response) 71 | 72 | def test_check_named_pipe_exists(self): 73 | """ 74 | Test if the script can check if a named pipe exists. 75 | """ 76 | 77 | # GIVEN 78 | pipe_name = r"\\.\pipe\test_if_named_pipe_exist" 79 | assert not NamedPipeHelper.check_named_pipe_exists(pipe_name) 80 | server_handle = NamedPipeHelper.create_named_pipe_server(pipe_name, TIMEOUT_SECONDS) 81 | 82 | # WHEN 83 | is_existed = False 84 | # Need to wait for launching the NamedPipe Server 85 | for _ in range(10): 86 | if NamedPipeHelper.check_named_pipe_exists(pipe_name): 87 | is_existed = True 88 | break 89 | time.sleep(1) 90 | 91 | # THEN 92 | win32file.CloseHandle(server_handle) 93 | assert is_existed 94 | 95 | @pytest.mark.skipif( 96 | os.getenv("GITHUB_ACTIONS") != "true", 97 | reason="Skip this test in local env to avoid user creation with elevated privilege.", 98 | ) 99 | def test_fail_to_connect_to_named_pipe_with_different_user( 100 | self, win_test_user, start_pipe_server 101 | ): 102 | """ 103 | This test is used for validating the security descriptor is working. 104 | Only the user who start running the named pipe server can connect to it. 105 | Any other users will get the error `Access is denied` 106 | """ 107 | # GIVEN 108 | user_name, password = win_test_user 109 | logon_type = win32security.LOGON32_LOGON_INTERACTIVE 110 | provider = win32security.LOGON32_PROVIDER_DEFAULT 111 | message_to_send, expected_response = start_pipe_server 112 | 113 | # WHEN 114 | # Log on with the user's credentials and get the token handle 115 | token_handle = win32security.LogonUser(user_name, "", password, logon_type, provider) 116 | # Impersonate the user 117 | win32security.ImpersonateLoggedOnUser(token_handle) 118 | 119 | # THEN 120 | with pytest.raises(pywintypes.error) as excinfo: 121 | NamedPipeHelper.send_named_pipe_request(PIPE_NAME, TIMEOUT_SECONDS, **message_to_send) 122 | assert "Access is denied" in str(excinfo.value) 123 | 124 | # Revert the impersonation 125 | win32security.RevertToSelf() 126 | 127 | # Close the token handle 128 | win32api.CloseHandle(token_handle) 129 | 130 | # Send a message to unblock the I/O 131 | NamedPipeHelper.send_named_pipe_request(PIPE_NAME, TIMEOUT_SECONDS, **message_to_send) 132 | -------------------------------------------------------------------------------- /src/openjd/adaptor_runtime/adaptors/_validator.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import jsonschema 7 | import logging 8 | import os 9 | import yaml 10 | from typing import Any 11 | 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | 16 | class AdaptorDataValidators: 17 | """ 18 | Class that contains validators for Adaptor input data. 19 | """ 20 | 21 | @classmethod 22 | def for_adaptor(cls, schema_dir: str) -> AdaptorDataValidators: 23 | """ 24 | Gets the validators for the specified adaptor. 25 | 26 | Args: 27 | adaptor_name (str): The name of the adaptor 28 | """ 29 | init_data_schema_path = os.path.join(schema_dir, "init_data.schema.json") 30 | _logger.info("Loading 'init_data' schema from %s", init_data_schema_path) 31 | run_data_schema_path = os.path.join(schema_dir, "run_data.schema.json") 32 | _logger.info("Loading 'run_data' schema from %s", run_data_schema_path) 33 | 34 | init_data_validator = AdaptorDataValidator.from_schema_file(init_data_schema_path) 35 | 36 | run_data_validator = AdaptorDataValidator.from_schema_file(run_data_schema_path) 37 | 38 | return AdaptorDataValidators(init_data_validator, run_data_validator) 39 | 40 | def __init__( 41 | self, 42 | init_data_validator: AdaptorDataValidator, 43 | run_data_validator: AdaptorDataValidator, 44 | ) -> None: 45 | self._init_data_validator = init_data_validator 46 | self._run_data_validator = run_data_validator 47 | 48 | @property 49 | def init_data(self) -> AdaptorDataValidator: 50 | """ 51 | Gets the validator for init_data. 52 | """ 53 | return self._init_data_validator 54 | 55 | @property 56 | def run_data(self) -> AdaptorDataValidator: 57 | """ 58 | Gets the validator for run_data. 59 | """ 60 | return self._run_data_validator 61 | 62 | 63 | class AdaptorDataValidator: 64 | """ 65 | Class that validates the input data for an Adaptor. 66 | """ 67 | 68 | @staticmethod 69 | def from_schema_file(schema_path: str) -> AdaptorDataValidator: 70 | """ 71 | Creates an AdaptorDataValidator with the JSON schema at the specified file path. 72 | 73 | Args: 74 | schema_path (str): The path to the JSON schema file to use. 75 | """ 76 | try: 77 | with open(schema_path, encoding="utf-8") as schema_file: 78 | schema = json.load(schema_file) 79 | except json.JSONDecodeError as e: 80 | _logger.error(f"Failed to decode JSON schema file: {e}") 81 | raise 82 | except OSError as e: 83 | _logger.error(f"Failed to open JSON schema file at {schema_path}: {e}") 84 | raise 85 | 86 | if not isinstance(schema, dict): 87 | raise ValueError(f"Expected JSON schema to be a dict, but got {type(schema)}") 88 | 89 | return AdaptorDataValidator(schema) 90 | 91 | def __init__(self, schema: dict) -> None: 92 | self._schema = schema 93 | 94 | def validate(self, data: str | dict) -> None: 95 | """ 96 | Validates that the data adheres to the schema. 97 | 98 | The data argument can be one of the following: 99 | - A string containing the data file path. Must be prefixed with "file://". 100 | - A string-encoded version of the data. 101 | - A dictionary containing the data. 102 | 103 | Args: 104 | data (dict): The data to validate. 105 | 106 | Raises: 107 | jsonschema.ValidationError: Raised when the data failed validate against the schema. 108 | jsonschema.SchemaError: Raised when the schema itself is nonvalid. 109 | """ 110 | if isinstance(data, str): 111 | data = _load_data(data) 112 | 113 | jsonschema.validate(data, self._schema) 114 | 115 | 116 | def _load_data(data: str) -> dict: 117 | """ 118 | Parses an input JSON/YAML (filepath or string-encoded) into a dictionary. 119 | 120 | Args: 121 | data (str): The filepath or string representation of the JSON/YAML to parse. 122 | If this is a filepath, it must begin with "file://" 123 | 124 | Raises: 125 | ValueError: Raised when the JSON/YAML is not parsed to a dictionary. 126 | """ 127 | try: 128 | loaded_data = _load_yaml_json(data) 129 | except OSError as e: 130 | _logger.error(f"Failed to open data file: {e}") 131 | raise 132 | except yaml.YAMLError as e: 133 | _logger.error(f"Failed to load data as JSON or YAML: {e}") 134 | raise 135 | 136 | if not isinstance(loaded_data, dict): 137 | raise ValueError(f"Expected loaded data to be a dict, but got {type(loaded_data)}") 138 | 139 | return loaded_data 140 | 141 | 142 | def _load_yaml_json(data: str) -> Any: 143 | """ 144 | Loads a YAML/JSON file/string. 145 | 146 | Note that yaml.safe_load() is capable of loading JSON documents. 147 | """ 148 | loaded_yaml = None 149 | if data.startswith("file://"): 150 | filepath = data[len("file://") :] 151 | with open(filepath, encoding="utf-8") as yaml_file: 152 | loaded_yaml = yaml.safe_load(yaml_file) 153 | else: 154 | loaded_yaml = yaml.safe_load(data) 155 | 156 | return loaded_yaml 157 | --------------------------------------------------------------------------------