Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

try to avoid recording tests #354

Merged
merged 6 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ htmlcov/

/.tox
/node_modules

/ruff.toml
56 changes: 40 additions & 16 deletions _appmap/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
import ast
import importlib.metadata
import inspect
import json
import os
import re
import sys
from os.path import realpath
from pathlib import Path
from textwrap import dedent

import yaml
from yaml import SafeLoader
from yaml.parser import ParserError

from _appmap.labels import LabelSet
Expand Down Expand Up @@ -49,28 +52,27 @@ def _get_sys_prefix():
return realpath(sys.prefix)


_EXCLUDE_PATTERN = re.compile(r"\..*|node_modules|.*test.*|site-packages")


def find_top_packages(rootdir):
"""
Scan a directory tree for packages that should appear in the
default config file.
Scan a directory tree for packages that should appear in the default config file.

Examine directories in rootdir, to see if they contains an
__init__.py. If it does, add it to the list of packages and don't
scan any of its subdirectories. If it doesn't, scan its
Examine each directory in rootdir, to see if it contains an __init__.py. If it does, add it to
the list of packages and don't scan any of its subdirectories. If it doesn't, scan its
subdirectories to find __init__.py.

Some directories are automatically excluded from the search:
* sys.prefix
* Hidden directories (i.e. those that start with a '.')
* node_modules
Directory traversal will stop at directories that match _EXCLUDE_PATTERN. Such a directory (and
its subdirectories) will not be added to the returned packages.

For example, in a directory like this

% ls -F
LICENSE Makefile appveyor.yml docs/ src/ tests/
MANIFEST.in README.rst blog/ setup.py tddium.yml tox.ini

docs, src, tests, and blog will get scanned.
docs, src, blog will get scanned. tests will be ignored.

Only src has a subdirectory containing an __init__.py:

Expand Down Expand Up @@ -104,7 +106,7 @@ def find_top_packages(rootdir):
packages = set()

def excluded(d):
excluded = d == "node_modules" or d[0] == "."
excluded = _EXCLUDE_PATTERN.search(d) is not None
if excluded:
logger.trace("excluding dir %s", d)
return excluded
Expand All @@ -129,6 +131,19 @@ def excluded(d):
class AppMapInvalidConfigException(Exception):
pass

# We don't have any control over the PyYAML class hierarchy, so we can't control how many ancestors
# SafeLoader has....
class _ConfigLoader(SafeLoader): # pylint: disable=too-many-ancestors
def construct_mapping(self, node, deep=False):
mapping = super().construct_mapping(node, deep=deep)
# Allow record_test_cases to be set using a string (in addition to allowing a boolean).
if "record_test_cases" in mapping:
val = mapping["record_test_cases"]
if isinstance(val, str):
mapping["record_test_cases"] = val.lower() == "true"
return mapping


class Config(metaclass=SingletonMeta):
"""Singleton Config class"""

Expand All @@ -144,6 +159,9 @@ def __init__(self):
if "labels" in self._config:
self.labels.append(self._config["labels"])

def __repr__(self):
return json.dumps(self._config["packages"])

@property
def name(self):
return self._config["name"]
Expand All @@ -152,11 +170,16 @@ def name(self):
def packages(self):
return self._config["packages"]

@property
def record_test_cases(self):
return self._config.get("record_test_cases", False)

@property
def default(self):
ret = {
"name": self.default_name,
"language": "python",
"record_test_cases": False,
"packages": self.default_packages,
}
env = Env.current
Expand Down Expand Up @@ -229,7 +252,7 @@ def _load_config(self, show_warnings=False):
Env.current.enabled = False
self.file_valid = False
try:
self._config = yaml.safe_load(path.read_text(encoding="utf-8"))
self._config = yaml.load(path.read_text(encoding="utf-8"), Loader=_ConfigLoader)
if not self._config:
# It parsed, but was (effectively) empty.
self._config = self.default
Expand Down Expand Up @@ -330,7 +353,6 @@ def _check_path_value(self, value):
except SyntaxError:
return False


def startswith(prefix, sequence):
"""
Check if a sequence starts with the prefix.
Expand Down Expand Up @@ -373,7 +395,8 @@ class DistMatcher(PathMatcher):
def __init__(self, dist, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dist = dist
self.files = [str(pp.locate()) for pp in importlib.metadata.files(dist)]
dist_files = importlib.metadata.files(dist)
self.files = [str(pp.locate()) for pp in dist_files] if dist_files is not None else []

def matches(self, filterable):
try:
Expand Down Expand Up @@ -419,8 +442,8 @@ def wrap(self, filterable):
# appropriate.
#
rule = self.match(filterable)
wrapped = getattr(filterable.obj, "_appmap_wrapped", None)
if wrapped is None:
wrapped = getattr(filterable.obj, "_appmap_instrumented", None)
if not wrapped:
logger.trace(" wrapping %s", filterable.fqname)
Config.current.labels.apply(filterable)
ret = instrument(filterable)
Expand Down Expand Up @@ -483,6 +506,7 @@ def initialize():
# pylint: disable=protected-access
c._load_config(show_warnings=True)
logger.info("file: %s", c._file if c.file_present else "[no appmap.yml]")
logger.info("config: %r", c)
logger.debug("package_functions: %s", c.package_functions)
logger.info("env: %r", os.environ)
os.environ["_APPMAP_MESSAGES_SHOWN"] = "true"
30 changes: 21 additions & 9 deletions _appmap/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class FilterableFn(
):
__slots__ = ()

def __new__(cls, scope, fn, static_fn, auxtype=None):
fqname = "%s.%s" % (scope.fqname, fn.__name__)
def __new__(cls, scope, fn_name, fn, static_fn, auxtype=None): # pylint: disable=too-many-arguments
fqname = "%s.%s" % (scope.fqname, fn_name)
self = super(FilterableFn, cls).__new__(cls, scope.scope, fqname, fn, static_fn, auxtype)
return self

Expand Down Expand Up @@ -132,7 +132,9 @@ def is_member_func(m):
if key.startswith("__"):
continue
static_value = inspect.getattr_static(cls, key)
if isinstance(static_value, property):
# Don't use isinstance to check the type of static_value -- we don't want to invoke the
# descriptor protocol.
if Importer.instrument_properties and type(static_value) is property: # pylint: disable=unidiomatic-typecheck
properties[key] = (
static_value,
{
Expand Down Expand Up @@ -164,6 +166,9 @@ def initialize(cls):
cls.filter_stack = []
cls.filter_chain = []
cls._skip_instrumenting = ("appmap", "_appmap")
cls.instrument_properties = (
Env.current.get("APPMAP_INSTRUMENT_PROPERTIES", "true").lower() == "true"
)

@classmethod
def use_filter(cls, filter_class):
Expand Down Expand Up @@ -191,27 +196,34 @@ def do_import(cls, *args, **kwargs):
cls.filter_chain = reduce(lambda acc, e: e(acc), cls.filter_stack, NullFilter(None))

def instrument_functions(filterable, selected_functions=None):
# pylint: disable=too-many-locals
logger.trace(" looking for members of %s", filterable.obj)
functions, properties = get_members(filterable.obj)
logger.trace(" functions %s", functions)

for fn_name, static_fn, fn in functions:
filterableFn = FilterableFn(filterable, fn, static_fn)
filterableFn = FilterableFn(filterable, fn.__name__, fn, static_fn)
new_fn = cls.instrument_function(fn_name, filterableFn, selected_functions)
if new_fn != fn:
wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
fw = wrapt.wrap_function_wrapper(filterable.obj, fn_name, new_fn)
fw._appmap_instrumented = True # pylint: disable=protected-access

# Now that we've instrumented all the functions, go through the properties and update
# them
for prop_name, (prop, prop_fns) in properties.items():
instrumented_fns = {}
for k, (fn, auxtype) in prop_fns.items():
if fn is None or getattr(fn, "_appmap_wrapped", None):
if fn is None:
continue
filterableFn = FilterableFn(filterable, fn, fn, auxtype)
new_fn = cls.instrument_function(fn.__name__, filterableFn, selected_functions)
filterableFn = FilterableFn(filterable, prop_name, fn, fn, auxtype)
if getattr(fn, "_appmap_instrumented", None):
continue
new_fn = cls.instrument_function(prop_name, filterableFn, selected_functions)
if new_fn != fn:
new_fn = wrapt.FunctionWrapper(fn, new_fn)
setattr(new_fn, "_appmap_wrapped", True)
# Set _appmap_instrumented on the FunctionWrapper, not on the wrapped function
new_fn._appmap_instrumented = True # pylint: disable=protected-access

instrumented_fns[k] = new_fn
if len(instrumented_fns) > 0:
instrumented_fns["doc"] = prop.__doc__
Expand Down
2 changes: 1 addition & 1 deletion _appmap/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,5 @@ def instrumented_fn(wrapped, instance, args, kwargs):
return call_instrumented(f, instance, args, kwargs)

ret = instrumented_fn
setattr(ret, "_appmap_wrapped", True)
setattr(ret, "_appmap_instrumented", True)
return ret
4 changes: 4 additions & 0 deletions _appmap/test/data/pytest/appmap-no-test-cases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: Simple
record_test_cases: false
packages:
- path: simple
2 changes: 2 additions & 0 deletions _appmap/test/data/pytest/appmap.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
name: Simple
record_test_cases: true
packages:
- path: simple
- path: tests
Loading
Loading