Edit on GitHub

sqlmesh.utils.metaprogramming

  1from __future__ import annotations
  2
  3import ast
  4import dis
  5import importlib
  6import inspect
  7import linecache
  8import logging
  9import os
 10import re
 11import sys
 12import textwrap
 13import types
 14import typing as t
 15from dataclasses import dataclass
 16from enum import Enum
 17from numbers import Number
 18from pathlib import Path
 19
 20from sqlmesh.core import constants as c
 21from sqlmesh.utils import format_exception, unique
 22from sqlmesh.utils.errors import SQLMeshError
 23from sqlmesh.utils.pydantic import PydanticModel
 24
 25logger = logging.getLogger(__name__)
 26
 27
 28IGNORE_DECORATORS = {"macro", "model", "signal"}
 29SERIALIZABLE_CALLABLES = (type, types.FunctionType)
 30LITERALS = (Number, str, bytes, tuple, list, dict, set, bool)
 31
 32
 33def _is_relative_to(path: t.Optional[Path | str], other: t.Optional[Path | str]) -> bool:
 34    if path is None or other is None:
 35        return False
 36
 37    if isinstance(path, str):
 38        path = Path(path)
 39    if isinstance(other, str):
 40        other = Path(other)
 41
 42    if "site-packages" in str(path) or not path.exists() or not other.exists():
 43        return False
 44
 45    try:
 46        path.absolute().relative_to(other.absolute())
 47        return True
 48    except ValueError:
 49        return False
 50
 51
 52def _code_globals(code: types.CodeType) -> t.Dict[str, None]:
 53    variables = {
 54        instruction.argval: None
 55        for instruction in dis.get_instructions(code)
 56        if instruction.opname == "LOAD_GLOBAL"
 57    }
 58
 59    for const in code.co_consts:
 60        if isinstance(const, types.CodeType):
 61            variables.update(_code_globals(const))
 62
 63    return variables
 64
 65
 66def _globals_match(obj1: t.Any, obj2: t.Any) -> bool:
 67    return type(obj1) == type(obj2) and (
 68        obj1 == obj2
 69        or (
 70            getattr(obj1, "__module__", None) == getattr(obj2, "__module__", None)
 71            and getattr(obj1, "__name__", None) == getattr(obj2, "__name__", None)
 72        )
 73    )
 74
 75
 76def func_globals(func: t.Callable) -> t.Dict[str, t.Any]:
 77    """Finds all global references and closures in a function and nested functions.
 78
 79    This function treats closures as global variables, which could cause problems in the future.
 80
 81    Args:
 82        func: The function to introspect
 83
 84    Returns:
 85        A dictionary of all global references.
 86    """
 87    variables = {}
 88
 89    if hasattr(func, "__code__"):
 90        root_node = parse_source(func)
 91
 92        func_args = next(node for node in ast.walk(root_node) if isinstance(node, ast.arguments))
 93        arg_defaults = (d for d in func_args.defaults + func_args.kw_defaults if d is not None)
 94
 95        # ast.Name corresponds to variable references, such as foo or x.foo. The former is
 96        # represented as Name(id=foo), and the latter as Attribute(value=Name(id=x) attr=foo)
 97        arg_globals = [
 98            n.id for default in arg_defaults for n in ast.walk(default) if isinstance(n, ast.Name)
 99        ]
100
101        code = func.__code__
102        for var in (
103            arg_globals + list(_code_globals(code)) + decorator_vars(func, root_node=root_node)
104        ):
105            if var in func.__globals__:
106                variables[var] = func.__globals__[var]
107
108        if func.__closure__:
109            for var, value in zip(code.co_freevars, func.__closure__):
110                variables[var] = value.cell_contents
111
112    return variables
113
114
115class ClassFoundException(Exception):
116    pass
117
118
119class _ClassFinder(ast.NodeVisitor):
120    def __init__(self, qualname: str) -> None:
121        self.stack: t.List[str] = []
122        self.qualname = qualname
123
124    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
125        self.stack.append(node.name)
126        self.stack.append("<locals>")
127        self.generic_visit(node)
128        self.stack.pop()
129        self.stack.pop()
130
131    visit_AsyncFunctionDef = visit_FunctionDef  # type: ignore
132
133    def visit_ClassDef(self, node: ast.ClassDef) -> None:
134        self.stack.append(node.name)
135        if self.qualname == ".".join(self.stack):
136            # Return the decorator for the class if present
137            if node.decorator_list:
138                line_number = node.decorator_list[0].lineno
139            else:
140                line_number = node.lineno
141
142            # decrement by one since lines starts with indexing by zero
143            line_number -= 1
144            raise ClassFoundException(line_number)
145        self.generic_visit(node)
146        self.stack.pop()
147
148
149class _DecoratorDependencyFinder(ast.NodeVisitor):
150    def __init__(self) -> None:
151        self.dependencies: t.List[str] = []
152
153    def _extract_dependencies(self, node: ast.ClassDef | ast.FunctionDef) -> None:
154        for decorator in node.decorator_list:
155            dependencies: t.List[str] = []
156            for n in ast.walk(decorator):
157                if isinstance(n, ast.Attribute):
158                    dep = n.attr
159                elif isinstance(n, ast.Name):
160                    dep = n.id
161                else:
162                    continue
163
164                if dep in IGNORE_DECORATORS:
165                    dependencies = []
166                    break
167
168                dependencies.append(dep)
169
170            self.dependencies.extend(dependencies)
171
172    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
173        self._extract_dependencies(node)
174
175    def visit_ClassDef(self, node: ast.ClassDef) -> None:
176        self._extract_dependencies(node)
177
178    visit_AsyncFunctionDef = visit_FunctionDef  # type: ignore
179
180
181def getsource(obj: t.Any) -> str:
182    """Get the source of a function or class.
183
184    inspect.getsource doesn't find decorators in python < 3.9
185    https://github.com/python/cpython/commit/696136b993e11b37c4f34d729a0375e5ad544ade
186    """
187    path = inspect.getsourcefile(obj)
188    if path:
189        module = inspect.getmodule(obj, path)
190
191        if module:
192            lines = linecache.getlines(path, module.__dict__)
193        else:
194            lines = linecache.getlines(path)
195
196        def join_source(lnum: int) -> str:
197            return "".join(inspect.getblock(lines[lnum:]))
198
199        if inspect.isclass(obj):
200            qualname = obj.__qualname__
201            source = "".join(lines)
202            tree = ast.parse(source)
203            class_finder = _ClassFinder(qualname)
204            try:
205                class_finder.visit(tree)
206            except ClassFoundException as e:
207                return join_source(e.args[0])
208        elif inspect.isfunction(obj):
209            obj = obj.__code__
210            if hasattr(obj, "co_firstlineno"):
211                lnum = obj.co_firstlineno - 1
212                pat = re.compile(r"^(\s*def\s)|(\s*async\s+def\s)|(.*(?<!\w)lambda(:|\s))|^(\s*@)")
213                while lnum > 0:
214                    try:
215                        line = lines[lnum]
216                    except IndexError:
217                        raise OSError("lineno is out of bounds")
218                    if pat.match(line):
219                        break
220                    lnum = lnum - 1
221                return join_source(lnum)
222    raise SQLMeshError(f"Cannot find source for {obj}")
223
224
225def parse_source(func: t.Callable) -> ast.Module:
226    """Parse a function and returns an ast node."""
227    return ast.parse(textwrap.dedent(getsource(func)))
228
229
230def _decorator_name(decorator: ast.expr) -> str:
231    node = decorator
232    if isinstance(decorator, ast.Call):
233        node = decorator.func
234    return node.id if isinstance(node, ast.Name) else ""
235
236
237def decorator_vars(func: t.Callable, root_node: t.Optional[ast.Module] = None) -> t.List[str]:
238    """
239    Returns a list of all the decorators of a callable, as well as names of objects that
240    are referenced in their argument list. These objects may be transitive dependencies
241    that we need to include in the serialized python environments.
242    """
243    root_node = root_node or parse_source(func)
244    finder = _DecoratorDependencyFinder()
245    finder.visit(root_node)
246    return unique(finder.dependencies)
247
248
249def normalize_source(obj: t.Any) -> str:
250    """Rewrites an object's source with formatting and doc strings removed by using Python ast.
251
252    Args:
253        obj: The object to fetch source from and convert to a string.
254
255    Returns:
256        A string representation of the normalized function.
257    """
258    root_node = parse_source(obj)
259
260    for node in ast.walk(root_node):
261        if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
262            for decorator in node.decorator_list:
263                if _decorator_name(decorator) in IGNORE_DECORATORS:
264                    node.decorator_list.remove(decorator)
265
266            # remove docstrings
267            body = node.body
268            if (
269                body
270                and isinstance(body[0], ast.Expr)
271                and isinstance(body[0].value, ast.Constant)
272                and isinstance(body[0].value.value, str)
273            ):
274                node.body = body[1:]
275
276            # remove function return type annotation
277            if isinstance(node, ast.FunctionDef):
278                node.returns = None
279
280    return ast.unparse(root_node).strip()
281
282
283def build_env(
284    obj: t.Any,
285    *,
286    env: t.Dict[str, t.Tuple[t.Any, t.Optional[bool]]],
287    name: str,
288    path: Path,
289    is_metadata_obj: bool = False,
290) -> None:
291    """Fills in env dictionary with all globals needed to execute the object.
292
293    Recursively traverse classes and functions.
294
295    Args:
296        obj: Any python object.
297        env: Dictionary to store the env.
298        name: Name of the object in the env.
299        path: The module path to serialize. Other modules will not be walked and treated as imports.
300        is_metadata_obj: An optional flag that determines whether the input object is metadata-only.
301    """
302    # We don't rely on `env` to keep track of visited objects, because it's populated in post-order
303    visited: t.Set[str] = set()
304
305    def walk(obj: t.Any, name: str, is_metadata: bool = False) -> None:
306        obj_module = inspect.getmodule(obj)
307        if obj_module and obj_module.__name__ == "builtins":
308            return
309
310        if name in visited:
311            if name not in env or _globals_match(env[name][0], obj):
312                return
313
314            raise SQLMeshError(
315                f"Cannot store {obj} in environment, duplicate definitions found for '{name}'"
316            )
317
318        visited.add(name)
319        name_missing_from_env = name not in env
320
321        if name_missing_from_env or (not is_metadata and env[name] == (obj, True)):
322            if not name_missing_from_env:
323                # The existing object in the env is "metadata only" but we're walking it again as a
324                # non-"metadata only" dependency, so we update this flag to ensure all transitive
325                # dependencies are also not marked as "metadata only"
326                is_metadata = False
327
328            if hasattr(obj, c.SQLMESH_MACRO):
329                # We only need to add the undecorated code of @macro() functions in env, which
330                # is accessible through the `__wrapped__` attribute added by functools.wraps
331                obj = obj.__wrapped__
332            elif callable(obj) and not isinstance(obj, SERIALIZABLE_CALLABLES):
333                obj = getattr(obj, "__wrapped__", None)
334                name = getattr(obj, "__name__", "")
335
336                # Callable class instances shouldn't be serialized (e.g. tenacity.Retrying).
337                # We still want to walk the callables they decorate, though
338                if not isinstance(obj, SERIALIZABLE_CALLABLES) or name in env:
339                    return
340
341            if (
342                not obj_module
343                or not hasattr(obj_module, "__file__")
344                or not _is_relative_to(obj_module.__file__, path)
345            ):
346                env[name] = (obj, is_metadata)
347                return
348
349            if inspect.isclass(obj):
350                for var in decorator_vars(obj):
351                    if obj_module and var in obj_module.__dict__:
352                        walk(obj_module.__dict__[var], var, is_metadata)
353
354                for base in obj.__bases__:
355                    walk(base, base.__qualname__, is_metadata)
356
357                for k, v in obj.__dict__.items():
358                    # skip dunder methods bar __init__ as it might contain user defined logic with cross class references
359                    if k.startswith("__") and k != "__init__":
360                        continue
361
362                    # Traverse methods in a class to find global references
363                    if isinstance(v, (classmethod, staticmethod)):
364                        v = v.__func__
365
366                    if callable(v):
367                        # Walk the method if it's part of the object, else it's a global function and we just store it
368                        if v.__qualname__.startswith(obj.__qualname__):
369                            try:
370                                for k, v in func_globals(v).items():
371                                    walk(v, k, is_metadata)
372                            except (OSError, TypeError):
373                                # __init__ may come from built-ins or wrapped callables
374                                pass
375                    else:
376                        walk(v, k, is_metadata)
377            elif callable(obj):
378                for k, v in func_globals(obj).items():
379                    walk(v, k, is_metadata)
380
381            # We store the object in the environment after its dependencies, because otherwise we
382            # could crash at environment hydration time, since dicts are ordered and the top-level
383            # objects would be loaded before their dependencies.
384            env[name] = (obj, is_metadata)
385        elif not _globals_match(env[name][0], obj):
386            raise SQLMeshError(
387                f"Cannot store {obj} in environment, duplicate definitions found for '{name}'"
388            )
389
390    # The "metadata only" annotation of the object is transitive
391    walk(obj, name, is_metadata_obj or getattr(obj, c.SQLMESH_METADATA, False))
392
393
394@dataclass
395class SqlValue:
396    """A SQL string representing a generated SQLGlot AST."""
397
398    sql: str
399
400
401class ExecutableKind(str, Enum):
402    """The kind of of executable. The order of the members is used when serializing the python model to text."""
403
404    IMPORT = "import"
405    VALUE = "value"
406    DEFINITION = "definition"
407
408    def __lt__(self, other: t.Any) -> bool:
409        if not isinstance(other, ExecutableKind):
410            return NotImplemented
411        values = list(ExecutableKind.__dict__.values())
412        return values.index(self) < values.index(other)
413
414    def __str__(self) -> str:
415        return self.value
416
417
418class Executable(PydanticModel):
419    payload: str
420    kind: ExecutableKind = ExecutableKind.DEFINITION
421    name: t.Optional[str] = None
422    path: t.Optional[str] = None
423    alias: t.Optional[str] = None
424    is_metadata: t.Optional[bool] = None
425
426    @property
427    def is_definition(self) -> bool:
428        return self.kind == ExecutableKind.DEFINITION
429
430    @property
431    def is_import(self) -> bool:
432        return self.kind == ExecutableKind.IMPORT
433
434    @property
435    def is_value(self) -> bool:
436        return self.kind == ExecutableKind.VALUE
437
438    @classmethod
439    def value(
440        cls, v: t.Any, is_metadata: t.Optional[bool] = None, sort_root_dict: bool = False
441    ) -> Executable:
442        payload = _dict_sort(v) if sort_root_dict else repr(v)
443        return Executable(
444            payload=payload,
445            kind=ExecutableKind.VALUE,
446            is_metadata=is_metadata or None,
447        )
448
449
450def _resolve_import_module(obj: t.Any, name: str) -> str:
451    """Resolve the most appropriate module path for importing an object.
452
453    When a callable's ``__module__`` points to a submodule of a known public
454    module (e.g. ``sqlglot.expressions.builders`` is a submodule of
455    ``sqlglot.expressions``), and the object is re-exported from that public
456    parent module, prefer the public parent so that generated import statements
457    remain stable across internal restructurings of third-party packages.
458
459    Args:
460        obj: The callable to resolve.
461        name: The name under which the object will be imported.
462
463    Returns:
464        The module path to use in the ``from <module> import <name>`` statement.
465    """
466    module_name = getattr(obj, "__module__", None) or ""
467    parts = module_name.split(".")
468
469    # Walk from the shallowest ancestor (excluding the top-level package) up to
470    # the immediate parent, returning the shallowest one that re-exports the object.
471    # We skip the top-level package to avoid over-normalizing (e.g. ``sqlglot``
472    # re-exports everything, but callers expect ``sqlglot.expressions``).
473    for i in range(2, len(parts)):
474        parent = ".".join(parts[:i])
475        try:
476            parent_module = sys.modules.get(parent) or importlib.import_module(parent)
477            if getattr(parent_module, name, None) is obj:
478                return parent
479        except Exception:
480            continue
481
482    return module_name
483
484
485def serialize_env(env: t.Dict[str, t.Any], path: Path) -> t.Dict[str, Executable]:
486    """Serializes a python function into a self contained dictionary.
487
488    Recursively walks a function's globals to store all other references inside of env.
489
490    Args:
491        env: Dictionary to store the env.
492        path: The root path to seralize. Other modules will not be walked and treated as imports.
493    """
494    serialized = {}
495
496    for k, (v, is_metadata) in env.items():
497        # We don't store `False` for `is_metadata` to reduce the pydantic model's payload size
498        is_metadata = is_metadata or None
499
500        if isinstance(v, LITERALS) or v is None:
501            serialized[k] = Executable.value(v, is_metadata=is_metadata)
502        elif inspect.ismodule(v):
503            name = v.__name__
504            if hasattr(v, "__file__") and _is_relative_to(v.__file__, path):
505                raise SQLMeshError(
506                    f"Cannot serialize 'import {name}'. Use 'from {name} import ...' instead."
507                )
508            postfix = "" if name == k else f" as {k}"
509            serialized[k] = Executable(
510                payload=f"import {name}{postfix}",
511                kind=ExecutableKind.IMPORT,
512                is_metadata=is_metadata,
513            )
514        elif callable(v):
515            name = v.__name__
516            name = k if name == "<lambda>" else name
517
518            # getfile raises a `TypeError` for built-in modules, classes, or functions
519            # https://docs.python.org/3/library/inspect.html#inspect.getfile
520            try:
521                file_path = Path(inspect.getfile(v))
522                relative_obj_file_path = _is_relative_to(file_path, path)
523
524                # A callable can be a "wrapper" that is defined in a third-party library [1], in which case the file
525                # containing its definition won't be relative to the project's path. This can lead to serializing
526                # it as a "relative import", such as `from models.some_python_model import foo`, because the `wraps`
527                # decorator preserves the wrapped function's module [2]. Payloads like this are invalid, as they
528                # can result in `ModuleNotFoundError`s when hydrating python environments, e.g. if a project's files
529                # are not available during a scheduled cadence run.
530                #
531                # [1]: https://github.com/jd/tenacity/blob/0d40e76f7d06d631fb127e1ec58c8bd776e70d49/tenacity/__init__.py#L322-L346
532                # [2]: https://github.com/python/cpython/blob/f502c8f6a6db4be27c97a0e5466383d117859b7f/Lib/functools.py#L33-L57
533                if not relative_obj_file_path and (wrapped := getattr(v, "__wrapped__", None)):
534                    v = wrapped
535                    file_path = Path(inspect.getfile(wrapped))
536                    relative_obj_file_path = _is_relative_to(file_path, path)
537            except TypeError:
538                file_path = None
539                relative_obj_file_path = False
540
541            if relative_obj_file_path:
542                serialized[k] = Executable(
543                    name=name,
544                    payload=normalize_source(v),
545                    kind=ExecutableKind.DEFINITION,
546                    # Do `as_posix` to serialize windows path back to POSIX
547                    path=t.cast(Path, file_path).relative_to(path.absolute()).as_posix(),
548                    alias=k if name != k else None,
549                    is_metadata=is_metadata,
550                )
551            else:
552                serialized[k] = Executable(
553                    payload=f"from {_resolve_import_module(v, name)} import {name}",
554                    kind=ExecutableKind.IMPORT,
555                    is_metadata=is_metadata,
556                )
557        else:
558            raise SQLMeshError(
559                f"Object '{v}' cannot be serialized. If it's defined in a library, import the corresponding "
560                "module and reference the object using its fully-qualified name. For example, the datetime "
561                "module's 'UTC' object should be accessed as 'datetime.UTC'."
562            )
563
564    return serialized
565
566
567def prepare_env(
568    python_env: t.Dict[str, Executable],
569    env: t.Optional[t.Dict[str, t.Any]] = None,
570) -> t.Dict[str, t.Any]:
571    """Prepare a python env by hydrating and executing functions.
572
573    The Python ENV is stored in a json serializable format.
574    Functions and imports are stored as a special data class.
575
576    Args:
577        python_env: The dictionary containing the serialized python environment.
578        env: The dictionary to execute code in.
579
580    Returns:
581        The prepared environment with hydrated functions.
582    """
583    env = {} if env is None else env
584
585    for name, executable in sorted(
586        python_env.items(), key=lambda item: 0 if item[1].is_import else 1
587    ):
588        if executable.is_value:
589            env[name] = eval(executable.payload)
590        else:
591            exec(executable.payload, env)
592            if executable.alias and executable.name:
593                env[executable.alias] = env[executable.name]
594
595    return env
596
597
598def format_evaluated_code_exception(
599    exception: Exception,
600    python_env: t.Dict[str, Executable],
601) -> str:
602    """Formats exceptions that occur from evaled code.
603
604    Stack traces generated by evaled code lose code context and are difficult to debug.
605    This intercepts the default stack trace and tries to make it debuggable.
606
607    Args:
608        exception: The exception to print the stack trace for.
609        python_env: The environment containing stringified python code.
610    """
611    tb: t.List[str] = []
612    indent = ""
613
614    skip_patterns = re.compile(
615        r"Traceback \(most recent call last\):|"
616        r'File ".*?core/model/definition\.py|'
617        r'File ".*?core/snapshot/definition\.py|'
618        r'File ".*?core/macros\.py|'
619        r'File ".*?inspect\.py'
620    )
621
622    for error_line in format_exception(exception):
623        if skip_patterns.search(error_line):
624            continue
625
626        error_match = re.search("^.*?Error: ", error_line)
627        if error_match:
628            tb.append(f"{indent * 2}  {error_line}")
629            continue
630
631        eval_code_match = re.search('File "<string>", line (.*), in (.*)', error_line)
632        if not eval_code_match:
633            tb.append(f"{indent}{error_line}")
634            continue
635
636        line_num = int(eval_code_match.group(1))
637        func = eval_code_match.group(2)
638
639        if func not in python_env:
640            tb.append(error_line)
641            continue
642
643        executable = python_env[func]
644        indent = error_line[: eval_code_match.start()]
645
646        error_line = (
647            f"{indent}File '{executable.path}' (or imported file), line {line_num}, in {func}"
648        )
649
650        code = executable.payload
651        formatted = []
652
653        for i, code_line in enumerate(code.splitlines()):
654            if i < line_num:
655                pad = len(code_line) - len(code_line.lstrip())
656                if i + 1 == line_num:
657                    formatted.append(f"{code_line[:pad]}{code_line[pad:]}")
658                else:
659                    formatted.append(code_line)
660
661        tb.extend(
662            (
663                error_line,
664                textwrap.indent(
665                    os.linesep.join(formatted),
666                    indent + "  ",
667                ),
668            )
669        )
670
671    return os.linesep.join(tb)
672
673
674def print_exception(
675    exception: Exception,
676    python_env: t.Dict[str, Executable],
677    out: t.TextIO = sys.stderr,
678) -> None:
679    """Prints exceptions that occur from evaled code.
680
681    Stack traces generated by evaled code lose code context and are difficult to debug.
682    This intercepts the default stack trace and tries to make it debuggable.
683
684    Args:
685        exception: The exception to print the stack trace for.
686        python_env: The environment containing stringified python code.
687        out: The output stream to write to.
688    """
689    tb = format_evaluated_code_exception(exception, python_env)
690    out.write(tb)
691
692
693def _dict_sort(obj: t.Any) -> str:
694    try:
695        if isinstance(obj, dict):
696            obj = dict(sorted(obj.items(), key=lambda x: str(x[0])))
697    except Exception:
698        logger.warning("Failed to sort non-recursive dict", exc_info=True)
699    return repr(obj)
700
701
702def import_python_file(path: Path, relative_base: Path = Path()) -> types.ModuleType:
703    relative_path = path.absolute().relative_to(relative_base.absolute())
704    module_name = str(relative_path.with_suffix("")).replace(os.path.sep, ".")
705
706    # remove the entire module hierarchy in case they were already loaded
707    parts = module_name.split(".")
708    for i in range(len(parts)):
709        sys.modules.pop(".".join(parts[0 : i + 1]), None)
710
711    return importlib.import_module(module_name)
logger = <Logger sqlmesh.utils.metaprogramming (WARNING)>
IGNORE_DECORATORS = {'macro', 'signal', 'model'}
SERIALIZABLE_CALLABLES = (<class 'type'>, <class 'function'>)
LITERALS = (<class 'numbers.Number'>, <class 'str'>, <class 'bytes'>, <class 'tuple'>, <class 'list'>, <class 'dict'>, <class 'set'>, <class 'bool'>)
def func_globals(func: Callable) -> Dict[str, Any]:
 77def func_globals(func: t.Callable) -> t.Dict[str, t.Any]:
 78    """Finds all global references and closures in a function and nested functions.
 79
 80    This function treats closures as global variables, which could cause problems in the future.
 81
 82    Args:
 83        func: The function to introspect
 84
 85    Returns:
 86        A dictionary of all global references.
 87    """
 88    variables = {}
 89
 90    if hasattr(func, "__code__"):
 91        root_node = parse_source(func)
 92
 93        func_args = next(node for node in ast.walk(root_node) if isinstance(node, ast.arguments))
 94        arg_defaults = (d for d in func_args.defaults + func_args.kw_defaults if d is not None)
 95
 96        # ast.Name corresponds to variable references, such as foo or x.foo. The former is
 97        # represented as Name(id=foo), and the latter as Attribute(value=Name(id=x) attr=foo)
 98        arg_globals = [
 99            n.id for default in arg_defaults for n in ast.walk(default) if isinstance(n, ast.Name)
100        ]
101
102        code = func.__code__
103        for var in (
104            arg_globals + list(_code_globals(code)) + decorator_vars(func, root_node=root_node)
105        ):
106            if var in func.__globals__:
107                variables[var] = func.__globals__[var]
108
109        if func.__closure__:
110            for var, value in zip(code.co_freevars, func.__closure__):
111                variables[var] = value.cell_contents
112
113    return variables

Finds all global references and closures in a function and nested functions.

This function treats closures as global variables, which could cause problems in the future.

Arguments:
  • func: The function to introspect
Returns:

A dictionary of all global references.

class ClassFoundException(builtins.Exception):
116class ClassFoundException(Exception):
117    pass

Common base class for all non-exit exceptions.

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
def getsource(obj: Any) -> str:
182def getsource(obj: t.Any) -> str:
183    """Get the source of a function or class.
184
185    inspect.getsource doesn't find decorators in python < 3.9
186    https://github.com/python/cpython/commit/696136b993e11b37c4f34d729a0375e5ad544ade
187    """
188    path = inspect.getsourcefile(obj)
189    if path:
190        module = inspect.getmodule(obj, path)
191
192        if module:
193            lines = linecache.getlines(path, module.__dict__)
194        else:
195            lines = linecache.getlines(path)
196
197        def join_source(lnum: int) -> str:
198            return "".join(inspect.getblock(lines[lnum:]))
199
200        if inspect.isclass(obj):
201            qualname = obj.__qualname__
202            source = "".join(lines)
203            tree = ast.parse(source)
204            class_finder = _ClassFinder(qualname)
205            try:
206                class_finder.visit(tree)
207            except ClassFoundException as e:
208                return join_source(e.args[0])
209        elif inspect.isfunction(obj):
210            obj = obj.__code__
211            if hasattr(obj, "co_firstlineno"):
212                lnum = obj.co_firstlineno - 1
213                pat = re.compile(r"^(\s*def\s)|(\s*async\s+def\s)|(.*(?<!\w)lambda(:|\s))|^(\s*@)")
214                while lnum > 0:
215                    try:
216                        line = lines[lnum]
217                    except IndexError:
218                        raise OSError("lineno is out of bounds")
219                    if pat.match(line):
220                        break
221                    lnum = lnum - 1
222                return join_source(lnum)
223    raise SQLMeshError(f"Cannot find source for {obj}")

Get the source of a function or class.

inspect.getsource doesn't find decorators in python < 3.9 https://github.com/python/cpython/commit/696136b993e11b37c4f34d729a0375e5ad544ade

def parse_source(func: Callable) -> ast.Module:
226def parse_source(func: t.Callable) -> ast.Module:
227    """Parse a function and returns an ast node."""
228    return ast.parse(textwrap.dedent(getsource(func)))

Parse a function and returns an ast node.

def decorator_vars(func: Callable, root_node: Optional[ast.Module] = None) -> List[str]:
238def decorator_vars(func: t.Callable, root_node: t.Optional[ast.Module] = None) -> t.List[str]:
239    """
240    Returns a list of all the decorators of a callable, as well as names of objects that
241    are referenced in their argument list. These objects may be transitive dependencies
242    that we need to include in the serialized python environments.
243    """
244    root_node = root_node or parse_source(func)
245    finder = _DecoratorDependencyFinder()
246    finder.visit(root_node)
247    return unique(finder.dependencies)

Returns a list of all the decorators of a callable, as well as names of objects that are referenced in their argument list. These objects may be transitive dependencies that we need to include in the serialized python environments.

def normalize_source(obj: Any) -> str:
250def normalize_source(obj: t.Any) -> str:
251    """Rewrites an object's source with formatting and doc strings removed by using Python ast.
252
253    Args:
254        obj: The object to fetch source from and convert to a string.
255
256    Returns:
257        A string representation of the normalized function.
258    """
259    root_node = parse_source(obj)
260
261    for node in ast.walk(root_node):
262        if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
263            for decorator in node.decorator_list:
264                if _decorator_name(decorator) in IGNORE_DECORATORS:
265                    node.decorator_list.remove(decorator)
266
267            # remove docstrings
268            body = node.body
269            if (
270                body
271                and isinstance(body[0], ast.Expr)
272                and isinstance(body[0].value, ast.Constant)
273                and isinstance(body[0].value.value, str)
274            ):
275                node.body = body[1:]
276
277            # remove function return type annotation
278            if isinstance(node, ast.FunctionDef):
279                node.returns = None
280
281    return ast.unparse(root_node).strip()

Rewrites an object's source with formatting and doc strings removed by using Python ast.

Arguments:
  • obj: The object to fetch source from and convert to a string.
Returns:

A string representation of the normalized function.

def build_env( obj: Any, *, env: Dict[str, Tuple[Any, Optional[bool]]], name: str, path: pathlib.Path, is_metadata_obj: bool = False) -> None:
284def build_env(
285    obj: t.Any,
286    *,
287    env: t.Dict[str, t.Tuple[t.Any, t.Optional[bool]]],
288    name: str,
289    path: Path,
290    is_metadata_obj: bool = False,
291) -> None:
292    """Fills in env dictionary with all globals needed to execute the object.
293
294    Recursively traverse classes and functions.
295
296    Args:
297        obj: Any python object.
298        env: Dictionary to store the env.
299        name: Name of the object in the env.
300        path: The module path to serialize. Other modules will not be walked and treated as imports.
301        is_metadata_obj: An optional flag that determines whether the input object is metadata-only.
302    """
303    # We don't rely on `env` to keep track of visited objects, because it's populated in post-order
304    visited: t.Set[str] = set()
305
306    def walk(obj: t.Any, name: str, is_metadata: bool = False) -> None:
307        obj_module = inspect.getmodule(obj)
308        if obj_module and obj_module.__name__ == "builtins":
309            return
310
311        if name in visited:
312            if name not in env or _globals_match(env[name][0], obj):
313                return
314
315            raise SQLMeshError(
316                f"Cannot store {obj} in environment, duplicate definitions found for '{name}'"
317            )
318
319        visited.add(name)
320        name_missing_from_env = name not in env
321
322        if name_missing_from_env or (not is_metadata and env[name] == (obj, True)):
323            if not name_missing_from_env:
324                # The existing object in the env is "metadata only" but we're walking it again as a
325                # non-"metadata only" dependency, so we update this flag to ensure all transitive
326                # dependencies are also not marked as "metadata only"
327                is_metadata = False
328
329            if hasattr(obj, c.SQLMESH_MACRO):
330                # We only need to add the undecorated code of @macro() functions in env, which
331                # is accessible through the `__wrapped__` attribute added by functools.wraps
332                obj = obj.__wrapped__
333            elif callable(obj) and not isinstance(obj, SERIALIZABLE_CALLABLES):
334                obj = getattr(obj, "__wrapped__", None)
335                name = getattr(obj, "__name__", "")
336
337                # Callable class instances shouldn't be serialized (e.g. tenacity.Retrying).
338                # We still want to walk the callables they decorate, though
339                if not isinstance(obj, SERIALIZABLE_CALLABLES) or name in env:
340                    return
341
342            if (
343                not obj_module
344                or not hasattr(obj_module, "__file__")
345                or not _is_relative_to(obj_module.__file__, path)
346            ):
347                env[name] = (obj, is_metadata)
348                return
349
350            if inspect.isclass(obj):
351                for var in decorator_vars(obj):
352                    if obj_module and var in obj_module.__dict__:
353                        walk(obj_module.__dict__[var], var, is_metadata)
354
355                for base in obj.__bases__:
356                    walk(base, base.__qualname__, is_metadata)
357
358                for k, v in obj.__dict__.items():
359                    # skip dunder methods bar __init__ as it might contain user defined logic with cross class references
360                    if k.startswith("__") and k != "__init__":
361                        continue
362
363                    # Traverse methods in a class to find global references
364                    if isinstance(v, (classmethod, staticmethod)):
365                        v = v.__func__
366
367                    if callable(v):
368                        # Walk the method if it's part of the object, else it's a global function and we just store it
369                        if v.__qualname__.startswith(obj.__qualname__):
370                            try:
371                                for k, v in func_globals(v).items():
372                                    walk(v, k, is_metadata)
373                            except (OSError, TypeError):
374                                # __init__ may come from built-ins or wrapped callables
375                                pass
376                    else:
377                        walk(v, k, is_metadata)
378            elif callable(obj):
379                for k, v in func_globals(obj).items():
380                    walk(v, k, is_metadata)
381
382            # We store the object in the environment after its dependencies, because otherwise we
383            # could crash at environment hydration time, since dicts are ordered and the top-level
384            # objects would be loaded before their dependencies.
385            env[name] = (obj, is_metadata)
386        elif not _globals_match(env[name][0], obj):
387            raise SQLMeshError(
388                f"Cannot store {obj} in environment, duplicate definitions found for '{name}'"
389            )
390
391    # The "metadata only" annotation of the object is transitive
392    walk(obj, name, is_metadata_obj or getattr(obj, c.SQLMESH_METADATA, False))

Fills in env dictionary with all globals needed to execute the object.

Recursively traverse classes and functions.

Arguments:
  • obj: Any python object.
  • env: Dictionary to store the env.
  • name: Name of the object in the env.
  • path: The module path to serialize. Other modules will not be walked and treated as imports.
  • is_metadata_obj: An optional flag that determines whether the input object is metadata-only.
@dataclass
class SqlValue:
395@dataclass
396class SqlValue:
397    """A SQL string representing a generated SQLGlot AST."""
398
399    sql: str

A SQL string representing a generated SQLGlot AST.

SqlValue(sql: str)
sql: str
class ExecutableKind(builtins.str, enum.Enum):
402class ExecutableKind(str, Enum):
403    """The kind of of executable. The order of the members is used when serializing the python model to text."""
404
405    IMPORT = "import"
406    VALUE = "value"
407    DEFINITION = "definition"
408
409    def __lt__(self, other: t.Any) -> bool:
410        if not isinstance(other, ExecutableKind):
411            return NotImplemented
412        values = list(ExecutableKind.__dict__.values())
413        return values.index(self) < values.index(other)
414
415    def __str__(self) -> str:
416        return self.value

The kind of of executable. The order of the members is used when serializing the python model to text.

IMPORT = <ExecutableKind.IMPORT: 'import'>
VALUE = <ExecutableKind.VALUE: 'value'>
DEFINITION = <ExecutableKind.DEFINITION: 'definition'>
Inherited Members
enum.Enum
name
value
builtins.str
encode
replace
split
rsplit
join
capitalize
casefold
title
center
count
expandtabs
find
partition
index
ljust
lower
lstrip
rfind
rindex
rjust
rstrip
rpartition
splitlines
strip
swapcase
translate
upper
startswith
endswith
removeprefix
removesuffix
isascii
islower
isupper
istitle
isspace
isdecimal
isdigit
isnumeric
isalpha
isalnum
isidentifier
isprintable
zfill
format
format_map
maketrans
class Executable(sqlmesh.utils.pydantic.PydanticModel):
419class Executable(PydanticModel):
420    payload: str
421    kind: ExecutableKind = ExecutableKind.DEFINITION
422    name: t.Optional[str] = None
423    path: t.Optional[str] = None
424    alias: t.Optional[str] = None
425    is_metadata: t.Optional[bool] = None
426
427    @property
428    def is_definition(self) -> bool:
429        return self.kind == ExecutableKind.DEFINITION
430
431    @property
432    def is_import(self) -> bool:
433        return self.kind == ExecutableKind.IMPORT
434
435    @property
436    def is_value(self) -> bool:
437        return self.kind == ExecutableKind.VALUE
438
439    @classmethod
440    def value(
441        cls, v: t.Any, is_metadata: t.Optional[bool] = None, sort_root_dict: bool = False
442    ) -> Executable:
443        payload = _dict_sort(v) if sort_root_dict else repr(v)
444        return Executable(
445            payload=payload,
446            kind=ExecutableKind.VALUE,
447            is_metadata=is_metadata or None,
448        )

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models. The origin and args items map to the [__origin__][genericalias.__origin__] and [__args__][genericalias.__args__] attributes of [generic aliases][types-genericalias], and the parameter item maps to the __parameter__ attribute of generic classes.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_fields__: A dictionary of field names and their corresponding [FieldInfo][pydantic.fields.FieldInfo] objects.
  • __pydantic_computed_fields__: A dictionary of computed field names and their corresponding [ComputedFieldInfo][pydantic.fields.ComputedFieldInfo] objects.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
payload: str
name: Optional[str]
path: Optional[str]
alias: Optional[str]
is_metadata: Optional[bool]
is_definition: bool
427    @property
428    def is_definition(self) -> bool:
429        return self.kind == ExecutableKind.DEFINITION
is_import: bool
431    @property
432    def is_import(self) -> bool:
433        return self.kind == ExecutableKind.IMPORT
is_value: bool
435    @property
436    def is_value(self) -> bool:
437        return self.kind == ExecutableKind.VALUE
@classmethod
def value( cls, v: Any, is_metadata: Optional[bool] = None, sort_root_dict: bool = False) -> Executable:
439    @classmethod
440    def value(
441        cls, v: t.Any, is_metadata: t.Optional[bool] = None, sort_root_dict: bool = False
442    ) -> Executable:
443        payload = _dict_sort(v) if sort_root_dict else repr(v)
444        return Executable(
445            payload=payload,
446            kind=ExecutableKind.VALUE,
447            is_metadata=is_metadata or None,
448        )
model_config = {'json_encoders': {<class 'sqlglot.expressions.core.Expr'>: <function _expression_encoder>, <class 'sqlglot.expressions.datatypes.DataType'>: <function _expression_encoder>, <class 'sqlglot.expressions.query.Tuple'>: <function _expression_encoder>, typing.Union[sqlglot.expressions.query.Query, sqlmesh.core.dialect.JinjaQuery]: <function _expression_encoder>, typing.Union[sqlglot.expressions.query.Query, sqlmesh.core.dialect.JinjaQuery, sqlmesh.core.dialect.MacroFunc]: <function _expression_encoder>, <class 'datetime.tzinfo'>: <function PydanticModel.<lambda>>}, 'arbitrary_types_allowed': True, 'extra': 'forbid', 'protected_namespaces': ()}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

Inherited Members
pydantic.main.BaseModel
BaseModel
model_fields
model_computed_fields
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
parse_file
from_orm
construct
schema
schema_json
validate
update_forward_refs
sqlmesh.utils.pydantic.PydanticModel
dict
json
copy
fields_set
parse_obj
parse_raw
missing_required_fields
extra_fields
all_fields
all_field_infos
required_fields
def serialize_env( env: Dict[str, Any], path: pathlib.Path) -> Dict[str, Executable]:
486def serialize_env(env: t.Dict[str, t.Any], path: Path) -> t.Dict[str, Executable]:
487    """Serializes a python function into a self contained dictionary.
488
489    Recursively walks a function's globals to store all other references inside of env.
490
491    Args:
492        env: Dictionary to store the env.
493        path: The root path to seralize. Other modules will not be walked and treated as imports.
494    """
495    serialized = {}
496
497    for k, (v, is_metadata) in env.items():
498        # We don't store `False` for `is_metadata` to reduce the pydantic model's payload size
499        is_metadata = is_metadata or None
500
501        if isinstance(v, LITERALS) or v is None:
502            serialized[k] = Executable.value(v, is_metadata=is_metadata)
503        elif inspect.ismodule(v):
504            name = v.__name__
505            if hasattr(v, "__file__") and _is_relative_to(v.__file__, path):
506                raise SQLMeshError(
507                    f"Cannot serialize 'import {name}'. Use 'from {name} import ...' instead."
508                )
509            postfix = "" if name == k else f" as {k}"
510            serialized[k] = Executable(
511                payload=f"import {name}{postfix}",
512                kind=ExecutableKind.IMPORT,
513                is_metadata=is_metadata,
514            )
515        elif callable(v):
516            name = v.__name__
517            name = k if name == "<lambda>" else name
518
519            # getfile raises a `TypeError` for built-in modules, classes, or functions
520            # https://docs.python.org/3/library/inspect.html#inspect.getfile
521            try:
522                file_path = Path(inspect.getfile(v))
523                relative_obj_file_path = _is_relative_to(file_path, path)
524
525                # A callable can be a "wrapper" that is defined in a third-party library [1], in which case the file
526                # containing its definition won't be relative to the project's path. This can lead to serializing
527                # it as a "relative import", such as `from models.some_python_model import foo`, because the `wraps`
528                # decorator preserves the wrapped function's module [2]. Payloads like this are invalid, as they
529                # can result in `ModuleNotFoundError`s when hydrating python environments, e.g. if a project's files
530                # are not available during a scheduled cadence run.
531                #
532                # [1]: https://github.com/jd/tenacity/blob/0d40e76f7d06d631fb127e1ec58c8bd776e70d49/tenacity/__init__.py#L322-L346
533                # [2]: https://github.com/python/cpython/blob/f502c8f6a6db4be27c97a0e5466383d117859b7f/Lib/functools.py#L33-L57
534                if not relative_obj_file_path and (wrapped := getattr(v, "__wrapped__", None)):
535                    v = wrapped
536                    file_path = Path(inspect.getfile(wrapped))
537                    relative_obj_file_path = _is_relative_to(file_path, path)
538            except TypeError:
539                file_path = None
540                relative_obj_file_path = False
541
542            if relative_obj_file_path:
543                serialized[k] = Executable(
544                    name=name,
545                    payload=normalize_source(v),
546                    kind=ExecutableKind.DEFINITION,
547                    # Do `as_posix` to serialize windows path back to POSIX
548                    path=t.cast(Path, file_path).relative_to(path.absolute()).as_posix(),
549                    alias=k if name != k else None,
550                    is_metadata=is_metadata,
551                )
552            else:
553                serialized[k] = Executable(
554                    payload=f"from {_resolve_import_module(v, name)} import {name}",
555                    kind=ExecutableKind.IMPORT,
556                    is_metadata=is_metadata,
557                )
558        else:
559            raise SQLMeshError(
560                f"Object '{v}' cannot be serialized. If it's defined in a library, import the corresponding "
561                "module and reference the object using its fully-qualified name. For example, the datetime "
562                "module's 'UTC' object should be accessed as 'datetime.UTC'."
563            )
564
565    return serialized

Serializes a python function into a self contained dictionary.

Recursively walks a function's globals to store all other references inside of env.

Arguments:
  • env: Dictionary to store the env.
  • path: The root path to seralize. Other modules will not be walked and treated as imports.
def prepare_env( python_env: Dict[str, Executable], env: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
568def prepare_env(
569    python_env: t.Dict[str, Executable],
570    env: t.Optional[t.Dict[str, t.Any]] = None,
571) -> t.Dict[str, t.Any]:
572    """Prepare a python env by hydrating and executing functions.
573
574    The Python ENV is stored in a json serializable format.
575    Functions and imports are stored as a special data class.
576
577    Args:
578        python_env: The dictionary containing the serialized python environment.
579        env: The dictionary to execute code in.
580
581    Returns:
582        The prepared environment with hydrated functions.
583    """
584    env = {} if env is None else env
585
586    for name, executable in sorted(
587        python_env.items(), key=lambda item: 0 if item[1].is_import else 1
588    ):
589        if executable.is_value:
590            env[name] = eval(executable.payload)
591        else:
592            exec(executable.payload, env)
593            if executable.alias and executable.name:
594                env[executable.alias] = env[executable.name]
595
596    return env

Prepare a python env by hydrating and executing functions.

The Python ENV is stored in a json serializable format. Functions and imports are stored as a special data class.

Arguments:
  • python_env: The dictionary containing the serialized python environment.
  • env: The dictionary to execute code in.
Returns:

The prepared environment with hydrated functions.

def format_evaluated_code_exception( exception: Exception, python_env: Dict[str, Executable]) -> str:
599def format_evaluated_code_exception(
600    exception: Exception,
601    python_env: t.Dict[str, Executable],
602) -> str:
603    """Formats exceptions that occur from evaled code.
604
605    Stack traces generated by evaled code lose code context and are difficult to debug.
606    This intercepts the default stack trace and tries to make it debuggable.
607
608    Args:
609        exception: The exception to print the stack trace for.
610        python_env: The environment containing stringified python code.
611    """
612    tb: t.List[str] = []
613    indent = ""
614
615    skip_patterns = re.compile(
616        r"Traceback \(most recent call last\):|"
617        r'File ".*?core/model/definition\.py|'
618        r'File ".*?core/snapshot/definition\.py|'
619        r'File ".*?core/macros\.py|'
620        r'File ".*?inspect\.py'
621    )
622
623    for error_line in format_exception(exception):
624        if skip_patterns.search(error_line):
625            continue
626
627        error_match = re.search("^.*?Error: ", error_line)
628        if error_match:
629            tb.append(f"{indent * 2}  {error_line}")
630            continue
631
632        eval_code_match = re.search('File "<string>", line (.*), in (.*)', error_line)
633        if not eval_code_match:
634            tb.append(f"{indent}{error_line}")
635            continue
636
637        line_num = int(eval_code_match.group(1))
638        func = eval_code_match.group(2)
639
640        if func not in python_env:
641            tb.append(error_line)
642            continue
643
644        executable = python_env[func]
645        indent = error_line[: eval_code_match.start()]
646
647        error_line = (
648            f"{indent}File '{executable.path}' (or imported file), line {line_num}, in {func}"
649        )
650
651        code = executable.payload
652        formatted = []
653
654        for i, code_line in enumerate(code.splitlines()):
655            if i < line_num:
656                pad = len(code_line) - len(code_line.lstrip())
657                if i + 1 == line_num:
658                    formatted.append(f"{code_line[:pad]}{code_line[pad:]}")
659                else:
660                    formatted.append(code_line)
661
662        tb.extend(
663            (
664                error_line,
665                textwrap.indent(
666                    os.linesep.join(formatted),
667                    indent + "  ",
668                ),
669            )
670        )
671
672    return os.linesep.join(tb)

Formats exceptions that occur from evaled code.

Stack traces generated by evaled code lose code context and are difficult to debug. This intercepts the default stack trace and tries to make it debuggable.

Arguments:
  • exception: The exception to print the stack trace for.
  • python_env: The environment containing stringified python code.
def import_python_file( path: pathlib.Path, relative_base: pathlib.Path = PosixPath('.')) -> module:
703def import_python_file(path: Path, relative_base: Path = Path()) -> types.ModuleType:
704    relative_path = path.absolute().relative_to(relative_base.absolute())
705    module_name = str(relative_path.with_suffix("")).replace(os.path.sep, ".")
706
707    # remove the entire module hierarchy in case they were already loaded
708    parts = module_name.split(".")
709    for i in range(len(parts)):
710        sys.modules.pop(".".join(parts[0 : i + 1]), None)
711
712    return importlib.import_module(module_name)