diff --git a/poetry.lock b/poetry.lock index 83e520f..621c67a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -409,6 +409,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "py-cpuinfo" +version = "7.0.0" +description = "Get CPU info with pure Python 2 & 3" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "pygments" version = "2.5.2" @@ -469,6 +477,25 @@ wcwidth = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] +[[package]] +name = "pytest-benchmark" +version = "3.2.3" +description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer. See calibration_ and FAQ_." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +pathlib2 = {version = "*", markers = "python_version < \"3.4\""} +py-cpuinfo = "*" +pytest = ">=3.8" +statistics = {version = "*", markers = "python_version < \"3.4\""} + +[package.extras] +aspect = ["aspectlib"] +elasticsearch = ["elasticsearch"] +histogram = ["pygal", "pygaljs"] + [[package]] name = "pytz" version = "2021.1" @@ -659,6 +686,17 @@ python-versions = ">=3.5" lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] +[[package]] +name = "statistics" +version = "1.0.3.5" +description = "A Python 2.* port of 3.4 Statistics Module" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +docutils = ">=0.3" + [[package]] name = "stevedore" version = "3.3.0" @@ -764,7 +802,7 @@ testing = ["pathlib2", "unittest2", "jaraco.itertools", "func-timeout"] [metadata] lock-version = "1.1" python-versions = "~2.7 || ~=3.5" -content-hash = "66b3b619f735c9504ddf521624a8f902b6fb71e0d577c662e943087d0a2d66b7" +content-hash = "57946bea81d199e225e949dee5fce80d7777c2c688a0f0af4d4852f5014d827e" [metadata.files] alabaster = [ @@ -1000,6 +1038,9 @@ py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] +py-cpuinfo = [ + {file = "py-cpuinfo-7.0.0.tar.gz", hash = "sha256:9aa2e49675114959697d25cf57fec41c29b55887bff3bc4809b44ac6f5730097"}, +] pygments = [ {file = "Pygments-2.5.2-py2.py3-none-any.whl", hash = "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b"}, {file = "Pygments-2.5.2.tar.gz", hash = "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"}, @@ -1020,6 +1061,10 @@ pytest = [ {file = "pytest-4.6.11-py2.py3-none-any.whl", hash = "sha256:a00a7d79cbbdfa9d21e7d0298392a8dd4123316bfac545075e6f8f24c94d8c97"}, {file = "pytest-4.6.11.tar.gz", hash = "sha256:50fa82392f2120cc3ec2ca0a75ee615be4c479e66669789771f1758332be4353"}, ] +pytest-benchmark = [ + {file = "pytest-benchmark-3.2.3.tar.gz", hash = "sha256:ad4314d093a3089701b24c80a05121994c7765ce373478c8f4ba8d23c9ba9528"}, + {file = "pytest_benchmark-3.2.3-py2.py3-none-any.whl", hash = "sha256:01f79d38d506f5a3a0a9ada22ded714537bbdfc8147a881a35c1655db07289d9"}, +] pytz = [ {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, @@ -1151,6 +1196,9 @@ sphinxcontrib-serializinghtml = [ {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, ] +statistics = [ + {file = "statistics-1.0.3.5.tar.gz", hash = "sha256:2dc379b80b07bf2ddd5488cad06b2b9531da4dd31edb04dc9ec0dc226486c138"}, +] stevedore = [ {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"}, {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"}, diff --git a/pyproject.toml b/pyproject.toml index fdcf2ce..a313737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ sphinx-rtd-theme = {version = "~=0.5.1", python = "~=3.5"} vulture = {version = "~=2.3", python = "~=3.6"} bandit = {version = "~=1.7.0", python = "~=3.6"} pdbpp = "^0.10.2" +pytest-benchmark = "^3.2.3" [build-system] requires = ["poetry-core>=1.0.0", "Cython", "setuptools"] diff --git a/sysaudit/__init__.py b/sysaudit/__init__.py index 24af53c..cae68e3 100644 --- a/sysaudit/__init__.py +++ b/sysaudit/__init__.py @@ -1,52 +1,98 @@ __all__ = ["audit", "addaudithook", "subscribe", "Span"] import collections +import os import sys # Python 3.8+ # DEV: We could check `sys.version_info >= (3, 8)`, but if auditing ever gets # back ported we want to take advantage of that +std_audit = None +std_addaudithook = None if hasattr(sys, "audit") and hasattr(sys, "addaudithook"): - audit = sys.audit - addaudithook = sys.addaudithook -else: - try: - from ._csysaudit import audit, addaudithook - except ImportError: - _hooks = list() - - def audit(event, *args): - global _hooks - # Grab a copy of hooks so we don't need to lock here - for hook in _hooks[:]: - hook(event, args) - - def addaudithook(callback): - global _hooks - - # https://docs.python.org/3.8/library/sys.html#sys.addaudithook - # Raise an auditing event `sys.addaudithook` with no arguments. - # If any existing hooks raise an exception derived from RuntimeError, - # the new hook will not be added and the exception suppressed. - # As a result, callers cannot assume that their hook has been added - # unless they control all existing hooks. - try: - audit("sys.addaudithook") - except RuntimeError: - return - - if callback not in _hooks: - _hooks.append(callback) + std_audit = sys.audit + std_addaudithook = sys.addaudithook + +# Try to import Cython version +csysaudit_audit = None +csysaudit_addaudithook = None +try: + from . import _csysaudit + + csysaudit_audit = _csysaudit.audit + csysaudit_addaudithook = _csysaudit.addaudithook +except ImportError: + pass + + +# Pure-python implementation +_hooks = list() + +def py_audit(event, *args): + global _hooks + for hook in _hooks: + hook(event, args) + + +def py_addaudithook(callback): + global _hooks + + # https://docs.python.org/3.8/library/sys.html#sys.addaudithook + # Raise an auditing event `sys.addaudithook` with no arguments. + # If any existing hooks raise an exception derived from RuntimeError, + # the new hook will not be added and the exception suppressed. + # As a result, callers cannot assume that their hook has been added + # unless they control all existing hooks. + try: + audit("sys.addaudithook") + except RuntimeError: + return + + if callback not in _hooks: + _hooks.append(callback) + + +# Choose the best implementation +# DEV: We still import/create all of them +# so we can easily access each implementation +# for testing +SYSAUDIT_IMPL = os.getenv("SYSAUDIT_IMPL") +if SYSAUDIT_IMPL: + if SYSAUDIT_IMPL == "stdlib": + audit = std_audit + addaudithook = std_addaudithook + elif SYSAUDIT_IMPL == "csysaudit": + audit = csysaudit_audit + addaudithook = csysaudit_addaudithook + elif SYSAUDIT_IMPL == "pysysaudit": + audit = py_audit + addaudithook = py_addaudithook + else: + raise ValueError( + "SYSAUDIT_IMPL must be one of ('stdlib', 'csysaudit', 'pysysaudit')" + ) +else: + if std_audit and std_addaudithook: + audit = std_audit + addaudithook = std_addaudithook + elif csysaudit_audit and csysaudit_addaudithook: + audit = csysaudit_audit + addaudithook = csysaudit_addaudithook + else: + audit = py_audit + addaudithook = py_addaudithook + + +# Subscriptions _subscriptions = collections.defaultdict(list) _subscription_hook_active = False def _subscription_hook(event, args): if event in _subscriptions: - # Grab a copy of hooks so we don't need to lock here - for hook in _subscriptions[event][:]: + for hook in _subscriptions[event]: hook(args) diff --git a/sysaudit/__init__.pyi b/sysaudit/__init__.pyi index e8e12ec..0d16475 100644 --- a/sysaudit/__init__.pyi +++ b/sysaudit/__init__.pyi @@ -5,6 +5,19 @@ def audit(event: str, *args: typing.Any) -> None: ... def addaudithook( hook: typing.Callable[[str, typing.Tuple[typing.Any, ...]], None] ) -> None: ... + +_audit_fn = typing.Callable[[str, typing.Any], None] +_addaudithook_fn = typing.Callable[ + [typing.Callable[[str, typing.Tuple[typing.Any, ...]], None]], None +] + +std_audit = typing.Optional[_audit_fn] +std_addaudithook = typing.Optional[_addaudithook_fn] +csysaudit_audit = typing.Optional[_audit_fn] +csysaudit_addaudithook = typing.Optional[_addaudithook_fn] +py_audit = typing.Optional[_audit_fn] +py_addaudithook = typing.Optional[_addaudithook_fn] + def _subscription_hook(event: str, args: typing.Tuple[typing.Any, ...]) -> None: ... def subscribe( event: str, hook: typing.Callable[[typing.Tuple[typing.Any, ...]], None] diff --git a/sysaudit/_csysaudit.pyx b/sysaudit/_csysaudit.pyx index 8a74c06..5ef0ef6 100644 --- a/sysaudit/_csysaudit.pyx +++ b/sysaudit/_csysaudit.pyx @@ -1,15 +1,24 @@ -_hooks = list() +cdef list hooks = [] +cdef int has_hooks = 0 -def audit(event, *args): - global _hooks - # Grab a copy of hooks so we don't need to lock here - for hook in _hooks[:]: +cdef void _audit(str event, tuple args) except *: + global hooks + for hook in hooks: hook(event, args) -def addaudithook(callback): - global _hooks +def audit(event, *args): + global has_hooks + + if has_hooks == 0: + return + _audit(event, args) + + +cpdef void addaudithook(callback) except *: + global hooks + global has_hooks # https://docs.python.org/3.8/library/sys.html#sys.addaudithook # Raise an auditing event `sys.addaudithook` with no arguments. @@ -18,9 +27,9 @@ def addaudithook(callback): # As a result, callers cannot assume that their hook has been added # unless they control all existing hooks. try: - audit("sys.addaudithook") + _audit("sys.addaudithook", tuple()) except RuntimeError: return - if callback not in _hooks: - _hooks.append(callback) + has_hooks = 1 + hooks.append(callback) diff --git a/tests/audit-tests.py b/tests/audit-tests.py index 85dc781..dc74e1b 100644 --- a/tests/audit-tests.py +++ b/tests/audit-tests.py @@ -7,6 +7,7 @@ module with arguments identifying each test. import contextlib import typing +import os import sys import sysaudit diff --git a/tests/base.py b/tests/base.py index ef9b87b..4e7359f 100644 --- a/tests/base.py +++ b/tests/base.py @@ -2,15 +2,22 @@ import subprocess import sys import unittest +if sys.version_info < (3, 8): + IMPLEMENTATIONS = ("csysaudit", "pysysaudit") +else: + IMPLEMENTATIONS = ("stdlib", "csysaudit", "pysysaudit") + class BaseTest(unittest.TestCase): TEST_FILE_PY = None # type: str - def do_test(self, *args): + def _do_test(self, *args, **kwargs): popen_kwargs = dict( stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + if "impl" in kwargs: + popen_kwargs["env"] = dict(SYSAUDIT_IMPL=kwargs["impl"]) if sys.version_info >= (3, 6): popen_kwargs["encoding"] = "utf-8" @@ -23,13 +30,19 @@ class BaseTest(unittest.TestCase): if p.returncode: self.fail("".join(p.stderr)) - def run_python(self, *args): + def do_test(self, *args): + for impl in IMPLEMENTATIONS: + return self._do_test(*args, impl=impl) + + def _run_python(self, *args, **kwargs): events = [] popen_kwargs = dict( stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + if "impl" in kwargs: + popen_kwargs["env"] = dict(SYSAUDIT_IMPL=kwargs["impl"]) if sys.version_info >= (3, 6): popen_kwargs["encoding"] = "utf-8" @@ -43,3 +56,7 @@ class BaseTest(unittest.TestCase): [line.strip().partition(" ") for line in p.stdout], "".join(p.stderr), ) + + def run_python(self, *args): + for impl in IMPLEMENTATIONS: + return self._run_python(*args, impl=impl) diff --git a/tests/test_audit.py b/tests/test_audit.py index a02e0ea..03c91ba 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -10,7 +10,6 @@ import unittest from .base import BaseTest - skip_old_py = unittest.skipIf( sys.version_info < (3, 8), "Skipping tests testing built-in events" ) diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 0000000..5f3646b --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,31 @@ +import unittest + +import pytest + +import sysaudit + + +def hook(event, args): + pass + + +@pytest.mark.benchmark(group="audit") +def test_csysaudit_audit(benchmark): + sysaudit.csysaudit_addaudithook(hook) + + benchmark(sysaudit.csysaudit_audit, "event", 1, 2, 3) + + +@pytest.mark.benchmark(group="audit") +def test_std_audit(benchmark): + if not sysaudit.std_audit: + raise unittest.SkipTest("stdlib version not available") + + sysaudit.std_addaudithook(hook) + benchmark(sysaudit.std_audit, "event", 1, 2, 3) + + +@pytest.mark.benchmark(group="audit") +def test_py_audit(benchmark): + sysaudit.py_addaudithook(hook) + benchmark(sysaudit.py_audit, "event", 1, 2, 3) diff --git a/tests/test_import.py b/tests/test_import.py index 7842bf5..c602172 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -4,9 +4,17 @@ import sysaudit def test_module(): # type: () -> None + assert sysaudit.audit is not None + assert sysaudit.addaudithook is not None + if sys.version_info >= (3, 8, 0): + assert sysaudit.std_audit == sys.audit # type: ignore + assert sysaudit.std_addaudithook == sys.addaudithook # type: ignore assert sysaudit.audit == sys.audit # type: ignore [attr-defined] assert sysaudit.addaudithook == sys.addaudithook # type: ignore [attr-defined] else: - assert sysaudit.audit == sysaudit._csysaudit.audit - assert sysaudit.addaudithook == sysaudit._csysaudit.addaudithook + assert sysaudit.audit == sysaudit.csysaudit_audit + assert sysaudit.addaudithook == sysaudit.csysaudit_addaudithook + + assert sysaudit.py_audit is not None + assert sysaudit.py_addaudithook is not None