diff --git a/.flake8 b/.flake8 index c835618..f7f571c 100644 --- a/.flake8 +++ b/.flake8 @@ -12,16 +12,14 @@ per-file-ignores = # ignore unused imports in __init__ files */__init__.py: F401 # supress some docstring requirements in tests - test/__init__.py: D104 - test/test_deep/__init__.py: D104 - test/test_deep/*/__init__.py: D104,D107 - test/test_deep/*/test_*.py: D101,D102,D107,D100,D105 - test/test_deep/test_*.py: D101,D102,D107,D100,D105 - test/test_deep/test_target.py: D102,D107,D103 + tests/unit_tests/*.py: D + tests/unit_tests/**/*.py: D # these files are from OTEL so should use OTEL license. */deep/api/types.py: NCF102 */deep/api/resource/__init__.py: NCF102 */deep/api/attributes/__init__.py: NCF102 + tests/unit_tests/api/attributes/*.py: NCF102,D + tests/unit_tests/api/resource/*.py: NCF102,D detailed-output = True copyright-regex = diff --git a/.github/workflows/on_push.yaml b/.github/workflows/on_push.yaml index c36dda2..b392648 100644 --- a/.github/workflows/on_push.yaml +++ b/.github/workflows/on_push.yaml @@ -22,8 +22,26 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r dev-requirements.txt + - name: Flake8 - run: flake8 + run: make lint + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Python # Set Python version + uses: actions/setup-python@v4 + with: + python-version: 3.12 + # Install pip and pytest + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r dev-requirements.txt + - run: | + make coverage tests: @@ -46,7 +64,7 @@ jobs: pip install -r dev-requirements.txt pip install . - name: Test with pytest - run: pytest test --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml + run: pytest tests/unit_tests --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml - name: Upload pytest test results uses: actions/upload-artifact@v3 with: diff --git a/.idea/misc.xml b/.idea/misc.xml index eaee005..77c396d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -7,7 +7,7 @@ - + \ No newline at end of file diff --git a/Makefile b/Makefile index ca0a66a..2eadb9c 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,11 @@ endif .PHONY: test test: - pytest test + pytest tests/unit_tests + +.PHONY: coverage +coverage: + pytest tests/unit_tests --cov=deep --cov-report term --cov-fail-under=77 --cov-report html --cov-branch .PHONY: lint lint: diff --git a/deep-python-client.iml b/deep-python-client.iml index 128d56c..7db90e3 100644 --- a/deep-python-client.iml +++ b/deep-python-client.iml @@ -7,10 +7,13 @@ - + - + + + \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index 326ed26..e93352a 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,3 +10,7 @@ flake8-docstrings certifi>=2023.7.22 # not directly required, pinned by Snyk to avoid a vulnerability flake8-header-validator>=0.0.3 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability +pytest-cov +mockito +opentelemetry-api +opentelemetry-sdk \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ed32c8b..46d7f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,15 @@ path = "src/deep/version.py" # read dependencies from reuirements.txt [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} + +[tool.pytest.ini_options] +pythonpath = [ + "./src", + "./tests" +] + +[tool.coverage.report] +exclude_lines = [ + "if TYPE_CHECKING:", + "@abc.abstractmethod" +] \ No newline at end of file diff --git a/src/deep/api/attributes/__init__.py b/src/deep/api/attributes/__init__.py index 28d477a..5818b91 100644 --- a/src/deep/api/attributes/__init__.py +++ b/src/deep/api/attributes/__init__.py @@ -43,7 +43,7 @@ def _clean_attribute_value( def _clean_attribute( - key: str, value: types.AttributeValue, max_len: Optional[int] + key: str, value: Union[types.AttributeValue, Sequence[types.AttributeValue]], max_len: Optional[int] ) -> Optional[types.AttributeValue]: """ Check if attribute value is valid and cleans it if required. diff --git a/src/deep/api/deep.py b/src/deep/api/deep.py index 86c5a68..1fbf685 100644 --- a/src/deep/api/deep.py +++ b/src/deep/api/deep.py @@ -68,6 +68,7 @@ def shutdown(self): if not self.started: return self.task_handler.flush() + self.poll.shutdown() self.started = False def register_tracepoint(self, path: str, line: int, args: Dict[str, str] = None, diff --git a/src/deep/api/plugin/__init__.py b/src/deep/api/plugin/__init__.py index affc03f..40bafc7 100644 --- a/src/deep/api/plugin/__init__.py +++ b/src/deep/api/plugin/__init__.py @@ -38,14 +38,14 @@ def __plugin_generator(configured): try: module, cls = plugin.rsplit(".", 1) yield getattr(import_module(module), cls) - logging.debug('Did import default integration %s', plugin) - except (DidNotEnable, SyntaxError) as e: + logging.debug('Did import integration %s', plugin) + except (DidNotEnable, Exception) as e: logging.debug( - "Did not import default integration %s: %s", plugin, e + "Did not import integration %s: %s", plugin, e ) -def load_plugins() -> 'Tuple[list[Plugin], BoundedAttributes]': +def load_plugins(custom=None) -> 'Tuple[list[Plugin], BoundedAttributes]': """ Load all the deep plugins. @@ -53,9 +53,11 @@ def load_plugins() -> 'Tuple[list[Plugin], BoundedAttributes]': :return: the loaded plugins and attributes. """ + if custom is None: + custom = [] bounded_attributes = BoundedAttributes(immutable=False) loaded = [] - for plugin in __plugin_generator(DEEP_PLUGINS): + for plugin in __plugin_generator(DEEP_PLUGINS + custom): try: plugin_instance = plugin() if not plugin_instance.is_active(): diff --git a/src/deep/api/plugin/otel.py b/src/deep/api/plugin/otel.py index 5268248..b229b10 100644 --- a/src/deep/api/plugin/otel.py +++ b/src/deep/api/plugin/otel.py @@ -71,12 +71,12 @@ def __span_name(span): @staticmethod def __span_id(span): # type: (_Span)-> Optional[str] - return (OTelPlugin.__format_span_id(span.context.__span_id)) if span else None + return (OTelPlugin.__format_span_id(span.context.span_id)) if span else None @staticmethod def __trace_id(span): # type: (_Span)-> Optional[str] - return (OTelPlugin.__format_trace_id(span.context.__trace_id)) if span else None + return (OTelPlugin.__format_trace_id(span.context.trace_id)) if span else None @staticmethod def __get_span(): diff --git a/src/deep/api/resource/__init__.py b/src/deep/api/resource/__init__.py index 3984ea2..845bbf7 100644 --- a/src/deep/api/resource/__init__.py +++ b/src/deep/api/resource/__init__.py @@ -142,12 +142,14 @@ def create( DeepResourceDetector().detect() ).merge(Resource(attributes, schema_url)) if not resource.attributes.get(SERVICE_NAME, None): - default_service_name = "unknown_service:python" + default_service_name = "unknown_service" process_executable_name = resource.attributes.get( PROCESS_EXECUTABLE_NAME, None ) if process_executable_name: default_service_name += ":" + process_executable_name + else: + default_service_name += ":python" resource = resource.merge( Resource({SERVICE_NAME: default_service_name}, schema_url) ) @@ -294,3 +296,40 @@ def detect(self) -> "Resource": if service_name: env_resource_map[SERVICE_NAME] = service_name return Resource(env_resource_map) + + +def get_aggregated_resources( + detectors: typing.List["ResourceDetector"], + initial_resource: typing.Optional[Resource] = None, + timeout=5, +) -> "Resource": + """Retrieve resources from detectors in the order that they were passed. + + :param detectors: List of resources in order of priority + :param initial_resource: Static resource. This has the highest priority + :param timeout: Number of seconds to wait for each detector to return + :return: + """ + detectors_merged_resource = initial_resource or Resource.create() + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(detector.detect) for detector in detectors] + for detector_ind, future in enumerate(futures): + detector = detectors[detector_ind] + try: + detected_resource = future.result(timeout=timeout) + # pylint: disable=broad-except + except Exception as ex: + detected_resource = _EMPTY_RESOURCE + if detector.raise_on_error: + raise ex + logging.warning( + "Exception %s in detector %s, ignoring", ex, detector + ) + finally: + detectors_merged_resource = detectors_merged_resource.merge( + detected_resource + ) + + return detectors_merged_resource diff --git a/src/deep/api/tracepoint/eventsnapshot.py b/src/deep/api/tracepoint/eventsnapshot.py index 197802b..62bfd74 100644 --- a/src/deep/api/tracepoint/eventsnapshot.py +++ b/src/deep/api/tracepoint/eventsnapshot.py @@ -38,7 +38,7 @@ def __init__(self, tracepoint, ts, resource, frames, var_lookup: Dict[str, 'Vari """ self._id = random.getrandbits(128) self._tracepoint = tracepoint - self._var_lookup: dict[str, 'Variable'] = var_lookup + self._var_lookup: Dict[str, 'Variable'] = var_lookup self._ts_nanos = ts self._frames = frames self._watches = [] diff --git a/src/deep/config/config_service.py b/src/deep/config/config_service.py index 5d06f85..412b94e 100644 --- a/src/deep/config/config_service.py +++ b/src/deep/config/config_service.py @@ -16,7 +16,7 @@ """Service for handling deep config.""" import os -from typing import Any, List, Dict +from typing import Any, List from deep import logging from deep.api.plugin import Plugin @@ -28,16 +28,18 @@ class ConfigService: """This is the main service that handles config for DEEP.""" - def __init__(self, custom: Dict[str, any]): + def __init__(self, custom=None, tracepoints=TracepointConfigService()): """ Create a new config object. :param custom: any custom values that are passed to DEEP """ + if custom is None: + custom = {} self._plugins = [] self.__custom = custom self._resource = None - self._tracepoint_config = TracepointConfigService() + self._tracepoint_config = tracepoints self._tracepoint_logger: 'TracepointLogger' = DefaultLogger() def __getattribute__(self, name: str) -> Any: diff --git a/src/deep/config/tracepoint_config.py b/src/deep/config/tracepoint_config.py index bc4f77f..5262389 100644 --- a/src/deep/config/tracepoint_config.py +++ b/src/deep/config/tracepoint_config.py @@ -33,7 +33,7 @@ def __init__(self) -> None: self._current_hash = None self._last_update = 0 self._task_handler = None - self._listeners: list[ConfigUpdateListener] = [] + self._listeners: List[ConfigUpdateListener] = [] def update_no_change(self, ts): """ @@ -149,6 +149,7 @@ def remove_custom(self, config: TracePointConfig): for idx, cfg in enumerate(self._custom): if cfg.id == config.id: del self._custom[idx] + self.__trigger_update(None, None) return diff --git a/src/deep/logging/__init__.py b/src/deep/logging/__init__.py index 2e36656..bea71ae 100644 --- a/src/deep/logging/__init__.py +++ b/src/deep/logging/__init__.py @@ -61,7 +61,7 @@ def error(msg, *args, **kwargs): :param args: the args for the log :param kwargs: the kwargs """ - logging.getLogger("deep").debug(msg, *args, **kwargs) + logging.getLogger("deep").error(msg, *args, **kwargs) def exception(msg, *args, exc_info=True, **kwargs): diff --git a/src/deep/poll/poll.py b/src/deep/poll/poll.py index 3a14b55..88704c7 100644 --- a/src/deep/poll/poll.py +++ b/src/deep/poll/poll.py @@ -47,8 +47,6 @@ def __init__(self, config: ConfigService, grpc: GRPCService): def start(self): """Start the long poll service.""" logging.info("Starting Long Poll system") - if self.timer is not None: - self.timer.stop() self.timer = RepeatedTimer("Tracepoint Long Poll", self.config.POLL_TIMER, self.poll) self.__initial_poll() self.timer.start() @@ -72,3 +70,9 @@ def poll(self): else: self.config.tracepoints.update_new_config(response.ts_nanos, response.current_hash, convert_response(response.response)) + + def shutdown(self): + """Shutdown the timer.""" + if self.timer: + self.timer.stop() + self.timer = None diff --git a/src/deep/push/__init__.py b/src/deep/push/__init__.py index fc873dd..817d543 100644 --- a/src/deep/push/__init__.py +++ b/src/deep/push/__init__.py @@ -98,4 +98,4 @@ def convert_snapshot(snapshot: EventSnapshot) -> Snapshot: log_msg=snapshot.log_msg) except Exception: logging.exception("Error converting to protobuf") - return Snapshot() + return None diff --git a/src/deep/push/push_service.py b/src/deep/push/push_service.py index 3b4d8fe..214503b 100644 --- a/src/deep/push/push_service.py +++ b/src/deep/push/push_service.py @@ -47,6 +47,9 @@ def push_snapshot(self, snapshot: EventSnapshot): def _push_task(self, snapshot): from deep.push import convert_snapshot converted = convert_snapshot(snapshot) + if converted is None: + return + logging.debug("Uploading snapshot: %s", snapshot_id_as_hex_str(snapshot.id)) stub = SnapshotServiceStub(self.grpc.channel) diff --git a/src/deep/task/__init__.py b/src/deep/task/__init__.py index fcbdd4e..3257b37 100644 --- a/src/deep/task/__init__.py +++ b/src/deep/task/__init__.py @@ -63,8 +63,8 @@ def submit_task(self, task, *args) -> Future: self._pending[next_id] = future # cannot use 'del' in lambda: https://stackoverflow.com/a/41953232/5151254 - def callback(future: Future): - if future.exception() is not None: + def callback(_future: Future): + if _future.exception() is not None: logging.exception("Submitted task failed %s", task) if next_id in self._pending: del self._pending[next_id] diff --git a/src/deep/utils.py b/src/deep/utils.py index f6fe38a..8edb4ed 100644 --- a/src/deep/utils.py +++ b/src/deep/utils.py @@ -35,24 +35,6 @@ def time_ns(): return time.time_ns() -def reduce_list(key, update_value, default_value, lst): - """Reduce a list to a dict. - - key :: list_item -> dict_key - update_value :: key * existing_value -> updated_value - default_value :: initial value passed to update_value - lst :: The list - - default_value comes before l. This is different from functools.reduce, - because functools.reduce's order is wrong. - """ - d = {} - for k in lst: - j = key(k) - d[j] = update_value(k, d.get(j, default_value)) - return d - - def str2bool(string): """ Convert a string to a boolean. @@ -60,7 +42,7 @@ def str2bool(string): :param string: the string to convert :return: True, if string is yes, true, t or 1. (case insensitive) """ - return string.lower() in ("yes", "true", "t", "1") + return string.lower() in ("yes", "true", "t", "1", "y") class RepeatedTimer: diff --git a/test/test_deep/__init__.py b/tests/unit_tests/__init__.py similarity index 100% rename from test/test_deep/__init__.py rename to tests/unit_tests/__init__.py diff --git a/test/test_deep/tracepoint/__init__.py b/tests/unit_tests/api/__init__.py similarity index 100% rename from test/test_deep/tracepoint/__init__.py rename to tests/unit_tests/api/__init__.py diff --git a/tests/unit_tests/api/attributes/__init__.py b/tests/unit_tests/api/attributes/__init__.py new file mode 100644 index 0000000..962577d --- /dev/null +++ b/tests/unit_tests/api/attributes/__init__.py @@ -0,0 +1,14 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . diff --git a/tests/unit_tests/api/attributes/test_attributes.py b/tests/unit_tests/api/attributes/test_attributes.py new file mode 100644 index 0000000..a5a5c8c --- /dev/null +++ b/tests/unit_tests/api/attributes/test_attributes.py @@ -0,0 +1,183 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import unittest +from typing import MutableSequence + +from deep.api.attributes import _clean_attribute, BoundedAttributes + + +class TestAttributes(unittest.TestCase): + def assertValid(self, value, key="k"): + expected = value + if isinstance(value, MutableSequence): + expected = tuple(value) + self.assertEqual(_clean_attribute(key, value, None), expected) + + def assertInvalid(self, value, key: any = "k"): + self.assertIsNone(_clean_attribute(key, value, None)) + + def test_attribute_key_validation(self): + # only non-empty strings are valid keys + self.assertInvalid(1, "") + self.assertInvalid(1, 1) + self.assertInvalid(1, {}) + self.assertInvalid(1, []) + self.assertInvalid(1, b"1") + self.assertValid(1, "k") + self.assertValid(1, "1") + + def test_clean_attribute(self): + self.assertInvalid([1, 2, 3.4, "ss", 4]) + self.assertInvalid([{}, 1, 2, 3.4, 4]) + self.assertInvalid(["sw", "lf", 3.4, "ss"]) + self.assertInvalid([1, 2, 3.4, 5]) + self.assertInvalid({}) + self.assertInvalid([1, True]) + self.assertValid(True) + self.assertValid("hi") + self.assertValid(3.4) + self.assertValid(15) + self.assertValid([1, 2, 3, 5]) + self.assertValid([1.2, 2.3, 3.4, 4.5]) + self.assertValid([True, False]) + self.assertValid(["ss", "dw", "fw"]) + self.assertValid([]) + # None in sequences are valid + self.assertValid(["A", None, None]) + self.assertValid(["A", None, None, "B"]) + self.assertValid([None, None]) + self.assertInvalid(["A", None, 1]) + self.assertInvalid([None, "A", None, 1]) + + # test keys + self.assertValid("value", "key") + self.assertInvalid("value", "") + self.assertInvalid("value", None) + + def test_sequence_attr_decode(self): + seq = [ + None, + b"Content-Disposition", + b"Content-Type", + b"\x81", + b"Keep-Alive", + ] + expected = [ + None, + "Content-Disposition", + "Content-Type", + None, + "Keep-Alive", + ] + self.assertEqual( + _clean_attribute("headers", seq, None), tuple(expected) + ) + + +class TestBoundedAttributes(unittest.TestCase): + base = collections.OrderedDict( + [ + ("name", "Firulais"), + ("age", 7), + ("weight", 13), + ("vaccinated", True), + ] + ) + + def test_negative_maxlen(self): + with self.assertRaises(ValueError): + BoundedAttributes(-1) + + def test_from_map(self): + dic_len = len(self.base) + base_copy = collections.OrderedDict(self.base) + bdict = BoundedAttributes(dic_len, base_copy) + + self.assertEqual(len(bdict), dic_len) + + # modify base_copy and test that bdict is not changed + base_copy["name"] = "Bruno" + base_copy["age"] = 3 + + for key in self.base: + self.assertEqual(bdict[key], self.base[key]) + + # test that iter yields the correct number of elements + self.assertEqual(len(tuple(bdict)), dic_len) + + # map too big + half_len = dic_len // 2 + bdict = BoundedAttributes(half_len, self.base) + self.assertEqual(len(tuple(bdict)), half_len) + self.assertEqual(bdict.dropped, dic_len - half_len) + + def test_bounded_dict(self): + # create empty dict + dic_len = len(self.base) + bdict = BoundedAttributes(dic_len, immutable=False) + self.assertEqual(len(bdict), 0) + + # fill dict + for key in self.base: + bdict[key] = self.base[key] + + self.assertEqual(len(bdict), dic_len) + self.assertEqual(bdict.dropped, 0) + + for key in self.base: + self.assertEqual(bdict[key], self.base[key]) + + # test __iter__ in BoundedAttributes + for key in bdict: + self.assertEqual(bdict[key], self.base[key]) + + # updating an existing element should not drop + bdict["name"] = "Bruno" + self.assertEqual(bdict.dropped, 0) + + # try to append more elements + for key in self.base: + bdict["new-" + key] = self.base[key] + + self.assertEqual(len(bdict), dic_len) + self.assertEqual(bdict.dropped, dic_len) + # Invalid values shouldn't be considered for `dropped` + bdict["invalid-seq"] = [None, 1, "2"] + self.assertEqual(bdict.dropped, dic_len) + + # test that elements in the dict are the new ones + for key in self.base: + self.assertEqual(bdict["new-" + key], self.base[key]) + + # delete an element + del bdict["new-name"] + self.assertEqual(len(bdict), dic_len - 1) + + with self.assertRaises(KeyError): + _ = bdict["new-name"] + + def test_no_limit_code(self): + bdict = BoundedAttributes(maxlen=None, immutable=False) + for num in range(100): + bdict[str(num)] = num + + for num in range(100): + self.assertEqual(bdict[str(num)], num) + + def test_immutable(self): + bdict = BoundedAttributes() + with self.assertRaises(TypeError): + bdict["should-not-work"] = "dict immutable" diff --git a/tests/unit_tests/api/plugin/test_otel.py b/tests/unit_tests/api/plugin/test_otel.py new file mode 100644 index 0000000..ef2e14a --- /dev/null +++ b/tests/unit_tests/api/plugin/test_otel.py @@ -0,0 +1,46 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +from opentelemetry import trace +from opentelemetry.sdk.resources import Resource, SERVICE_NAME +from opentelemetry.sdk.trace import TracerProvider + +from deep.api.plugin.otel import OTelPlugin + + +class TestOtel(unittest.TestCase): + + def setUp(self): + resource = Resource(attributes={ + SERVICE_NAME: "your-service-name" + }) + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + + def test_load_plugin(self): + plugin = OTelPlugin() + load_plugin = plugin.load_plugin() + self.assertIsNotNone(load_plugin) + self.assertEqual("your-service-name", load_plugin.get(SERVICE_NAME)) + + def test_collect_attributes(self): + with trace.get_tracer_provider().get_tracer("test").start_as_current_span("test-span"): + plugin = OTelPlugin() + attributes = plugin.collect_attributes() + self.assertIsNotNone(attributes) + self.assertEqual("test-span", attributes.get("span_name")) + self.assertIsNotNone(attributes.get("span_id")) + self.assertIsNotNone(attributes.get("trace_id")) diff --git a/tests/unit_tests/api/plugin/test_plugin.py b/tests/unit_tests/api/plugin/test_plugin.py new file mode 100644 index 0000000..85ab50c --- /dev/null +++ b/tests/unit_tests/api/plugin/test_plugin.py @@ -0,0 +1,48 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import deep +from deep.api.attributes import BoundedAttributes +from deep.api.plugin import load_plugins, Plugin +from deep.config import ConfigService + + +class BadPlugin(Plugin): + def load_plugin(self) -> BoundedAttributes: + raise Exception('test: failed load') + + def collect_attributes(self) -> BoundedAttributes: + raise Exception('test: failed collection') + + +class TestPluginLoader(unittest.TestCase): + + def setUp(self): + deep.logging.init(ConfigService()) + + def test_load_plugins(self): + plugins = load_plugins() + self.assertIsNotNone(plugins) + self.assertEqual(2, len(plugins)) + + def test_handle_bad_plugin(self): + plugins = load_plugins([BadPlugin.__qualname__]) + + self.assertEqual(2, len(plugins)) + + plugins = load_plugins([BadPlugin.__module__ + '.' + BadPlugin.__name__]) + + self.assertEqual(2, len(plugins)) diff --git a/tests/unit_tests/api/plugin/test_python.py b/tests/unit_tests/api/plugin/test_python.py new file mode 100644 index 0000000..9c82c0e --- /dev/null +++ b/tests/unit_tests/api/plugin/test_python.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +from deep.api.plugin.python import PythonPlugin + + +class TestPython(unittest.TestCase): + + def test_load_plugin(self): + plugin = PythonPlugin() + load_plugin = plugin.load_plugin() + self.assertIsNotNone(load_plugin) + self.assertIsNotNone(load_plugin.get('python_version')) + + def test_collect_attributes(self): + plugin = PythonPlugin() + attributes = plugin.collect_attributes() + self.assertIsNotNone(attributes) + self.assertEqual("MainThread", attributes.get("thread_name")) diff --git a/tests/unit_tests/api/resource/test_resource.py b/tests/unit_tests/api/resource/test_resource.py new file mode 100644 index 0000000..c07720c --- /dev/null +++ b/tests/unit_tests/api/resource/test_resource.py @@ -0,0 +1,420 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import uuid +from logging import ERROR, WARNING +from os import environ +from unittest.mock import Mock, patch + +from deep import version, logging +from deep.api.attributes import BoundedAttributes +from deep.api.resource import Resource, TELEMETRY_SDK_NAME, TELEMETRY_SDK_LANGUAGE, TELEMETRY_SDK_VERSION, \ + SERVICE_NAME, DEEP_SERVICE_NAME, DEEP_RESOURCE_ATTRIBUTES, ResourceDetector, _DEFAULT_RESOURCE, \ + get_aggregated_resources, _DEEP_SDK_VERSION, _EMPTY_RESOURCE, PROCESS_EXECUTABLE_NAME +from deep.config import ConfigService + + +class TestResources(unittest.TestCase): + def setUp(self) -> None: + logging.init(ConfigService({})) + environ[DEEP_RESOURCE_ATTRIBUTES] = "" + + def tearDown(self) -> None: + environ.pop(DEEP_RESOURCE_ATTRIBUTES) + + def test_create(self): + attributes = { + "service": "ui", + "version": 1, + "has_bugs": True, + "cost": 112.12, + } + + expected_attributes = { + "service": "ui", + "version": 1, + "has_bugs": True, + "cost": 112.12, + TELEMETRY_SDK_NAME: "deep", + TELEMETRY_SDK_LANGUAGE: "python", + TELEMETRY_SDK_VERSION: version.__version__, + SERVICE_NAME: "unknown_service:python", + } + + resource = Resource.create(attributes) + self.assertIsInstance(resource, Resource) + self.assertEqual(resource.attributes, BoundedAttributes(attributes=expected_attributes)) + self.assertEqual(resource.schema_url, "") + + schema_url = "https://opentelemetry.io/schemas/1.3.0" + + resource = Resource.create(attributes, schema_url) + self.assertIsInstance(resource, Resource) + self.assertEqual(resource.attributes, expected_attributes) + self.assertEqual(resource.schema_url, schema_url) + + environ[DEEP_RESOURCE_ATTRIBUTES] = "key=value" + resource = Resource.create(attributes) + self.assertIsInstance(resource, Resource) + expected_with_envar = expected_attributes.copy() + expected_with_envar["key"] = "value" + self.assertEqual(resource.attributes, expected_with_envar) + environ[DEEP_RESOURCE_ATTRIBUTES] = "" + + resource = Resource.get_empty() + self.assertEqual(resource, _EMPTY_RESOURCE) + + resource = Resource.create(None) + self.assertEqual( + resource, + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + self.assertEqual(resource.schema_url, "") + + resource = Resource.create(None, None) + self.assertEqual( + resource, + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + self.assertEqual(resource.schema_url, "") + + resource = Resource.create({}) + self.assertEqual( + resource, + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + self.assertEqual(resource.schema_url, "") + + resource = Resource.create({}, None) + self.assertEqual( + resource, + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + self.assertEqual(resource.schema_url, "") + + def test_resource_merge(self): + left = Resource({"service": "ui"}) + right = Resource({"host": "service-host"}) + self.assertEqual( + left.merge(right), + Resource({"service": "ui", "host": "service-host"}), + ) + schema_urls = ( + "https://opentelemetry.io/schemas/1.2.0", + "https://opentelemetry.io/schemas/1.3.0", + ) + + left = Resource.create({}, None) + right = Resource.create({}, None) + self.assertEqual(left.merge(right).schema_url, "") + + left = Resource.create({}, None) + right = Resource.create({}, schema_urls[0]) + self.assertEqual(left.merge(right).schema_url, schema_urls[0]) + + left = Resource.create({}, schema_urls[0]) + right = Resource.create({}, None) + self.assertEqual(left.merge(right).schema_url, schema_urls[0]) + + left = Resource.create({}, schema_urls[0]) + right = Resource.create({}, schema_urls[0]) + self.assertEqual(left.merge(right).schema_url, schema_urls[0]) + + left = Resource.create({}, schema_urls[0]) + right = Resource.create({}, schema_urls[1]) + with self.assertLogs(level=ERROR, logger=logging.logging.getLogger("deep")) as log_entry: + self.assertEqual(left.merge(right), left) + self.assertIn(schema_urls[0], log_entry.output[0]) + self.assertIn(schema_urls[1], log_entry.output[0]) + + def test_resource_merge_empty_string(self): + """Verify Resource.merge behavior with the empty string. + + Attributes from the source Resource take precedence, with + the exception of the empty string. + + """ + left = Resource({"service": "ui", "host": ""}) + right = Resource({"host": "service-host", "service": "not-ui"}) + self.assertEqual( + left.merge(right), + Resource({"service": "not-ui", "host": "service-host"}), + ) + + def test_immutability(self): + attributes = { + "service": "ui", + "version": 1, + "has_bugs": True, + "cost": 112.12, + } + + default_attributes = { + TELEMETRY_SDK_NAME: "deep", + TELEMETRY_SDK_LANGUAGE: "python", + TELEMETRY_SDK_VERSION: _DEEP_SDK_VERSION, + SERVICE_NAME: "unknown_service:python", + } + + attributes_copy = attributes.copy() + attributes_copy.update(default_attributes) + + resource = Resource.create(attributes) + self.assertEqual(resource.attributes, attributes_copy) + + with self.assertRaises(TypeError): + resource.attributes["has_bugs"] = False + self.assertEqual(resource.attributes, attributes_copy) + + attributes["cost"] = 999.91 + self.assertEqual(resource.attributes, attributes_copy) + + with self.assertRaises(AttributeError): + resource.schema_url = "bug" + + self.assertEqual(resource.schema_url, "") + + def test_service_name_using_process_name(self): + resource = Resource.create({PROCESS_EXECUTABLE_NAME: "test"}) + self.assertEqual( + resource.attributes.get(SERVICE_NAME), + "unknown_service:test", + ) + + def test_invalid_resource_attribute_values(self): + with self.assertLogs(level=WARNING, logger=logging.logging.getLogger("deep")): + resource = Resource( + { + SERVICE_NAME: "test", + "non-primitive-data-type": {}, + "invalid-byte-type-attribute": ( + b"\xd8\xe1\xb7\xeb\xa8\xe5 \xd2\xb7\xe1" + ), + "": "empty-key-value", + None: "null-key-value", + "another-non-primitive": uuid.uuid4(), + } + ) + self.assertEqual( + resource.attributes, + { + SERVICE_NAME: "test", + }, + ) + self.assertEqual(len(resource.attributes), 1) + + def test_aggregated_resources_no_detectors(self): + aggregated_resources = get_aggregated_resources([]) + self.assertEqual( + aggregated_resources, + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + + def test_aggregated_resources_with_default_destroying_static_resource( + self, + ): + static_resource = Resource({"static_key": "static_value"}) + + self.assertEqual( + get_aggregated_resources([], initial_resource=static_resource), + static_resource, + ) + + resource_detector = Mock(spec=ResourceDetector) + resource_detector.detect.return_value = Resource( + {"static_key": "try_to_overwrite_existing_value", "key": "value"} + ) + self.assertEqual( + get_aggregated_resources( + [resource_detector], initial_resource=static_resource + ), + Resource( + { + "static_key": "try_to_overwrite_existing_value", + "key": "value", + } + ), + ) + + def test_aggregated_resources_multiple_detectors(self): + resource_detector1 = Mock(spec=ResourceDetector) + resource_detector1.detect.return_value = Resource({"key1": "value1"}) + resource_detector2 = Mock(spec=ResourceDetector) + resource_detector2.detect.return_value = Resource( + {"key2": "value2", "key3": "value3"} + ) + resource_detector3 = Mock(spec=ResourceDetector) + resource_detector3.detect.return_value = Resource( + { + "key2": "try_to_overwrite_existing_value", + "key3": "try_to_overwrite_existing_value", + "key4": "value4", + } + ) + + self.assertEqual( + get_aggregated_resources( + [resource_detector1, resource_detector2, resource_detector3] + ), + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ).merge( + Resource( + { + "key1": "value1", + "key2": "try_to_overwrite_existing_value", + "key3": "try_to_overwrite_existing_value", + "key4": "value4", + } + ) + ), + ) + + def test_aggregated_resources_different_schema_urls(self): + resource_detector1 = Mock(spec=ResourceDetector) + resource_detector1.detect.return_value = Resource( + {"key1": "value1"}, "" + ) + resource_detector2 = Mock(spec=ResourceDetector) + resource_detector2.detect.return_value = Resource( + {"key2": "value2", "key3": "value3"}, "url1" + ) + resource_detector3 = Mock(spec=ResourceDetector) + resource_detector3.detect.return_value = Resource( + { + "key2": "try_to_overwrite_existing_value", + "key3": "try_to_overwrite_existing_value", + "key4": "value4", + }, + "url2", + ) + resource_detector4 = Mock(spec=ResourceDetector) + resource_detector4.detect.return_value = Resource( + { + "key2": "try_to_overwrite_existing_value", + "key3": "try_to_overwrite_existing_value", + "key4": "value4", + }, + "url1", + ) + self.assertEqual( + get_aggregated_resources([resource_detector1, resource_detector2]), + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ).merge( + Resource( + {"key1": "value1", "key2": "value2", "key3": "value3"}, + "url1", + ) + ), + ) + with self.assertLogs(level=ERROR, logger=logging.logging.getLogger("deep")) as log_entry: + self.assertEqual( + get_aggregated_resources( + [resource_detector2, resource_detector3] + ), + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ).merge( + Resource({"key2": "value2", "key3": "value3"}, "url1") + ), + ) + self.assertIn("url1", log_entry.output[0]) + self.assertIn("url2", log_entry.output[0]) + with self.assertLogs(level=ERROR, logger=logging.logging.getLogger("deep")): + self.assertEqual( + get_aggregated_resources( + [ + resource_detector2, + resource_detector3, + resource_detector4, + resource_detector1, + ] + ), + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ).merge( + Resource( + { + "key1": "value1", + "key2": "try_to_overwrite_existing_value", + "key3": "try_to_overwrite_existing_value", + "key4": "value4", + }, + "url1", + ) + ), + ) + self.assertIn("url1", log_entry.output[0]) + self.assertIn("url2", log_entry.output[0]) + + def test_resource_detector_ignore_error(self): + resource_detector = Mock(spec=ResourceDetector) + resource_detector.detect.side_effect = Exception() + resource_detector.raise_on_error = False + with self.assertLogs(level=WARNING, logger=logging.logging.getLogger("deep")): + self.assertEqual( + get_aggregated_resources([resource_detector]), + _DEFAULT_RESOURCE.merge( + Resource({SERVICE_NAME: "unknown_service:python"}, "") + ), + ) + + def test_resource_detector_raise_error(self): + resource_detector = Mock(spec=ResourceDetector) + resource_detector.detect.side_effect = Exception() + resource_detector.raise_on_error = True + self.assertRaises( + Exception, get_aggregated_resources, [resource_detector] + ) + + @patch.dict( + environ, + {"DEEP_RESOURCE_ATTRIBUTES": "key1=env_value1,key2=env_value2"}, + ) + def test_env_priority(self): + resource_env = Resource.create() + self.assertEqual(resource_env.attributes["key1"], "env_value1") + self.assertEqual(resource_env.attributes["key2"], "env_value2") + + resource_env_override = Resource.create( + {"key1": "value1", "key2": "value2"} + ) + self.assertEqual(resource_env_override.attributes["key1"], "value1") + self.assertEqual(resource_env_override.attributes["key2"], "value2") + + @patch.dict( + environ, + { + DEEP_SERVICE_NAME: "test-srv-name", + DEEP_RESOURCE_ATTRIBUTES: "service.name=svc-name-from-resource", + }, + ) + def test_service_name_env(self): + resource = Resource.create() + self.assertEqual(resource.attributes["service.name"], "test-srv-name") + + resource = Resource.create({"service.name": "from-code"}) + self.assertEqual(resource.attributes["service.name"], "from-code") diff --git a/test/test_deep/auth/__init__.py b/tests/unit_tests/auth/__init__.py similarity index 100% rename from test/test_deep/auth/__init__.py rename to tests/unit_tests/auth/__init__.py diff --git a/test/test_deep/auth/test_auth.py b/tests/unit_tests/auth/test_auth.py similarity index 100% rename from test/test_deep/auth/test_auth.py rename to tests/unit_tests/auth/test_auth.py diff --git a/test/test_deep/config/__init__.py b/tests/unit_tests/config/__init__.py similarity index 100% rename from test/test_deep/config/__init__.py rename to tests/unit_tests/config/__init__.py diff --git a/test/test_deep/config/test_config.py b/tests/unit_tests/config/test_config.py similarity index 100% rename from test/test_deep/config/test_config.py rename to tests/unit_tests/config/test_config.py diff --git a/test/test_deep/config/test_config_service.py b/tests/unit_tests/config/test_config_service.py similarity index 100% rename from test/test_deep/config/test_config_service.py rename to tests/unit_tests/config/test_config_service.py diff --git a/tests/unit_tests/config/test_tracepoint_config.py b/tests/unit_tests/config/test_tracepoint_config.py new file mode 100644 index 0000000..db7449a --- /dev/null +++ b/tests/unit_tests/config/test_tracepoint_config.py @@ -0,0 +1,151 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import mockito + +from deep.config.tracepoint_config import TracepointConfigService, ConfigUpdateListener +from deep.task import TaskHandler + + +class TestTracepointConfigService(unittest.TestCase): + + def test_no_change(self): + service = TracepointConfigService() + service.update_no_change(1) + self.assertEqual(1, service._last_update) + + service.update_no_change(2) + self.assertEqual(2, service._last_update) + + def test_update(self): + service = TracepointConfigService() + handler = mockito.mock() + service.set_task_handler(handler) + + mock_callback = mockito.mock() + mockito.when(handler).submit_task(mockito.ANY, mockito.eq(1), mockito.eq(None), + mockito.eq("123"), mockito.eq([]), + mockito.eq([])).thenReturn(mock_callback) + + service.update_new_config(1, "123", []) + self.assertEqual(1, service._last_update) + self.assertEqual("123", service.current_hash) + self.assertEqual([], service.current_config) + + mockito.verify(handler, mockito.times(1)).submit_task(mockito.ANY, mockito.eq(1), mockito.eq(None), + mockito.eq("123"), mockito.eq([]), + mockito.eq([])) + + mockito.verify(mock_callback, mockito.times(1)).add_done_callback(mockito.ANY) + + def test_update_no_handler(self): + service = TracepointConfigService() + + service.update_new_config(1, "123", []) + self.assertEqual(1, service._last_update) + self.assertEqual("123", service.current_hash) + self.assertEqual([], service.current_config) + + def test_calls_update_listeners(self): + service = TracepointConfigService() + handler = TaskHandler() + service.set_task_handler(handler) + + self.called = False + + class TestListener(ConfigUpdateListener): + + def config_change(me, ts, old_hash, current_hash, old_config, new_config): + self.called = True + + service.add_listener(TestListener()) + + service.update_new_config(1, "123", []) + + handler.flush() + + self.assertTrue(self.called) + + def test_bad_listener(self): + service = TracepointConfigService() + handler = TaskHandler() + service.set_task_handler(handler) + + self.called = False + + class TestListener(ConfigUpdateListener): + + def config_change(me, ts, old_hash, current_hash, old_config, new_config): + self.called = True + raise Exception("test bad listener") + + service.add_listener(TestListener()) + + service.add_custom("path", 123, {}, []) + + handler.flush() + + self.assertTrue(self.called) + + def test_add_custom_calls_update(self): + service = TracepointConfigService() + handler = TaskHandler() + service.set_task_handler(handler) + + self.called = False + + class TestListener(ConfigUpdateListener): + + def config_change(me, ts, old_hash, current_hash, old_config, new_config): + self.called = True + + service.add_listener(TestListener()) + + service.add_custom("path", 123, {}, []) + + handler.flush() + + self.assertTrue(self.called) + + def test_remove_custom_calls_update(self): + service = TracepointConfigService() + handler = TaskHandler() + service.set_task_handler(handler) + + self.called = False + + class TestListener(ConfigUpdateListener): + + def config_change(me, ts, old_hash, current_hash, old_config, new_config): + self.called = True + + service.add_listener(TestListener()) + + custom = service.add_custom("path", 123, {}, []) + + handler.flush() + + self.assertTrue(self.called) + + self.called = False + + handler._open = True + + service.remove_custom(custom) + + handler.flush() + + self.assertTrue(self.called) diff --git a/test/test_deep/grpc/__init__.py b/tests/unit_tests/grpc/__init__.py similarity index 100% rename from test/test_deep/grpc/__init__.py rename to tests/unit_tests/grpc/__init__.py diff --git a/test/test_deep/grpc/test_grpc.py b/tests/unit_tests/grpc/test_grpc.py similarity index 100% rename from test/test_deep/grpc/test_grpc.py rename to tests/unit_tests/grpc/test_grpc.py diff --git a/tests/unit_tests/poll/test_poll.py b/tests/unit_tests/poll/test_poll.py new file mode 100644 index 0000000..3dc189a --- /dev/null +++ b/tests/unit_tests/poll/test_poll.py @@ -0,0 +1,90 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import mockito + +import deep +from deep.api.resource import Resource +from deep.config import ConfigService +from deep.poll import LongPoll + +# noinspection PyUnresolvedReferences +from deepproto.proto.poll.v1.poll_pb2 import PollResponse, ResponseType + + +class TestPoll(unittest.TestCase): + + def setUp(self): + self.tracepoints = mockito.mock() + self.tracepoints.current_hash = '123' + self.config = ConfigService(tracepoints=self.tracepoints) + self.config.resource = Resource.create(attributes={"test": "test_poll"}) + self.grpc_service = mockito.mock() + self.handler = mockito.mock() + # mock for stub sending + mockito.when(self.handler).submit_task(mockito.ANY, mockito.ANY).thenReturn(mockito.mock()) + deep.logging.init(self.config) + + def test_can_poll(self): + poll = LongPoll(self.config, self.grpc_service) + + self.poll_request = None + + def mock_poll(request, **kwargs): + self.poll_request = request + return PollResponse(response_type=ResponseType.NO_CHANGE) + + mock_channel = mockito.mock() + self.grpc_service.channel = mock_channel + mockito.when(mock_channel).unary_unary(mockito.ANY, request_serializer=mockito.ANY, + response_deserializer=mockito.ANY).thenReturn(mock_poll) + + poll.start() + + poll.shutdown() + + self.assertIsNotNone(self.poll_request) + + self.assertEqual("test", self.poll_request.resource.attributes[3].key) + self.assertEqual("test_poll", self.poll_request.resource.attributes[3].value.string_value) + + mockito.verify(self.tracepoints, mockito.times(1)).update_no_change(mockito.ANY) + + def test_can_poll_new_cfg(self): + poll = LongPoll(self.config, self.grpc_service) + + self.poll_request = None + + def mock_poll(request, **kwargs): + self.poll_request = request + return PollResponse(response_type=ResponseType.UPDATE) + + mock_channel = mockito.mock() + self.grpc_service.channel = mock_channel + mockito.when(mock_channel).unary_unary(mockito.ANY, request_serializer=mockito.ANY, + response_deserializer=mockito.ANY).thenReturn(mock_poll) + + poll.start() + + poll.shutdown() + + self.assertIsNotNone(self.poll_request) + + self.assertEqual("test", self.poll_request.resource.attributes[3].key) + self.assertEqual("test_poll", self.poll_request.resource.attributes[3].value.string_value) + + mockito.verify(self.tracepoints, mockito.times(0)).update_no_change(mockito.ANY) + mockito.verify(self.tracepoints, mockito.times(1)).update_new_config(mockito.ANY, mockito.ANY, mockito.ANY) diff --git a/test/test_deep/processor/__init__.py b/tests/unit_tests/processor/__init__.py similarity index 100% rename from test/test_deep/processor/__init__.py rename to tests/unit_tests/processor/__init__.py diff --git a/test/test_deep/processor/test_log_messages.py b/tests/unit_tests/processor/test_log_messages.py similarity index 71% rename from test/test_deep/processor/test_log_messages.py rename to tests/unit_tests/processor/test_log_messages.py index 29c88c1..dd8416b 100644 --- a/test/test_deep/processor/test_log_messages.py +++ b/tests/unit_tests/processor/test_log_messages.py @@ -1,4 +1,17 @@ -# Copyright (C) 2023 Intergral GmbH +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -18,7 +31,7 @@ from deep.config import ConfigService from deep.processor.frame_processor import FrameProcessor -from test_deep.processor import MockFrame +from unit_tests.processor import MockFrame class TestLogMessages(unittest.TestCase): @@ -33,6 +46,8 @@ class TestLogMessages(unittest.TestCase): ["some log message: {person['name']}", "[deep] some log message: bob", {'person': {'name': 'bob'}}, ["bob"]], ]) def test_simple_log_interpolation(self, log_msg, expected_msg, _locals, expected_watches): + # noinspection PyTypeChecker + # Frame type is final, so we cannot check the type here processor = FrameProcessor([], MockFrame(_locals), ConfigService({})) processor.configure_self() log, watches, _vars = processor.process_log({}, log_msg) diff --git a/test/test_deep/processor/test_variable_processor.py b/tests/unit_tests/processor/test_variable_processor.py similarity index 100% rename from test/test_deep/processor/test_variable_processor.py rename to tests/unit_tests/processor/test_variable_processor.py diff --git a/tests/unit_tests/push/test_push.py b/tests/unit_tests/push/test_push.py new file mode 100644 index 0000000..70b98f3 --- /dev/null +++ b/tests/unit_tests/push/test_push.py @@ -0,0 +1,78 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from deep.api.tracepoint import WatchResult +from deep.push import convert_snapshot +from utils import mock_snapshot, mock_frame, mock_variable, mock_variable_id + + +def test_convert_snapshot(): + event_snapshot = mock_snapshot() + snapshot = convert_snapshot(event_snapshot) + assert snapshot is not None + + +def test_convert_snapshot_with_frame(): + event_snapshot = mock_snapshot(frames=[mock_frame()]) + snapshot = convert_snapshot(event_snapshot) + assert snapshot is not None + assert 1 == len(snapshot.frames) + + assert "file_name" == snapshot.frames[0].file_name + + +def test_convert_snapshot_with_frame_with_vars(): + event_snapshot = mock_snapshot(frames=[mock_frame(variables=[mock_variable_id()])], + var_lookup={'vid': mock_variable()}) + snapshot = convert_snapshot(event_snapshot) + assert snapshot is not None + assert 1 == len(snapshot.frames) + + assert "file_name" == snapshot.frames[0].file_name + + assert 1 == len(snapshot.frames[0].variables) + + assert "name" == snapshot.frames[0].variables[0].name + + assert 1 == len(snapshot.var_lookup) + + assert "name" == snapshot.var_lookup['vid'].value + + +def test_convert_snapshot_with_watch(): + event_snapshot = mock_snapshot() + event_snapshot.add_watch_result(WatchResult("test", mock_variable_id())) + + snapshot = convert_snapshot(event_snapshot) + + assert snapshot.watches[0].error_result == '' + assert snapshot.watches[0].good_result is not None + assert snapshot.watches[0].good_result.ID == "vid" + assert "good_result" == snapshot.watches[0].WhichOneof("result") + + +def test_convert_snapshot_with_error_watch(): + event_snapshot = mock_snapshot() + event_snapshot.add_watch_result(WatchResult("test", None, 'test error')) + + snapshot = convert_snapshot(event_snapshot) + + assert snapshot.watches[0].error_result == 'test error' + assert "error_result" == snapshot.watches[0].WhichOneof("result") + + +def test_convert_error(): + # noinspection PyTypeChecker + snapshot = convert_snapshot({}) + assert snapshot is None diff --git a/tests/unit_tests/push/test_push_service.py b/tests/unit_tests/push/test_push_service.py new file mode 100644 index 0000000..ea1982e --- /dev/null +++ b/tests/unit_tests/push/test_push_service.py @@ -0,0 +1,164 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import mockito + +import deep.logging +from deep.api.attributes import BoundedAttributes +from deep.api.plugin import Plugin +from deep.config import ConfigService +from deep.push import PushService +from utils import mock_snapshot, Captor + + +class TestPushService(unittest.TestCase): + + def setUp(self): + self.config = ConfigService() + self.grpc_service = mockito.mock() + self.handler = mockito.mock() + # mock for stub sending + mockito.when(self.handler).submit_task(mockito.ANY, mockito.ANY).thenReturn(mockito.mock()) + deep.logging.init(self.config) + + def tearDown(self): + mockito.unstub() + + def test_push_service(self): + service = PushService(self.config, self.grpc_service, self.handler) + service.push_snapshot(mock_snapshot()) + + mockito.verify(self.handler).submit_task(mockito.ANY, mockito.ANY) + + def test_push_service_function(self): + service = PushService(self.config, self.grpc_service, self.handler) + service.push_snapshot(mock_snapshot()) + + task_captor = Captor() + snapshot_captor = Captor() + + mockito.verify(self.handler).submit_task(task_captor, snapshot_captor) + + task = task_captor.get_value() + snapshot = snapshot_captor.get_value() + + self.assertIsNotNone(task) + self.assertIsNotNone(snapshot) + + mock_channel = mockito.mock() + + self.sent_snap = None + + def mock_send(snap, **kwargs): + self.sent_snap = snap + + self.grpc_service.channel = mock_channel + mockito.when(mock_channel).unary_unary(mockito.ANY, request_serializer=mockito.ANY, + response_deserializer=mockito.ANY).thenReturn(mock_send) + + task(snapshot) + + self.assertIsNotNone(self.sent_snap) + + def test_do_not_send_on_convert_failure(self): + service = PushService(self.config, self.grpc_service, self.handler) + + class FakeSnapshot: + def complete(self): + pass + + # noinspection PyTypeChecker + service.push_snapshot(FakeSnapshot()) + + task_captor = Captor() + snapshot_captor = Captor() + + mockito.verify(self.handler).submit_task(task_captor, snapshot_captor) + + task = task_captor.get_value() + snapshot = snapshot_captor.get_value() + + self.assertIsNotNone(task) + self.assertIsNotNone(snapshot) + + mock_channel = mockito.mock() + + self.sent_snap = None + + def mock_send(snap, **kwargs): + self.sent_snap = snap + + self.grpc_service.channel = mock_channel + mockito.when(mock_channel).unary_unary(mockito.ANY, request_serializer=mockito.ANY, + response_deserializer=mockito.ANY).thenReturn(mock_send) + + task(snapshot) + + self.assertIsNone(self.sent_snap) + + def test_does_decorate(self): + class TestPlugin(Plugin): + def load_plugin(self) -> BoundedAttributes: + return BoundedAttributes() + + def collect_attributes(self) -> BoundedAttributes: + return BoundedAttributes(attributes={ + 'test': 'plugin' + }) + + self.config.plugins.append(TestPlugin()) + + service = PushService(self.config, self.grpc_service, self.handler) + + service.push_snapshot(mock_snapshot()) + + task_captor = Captor() + snapshot_captor = Captor() + + mockito.verify(self.handler).submit_task(task_captor, snapshot_captor) + + task = task_captor.get_value() + snapshot = snapshot_captor.get_value() + + self.assertIsNotNone(task) + self.assertIsNotNone(snapshot) + + self.assertEqual(snapshot.attributes['test'], 'plugin') + + def test_does__send_on_decorate_FAIL(self): + class TestPlugin(Plugin): + def load_plugin(self) -> BoundedAttributes: + return BoundedAttributes() + + def collect_attributes(self) -> BoundedAttributes: + raise Exception("test exception") + + self.config.plugins.append(TestPlugin()) + + service = PushService(self.config, self.grpc_service, self.handler) + + service.push_snapshot(mock_snapshot()) + + task_captor = Captor() + snapshot_captor = Captor() + + mockito.verify(self.handler).submit_task(task_captor, snapshot_captor) + + task = task_captor.get_value() + snapshot = snapshot_captor.get_value() + + self.assertIsNotNone(task) + self.assertIsNotNone(snapshot) diff --git a/tests/unit_tests/task/test_task.py b/tests/unit_tests/task/test_task.py new file mode 100644 index 0000000..684bac1 --- /dev/null +++ b/tests/unit_tests/task/test_task.py @@ -0,0 +1,65 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest +from time import sleep + +from deep.task import TaskHandler, IllegalStateException + + +class TestTask(unittest.TestCase): + + def setUp(self): + self.count = 0 + + def task_function(self): + self.count += 1 + + def task_function_sleep(self): + sleep(2) + self.count += 1 + + def call_back(self, expected): + return lambda x: self.assertEqual(self.count, expected) + + def test_handle_task(self): + handler = TaskHandler() + future = handler.submit_task(self.task_function) + future.add_done_callback(self.call_back(1)) + handler.flush() + + def test_handle_task_with_error(self): + def raise_exception(): + raise Exception("test") + + handler = TaskHandler() + handler.submit_task(raise_exception) + handler.flush() + + def test_post_flush(self): + handler = TaskHandler() + handler.flush() + self.assertRaises(IllegalStateException, lambda: handler.submit_task(self.task_function)) + + def test_flush(self): + handler = TaskHandler() + handler.submit_task(self.task_function_sleep) + handler.submit_task(self.task_function_sleep) + handler.submit_task(self.task_function_sleep) + + self.assertEqual(self.count, 0) + + handler.flush() + + self.assertEqual(self.count, 3) diff --git a/tests/unit_tests/test_deep.py b/tests/unit_tests/test_deep.py new file mode 100644 index 0000000..82c6b5f --- /dev/null +++ b/tests/unit_tests/test_deep.py @@ -0,0 +1,44 @@ +# Copyright (C) 2023 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import unittest + +from deep import start +from deep.api import Deep + + +class DeepTest(unittest.TestCase): + + def test_deep(self): + # do not actual try and start deep in this test + Deep.start = lambda s: None + deep = start() + self.assertTrue(deep.config.APP_ROOT.endswith("/tests")) + + def test_deep_custom_config(self): + # do not actual try and start deep in this test + Deep.start = lambda s: None + deep = start({ + 'value': 'something' + }) + self.assertEqual(deep.config.value, 'something') + + def test_deep_use_configured_app_root(self): + # do not actual try and start deep in this test + Deep.start = lambda s: None + deep = start({ + 'APP_ROOT': '/some/path' + }) + self.assertEqual(deep.config.APP_ROOT, '/some/path') diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py new file mode 100644 index 0000000..6c115b9 --- /dev/null +++ b/tests/unit_tests/test_utils.py @@ -0,0 +1,96 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from time import sleep + +from deep.utils import snapshot_id_as_hex_str, time_ms, time_ns, str2bool, RepeatedTimer + + +def test_snapshot_id_as_hex_str(): + assert "0000000000000000000000000000007b" == snapshot_id_as_hex_str(123) + assert "000000000000000000000000499602d2" == snapshot_id_as_hex_str(1234567890) + assert "0000000000000000000000003ade68b1" == snapshot_id_as_hex_str(987654321) + + +def test_time_ms(): + assert time_ms() + assert len(str(time_ms())) == 13 + + +def test_time_ns(): + assert time_ns() + assert len(str(time_ns())) == 19 + + +def test_str2bool(): + assert str2bool("yes") + assert str2bool("Yes") + assert str2bool("y") + assert str2bool("Y") + assert str2bool("true") + assert str2bool("True") + assert str2bool("t") + assert str2bool("1") + assert not str2bool("0") + assert not str2bool("false") + assert not str2bool("False") + assert not str2bool("no") + assert not str2bool("No") + + +count = 0 + + +def test_repeated_timer(): + global count + + def repeated(val): + global count + val += 1 + count = count + 1 + + timer = RepeatedTimer("test", 1, repeated, 1) + timer.start() + sleep(2) + timer.stop() + + assert count > 0 + + +def test_repeated_timer_error(): + global count + count = 0 + + def repeated(val): + raise Exception("test") + + timer = RepeatedTimer("test", 1, repeated, 1) + timer.start() + sleep(2) + timer.stop() + + assert 0 == 0 diff --git a/tests/unit_tests/tracepoint/__init__.py b/tests/unit_tests/tracepoint/__init__.py new file mode 100644 index 0000000..962577d --- /dev/null +++ b/tests/unit_tests/tracepoint/__init__.py @@ -0,0 +1,14 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . diff --git a/test/test_deep/tracepoint/test_tracepoint_config.py b/tests/unit_tests/tracepoint/test_tracepoint_config.py similarity index 100% rename from test/test_deep/tracepoint/test_tracepoint_config.py rename to tests/unit_tests/tracepoint/test_tracepoint_config.py diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..bd26948 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,150 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Utils used for making testing easier.""" + +from mockito.matchers import Matcher + +from deep.api.resource import Resource +from deep.api.tracepoint import TracePointConfig, EventSnapshot, StackFrame, VariableId, Variable +from deep.utils import time_ns + + +def mock_tracepoint(**kwargs) -> TracePointConfig: + """ + Create a tracepoint while defaulting arguments if not set. + + :param kwargs: the arguments to set on the tracepoint + :return: the new tracepoint + """ + if 'tp_id' not in kwargs: + kwargs['tp_id'] = "tp_id" + if 'path' not in kwargs: + kwargs['path'] = "some_test.py" + if 'line_no' not in kwargs: + kwargs['line_no'] = 123 + if 'args' not in kwargs: + kwargs['args'] = {} + if 'watches' not in kwargs: + kwargs['watches'] = [] + + return TracePointConfig(**kwargs) + + +def mock_snapshot(**kwargs) -> EventSnapshot: + """ + Create a snapshot while defaulting arguments if not set. + + :param kwargs: the arguments to set on the snapshot + :return: the new snapshot + """ + if 'tracepoint' not in kwargs: + kwargs['tracepoint'] = mock_tracepoint() + if 'ts' not in kwargs: + kwargs['ts'] = time_ns() + if 'resource' not in kwargs: + kwargs['resource'] = Resource.get_empty() + if 'frames' not in kwargs: + kwargs['frames'] = [] + if 'var_lookup' not in kwargs: + kwargs['var_lookup'] = {} + + return EventSnapshot(**kwargs) + + +def mock_frame(**kwargs) -> StackFrame: + """ + Create a frame while defaulting arguments if not set. + + :param kwargs: the arguments to set on the frame + :return: the new frame + """ + if 'file_name' not in kwargs: + kwargs['file_name'] = 'file_name' + if 'short_path' not in kwargs: + kwargs['short_path'] = 'short_path' + if 'method_name' not in kwargs: + kwargs['method_name'] = 'method_name' + if 'line_number' not in kwargs: + kwargs['line_number'] = 123 + if 'variables' not in kwargs: + kwargs['variables'] = {} + if 'class_name' not in kwargs: + kwargs['class_name'] = 'class_name' + + return StackFrame(**kwargs) + + +def mock_variable_id(**kwargs) -> VariableId: + """ + Create a variable id while defaulting arguments if not set. + + :param kwargs: the arguments to set on the variable id + :return: the new variable id + """ + if 'vid' not in kwargs: + kwargs['vid'] = 'vid' + if 'name' not in kwargs: + kwargs['name'] = 'name' + + return VariableId(**kwargs) + + +def mock_variable(**kwargs) -> Variable: + """ + Create a variable while defaulting arguments if not set. + + :param kwargs: the arguments to set on the variable + :return: the new variable + """ + if 'var_type' not in kwargs: + kwargs['var_type'] = 'str' + if 'value' not in kwargs: + kwargs['value'] = 'name' + if 'var_hash' not in kwargs: + kwargs['var_hash'] = '17117' + if 'children' not in kwargs: + kwargs['children'] = [] + if 'truncated' not in kwargs: + kwargs['truncated'] = False + + return Variable(**kwargs) + + +class Captor(Matcher): + """Use with mockito to capture values passed ot mocks.""" + + def __init__(self, to_dict=False): + """Create new Captor.""" + self.values = [] + self.to_dict = to_dict + + def matches(self, arg): + """Validate the value matches the expected value.""" + if self.to_dict: + self.values.append(arg.as_dict()) + else: + self.values.append(arg) + return True + + def get_value(self): + """Get the captured value.""" + if len(self.values) > 0: + return self.values[len(self.values) - 1] + return None + + def get_values(self): + """Get all captured values.""" + return self.values