diff --git a/README.md b/README.md index eceaf2e..221ab33 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ # lazy-import -python with no imports \ No newline at end of file +python with no imports + +hell ya it's got side effects + +## TODO + +- oh frick we actually need to conert to a NodeTransformer so that + we can control the rendering of child nodes (aka we can recursively + get the names in the namespace rather than having an entry-only visitor) \ No newline at end of file diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..3c1efd5 --- /dev/null +++ b/pdm.lock @@ -0,0 +1,11 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:6e3746c7427353cd94cfd6c1a5a6309fdfd871714e3b1213ded2befc6ef522a0" + +[[metadata.targets]] +requires_python = ">=3.10" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e58a6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "lazy-import" +version = "0.1.0" +description = "python with no imports" +authors = [ + {name = "sneakers-the-rat", email = "sneakers-the-rat@protonmail.com"}, +] +dependencies = [] +requires-python = ">=3.10" +readme = "README.md" +license = {text = "EUPL-1.2"} + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + + +[tool.pdm] +distribution = true diff --git a/src/lazy_import/__init__.py b/src/lazy_import/__init__.py new file mode 100644 index 0000000..8b969c5 --- /dev/null +++ b/src/lazy_import/__init__.py @@ -0,0 +1,4 @@ +import sys + +def getattr(name): + pass \ No newline at end of file diff --git a/src/lazy_import/ast.py b/src/lazy_import/ast.py new file mode 100644 index 0000000..b78106f --- /dev/null +++ b/src/lazy_import/ast.py @@ -0,0 +1,123 @@ +import ast +from collections import ChainMap +from dataclasses import dataclass, field +from pathlib import Path + +@dataclass +class Name: + + module: str + name: str | None = None + aliases: set = field(default_factory=set) + + def __contains__(self, item: str): + return item in self.aliases or ( + item == self.module + if self.name is None else + item == self.name + ) + + @classmethod + def from_ast_name(cls, name: ast.Name) -> 'Name': + return cls.from_str(name.id) + + @classmethod + def from_str(cls, name: str) -> 'Name': + if len(name_parts := name.rsplit(".")) > 1: + return Name(module=name_parts[0], name=name_parts[1]) + else: + return Name(module=name) + +@dataclass +class NameCollection: + + names: list[Name] = field(default_factory=set) + + def add(self, new_name: ast.Name): + for name in self.names: + if new_name.id in name: + name.aliases.add(new_name.id) + return + self.names.append(Name.from_ast_name(new_name)) + +class NameVisitor(ast.NodeVisitor): + """ + Extract names to import and assign from an importless python module + """ + + def __init__(self): + self.real_names = ChainMap() + self.fake_names = NameCollection() + + def pop_ctx(self): + self.real_names = self.real_names.parents + + def push_ctx(self): + self.real_names = self.real_names.new_child() + + def visit_Import(self, node: ast.Import | ast.ImportFrom) -> None: + """Add to names""" + for alias in node.names: + name = alias.asname if alias.asname else alias.name + self.real_names[name] = node + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """Add to names""" + self.visit_Import(node) + + + def visit_Name(self, node: ast.Name): + """Either add to real names or fake names depending on ctx""" + if node.ctx == ast.Store(): + self.real_names[node.id] = node + elif node.ctx == ast.Load() and node.id not in self.real_names: + self.fake_names.add(node) + elif node.ctx == ast.Del() and node.id in self.real_names: + del self.real_names[node.id] + else: # pragma: no cover + if node.ctx not in (ast.Del(), ast.Store(), ast.Load()): + raise ValueError('How did this happen!? wrong node ctx type?') + + def visit_Attribute(self, node: ast.Attribute): + attr_name = flatten_attribute(node) + if node.ctx == ast.Load(): + if attr_name not in self.real_names: + self.fake_names.add(Name.from_str(attr_name)) + elif node.ctx == ast.Store(): + self.real_names[attr_name] = node + elif node.ctx == ast.Del() and attr_name in self.real_names: + del self.real_names[attr_name] + else: # pragma: no cover + if node.ctx not in (ast.Del(), ast.Store(), ast.Load()): + raise ValueError('How did this happen!? wrong node ctx type?') + + def visit_FunctionDef(self, node): + """push context""" + + self.push_ctx() + args = node.args + for arg in args.args: + self.real_names[arg.arg] = arg + if args.vararg: + self.real_names[args.vararg.arg] = args.vararg + if args.kwarg: + self.real_names[args.kwarg.arg] = args.kwarg + + def visit_AsyncFunctionDef(self, node): + """push context""" + self.push_ctx() + + def visit_ClassDef(self, node): + """push context""" + self.push_ctx() + + def visit_Return(self, node): + """pop context""" + self.pop_ctx() + + +def flatten_attribute(attr: ast.Attribute) -> str: + if isinstance(attr.value, ast.Attribute): + return '.'.join([flatten_attribute(attr.value), attr.attr]) + elif isinstance(attr.value, ast.Name): + return '.'.join([attr.value.id, attr.attr]) diff --git a/src/lazy_import/importer.py b/src/lazy_import/importer.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..250228f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import lazy_import + +__all__ = ['lazy_import'] \ No newline at end of file diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/input_file.py b/tests/data/input_file.py new file mode 100644 index 0000000..92a8510 --- /dev/null +++ b/tests/data/input_file.py @@ -0,0 +1,51 @@ +import lazy_import +import collections.abc +import json as jay_son +from typing import List +from collections.abc import Callable +from collections.abc import ChainMap as cm + +mod_variable = 10 + +def imports_are_lazy(): + ast + typing.List + re.match('hell ya', 'hell ya') + # one day you should be able to do this + # numpy as np + + def a_function(a: typing.Iterable) -> importlib.abc.Finder: + a = 1 + + class AClass(): + zz = 1 + yy = array.array() + + def __init__(self): + _ = base64.b64decode(f"{binascii.hexlify(b'abc')}") + +def regular_names_still_work(): + # assert mod_variable == 10 + x = 20 + # assert x == 20 + + def afunc(y, z=1, *args, **kwargs): + z = 10 + assert z == 10 + assert y == 30 + + class AClass(): + z = 1 + + + afunc(30) + +def test_names_are_lazy(): + """ + you can just use the last unique segment + """ + _ = numpy.random.random(100) + _ = random + + assert random is numpy.random.random + diff --git a/tests/test_ast.py b/tests/test_ast.py new file mode 100644 index 0000000..c1df1e3 --- /dev/null +++ b/tests/test_ast.py @@ -0,0 +1 @@ +def test \ No newline at end of file diff --git a/tests/test_lazy_import.py b/tests/test_lazy_import.py new file mode 100644 index 0000000..f017afa --- /dev/null +++ b/tests/test_lazy_import.py @@ -0,0 +1,33 @@ +import lazy_import +from typing import List +import collections.abc +from collections.abc import Callable +from collections.abc import ChainMap as cm + +mod_variable = 10 + +def test_imports_are_lazy(): + re.match('hell ya', 'hell ya') + typing.List + +def test_regular_names_still_work(): + assert mod_variable == 10 + x = 20 + assert x == 20 + + def afunc(y): + z = 10 + assert z == 10 + assert y == 30 + + afunc(30) + +def test_names_are_lazy(): + """ + you can just use the last unique segment + """ + _ = numpy.random.random(100) + _ = random + + assert random is numpy.random.random +