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