diff --git a/python/ql/src/Variables/UndefinedExport.ql b/python/ql/src/Variables/UndefinedExport.ql index ff3f78ec4bcc..ea1936e1cc60 100644 --- a/python/ql/src/Variables/UndefinedExport.ql +++ b/python/ql/src/Variables/UndefinedExport.ql @@ -76,6 +76,20 @@ predicate contains_unknown_import_star(ModuleValue m) { ) } +/** Holds if `m` can provide missing attributes through a module-level `__getattr__`. */ +private predicate has_dynamic_module_attributes(ModuleValue m) { + major_version() = 3 and + minor_version() >= 7 and + m.hasAttribute("__getattr__") and + // If points-to cannot resolve the binding, do not assume it is non-callable. + not exists(Value getter, ClassValue cls | + getter = m.attr("__getattr__") and + cls = getter.getClass() and + not cls.isCallable() and + not cls.failedInference(_) + ) +} + from ModuleValue m, StringLiteral name, string exported_name where declaredInAll(m.getScope(), name) and @@ -83,5 +97,6 @@ where not m.hasAttribute(exported_name) and not is_exported_submodule_name(m, exported_name) and not contains_unknown_import_star(m) and - not mutates_globals(m) + not mutates_globals(m) and + not has_dynamic_module_attributes(m) select name, "The name '" + exported_name + "' is exported by __all__ but is not defined." diff --git a/python/ql/src/change-notes/2026-08-16-undefined-export-module-getattr.md b/python/ql/src/change-notes/2026-08-16-undefined-export-module-getattr.md new file mode 100644 index 000000000000..2ccf1b9b9ca0 --- /dev/null +++ b/python/ql/src/change-notes/2026-08-16-undefined-export-module-getattr.md @@ -0,0 +1,5 @@ +--- +category: minorAnalysis +--- + +* The `py/undefined-export` query no longer flags names listed in `__all__` when a Python 3.7 or newer module has a callable module-level `__getattr__` to provide attributes dynamically. diff --git a/python/ql/test/query-tests/Variables/undefined/UndefinedExport.expected b/python/ql/test/query-tests/Variables/undefined/UndefinedExport.expected index d2749575b60b..e2027cfe6c36 100644 --- a/python/ql/test/query-tests/Variables/undefined/UndefinedExport.expected +++ b/python/ql/test/query-tests/Variables/undefined/UndefinedExport.expected @@ -1,2 +1,5 @@ | decorated_exports.py:3:33:3:45 | StringLiteral | The name 'not_defined' is exported by __all__ but is not defined. | | exports.py:1:57:1:64 | StringLiteral | The name 'nosuch' is exported by __all__ but is not defined. | +| nested_getattr.py:1:12:1:26 | StringLiteral | The name 'still_missing' is exported by __all__ but is not defined. | +| non_callable_getattr.py:1:12:1:26 | StringLiteral | The name 'still_missing' is exported by __all__ but is not defined. | +| rebound_getattr.py:1:12:1:26 | StringLiteral | The name 'still_missing' is exported by __all__ but is not defined. | diff --git a/python/ql/test/query-tests/Variables/undefined/cached_getattr.py b/python/ql/test/query-tests/Variables/undefined/cached_getattr.py new file mode 100644 index 000000000000..a23f6dae23b2 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/cached_getattr.py @@ -0,0 +1,8 @@ +from functools import lru_cache + +__all__ = ["available_lazily"] + + +@lru_cache +def __getattr__(name): + return object() diff --git a/python/ql/test/query-tests/Variables/undefined/class_getattr.py b/python/ql/test/query-tests/Variables/undefined/class_getattr.py new file mode 100644 index 000000000000..390958de8cef --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/class_getattr.py @@ -0,0 +1,9 @@ +__all__ = ["available_lazily"] + + +class DynamicAttribute: + def __init__(self, name): + self.name = name + + +__getattr__ = DynamicAttribute diff --git a/python/ql/test/query-tests/Variables/undefined/dynamic_exports.py b/python/ql/test/query-tests/Variables/undefined/dynamic_exports.py new file mode 100644 index 000000000000..f5093f5e81f0 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/dynamic_exports.py @@ -0,0 +1,7 @@ +__all__ = ["available_lazily", "also_available_lazily"] + + +def __getattr__(name): + if name in __all__: + return object() + raise AttributeError(name) diff --git a/python/ql/test/query-tests/Variables/undefined/instance_getattr.py b/python/ql/test/query-tests/Variables/undefined/instance_getattr.py new file mode 100644 index 000000000000..2682f7280ef7 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/instance_getattr.py @@ -0,0 +1,9 @@ +__all__ = ["available_lazily"] + + +class DynamicAttributes: + def __call__(self, name): + return object() + + +__getattr__ = DynamicAttributes() diff --git a/python/ql/test/query-tests/Variables/undefined/lambda_getattr.py b/python/ql/test/query-tests/Variables/undefined/lambda_getattr.py new file mode 100644 index 000000000000..515d76b264c9 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/lambda_getattr.py @@ -0,0 +1,3 @@ +__all__ = ["available_lazily"] + +__getattr__ = lambda name: object() diff --git a/python/ql/test/query-tests/Variables/undefined/nested_getattr.py b/python/ql/test/query-tests/Variables/undefined/nested_getattr.py new file mode 100644 index 000000000000..e72fd3714d86 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/nested_getattr.py @@ -0,0 +1,6 @@ +__all__ = ["still_missing"] + + +class DynamicObject: + def __getattr__(self, name): + return name diff --git a/python/ql/test/query-tests/Variables/undefined/non_callable_getattr.py b/python/ql/test/query-tests/Variables/undefined/non_callable_getattr.py new file mode 100644 index 000000000000..b1699953c1d0 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/non_callable_getattr.py @@ -0,0 +1,4 @@ +__all__ = ["still_missing"] + +# This does not enable dynamic module attributes because it is not callable. +__getattr__ = None diff --git a/python/ql/test/query-tests/Variables/undefined/rebound_getattr.py b/python/ql/test/query-tests/Variables/undefined/rebound_getattr.py new file mode 100644 index 000000000000..0cd9f118e084 --- /dev/null +++ b/python/ql/test/query-tests/Variables/undefined/rebound_getattr.py @@ -0,0 +1,8 @@ +__all__ = ["still_missing"] + + +def __getattr__(name): + return object() + + +__getattr__ = None