Writing/Metaprogramming: An Adventure

Chapter 9 · 25 min read

Source Code Manipulation

So far, whenever we wanted to parse or augment code, we used the AST module – and with good reason, since code is written in a grammar that includes nesting, precedence, and other context-sensitive syntax. To capture it properly, we need a recursive, elaborate tree – but I've also mentioned that code is, at the end of the day, a string.

What will happen if we treat it as such, and use simpler methods like regular expressions, or even plain old search and replace? Feels a bit scary, doesn't it? – and it wouldn't work in all cases, to be sure; but in some it might, and then we'd have saved ourselves a lot of trouble. What's more, we'll see how we can meet it half way: that is, write source code in a way that lends itself to these manipulations, in which case we really can achieve quite a lot without doing anything all that special.

Injecting Logs

To give an example, consider the @log decorator from the previous chapter. It was both simple and effective – the issue, if you remember, was that we could only apply it to the global scope, and even then only dynamically.

But what if we were more intrepid about it? Consider how Python functions are defined: putting aside the seldom used lambda notation, they always start on a new line, and always begin with the def keyword; that's a pretty unique signature. What's more, adding a decoration to one shouldn't disturb the rest of the system – we'll just inject a line before it, in our case @log.

The only thing we need to bear in mind, if we want to support methods and otherwise nested functions, is that any def may be preceded by an arbitrary amount of whitespace, which our decoration line would have to match. That is, given:

<whitespace>def <name>(<parameters>):
<whitespace>    <body>

We want to add:

<whitespace>@log

And that's really not all that hard, and pretty solid, as far as I can tell. The only edge case that comes to mind is multiline strings, which might contain lines starting with whitespace followed by def – but that's a pretty niche case, so I think we can safely ignore it. So then:

import re, sys

def add_log_decorations(source, target):
    code = open(source).read()
    code = f"from log import log\n\n{code}"
    code = re.sub(r"\n( *)def ", r"\n\1@log\n\1def ", code)
    open(target, "w").write(code)
    
if __name__ == "__main__":
    add_log_decorations(sys.argv[1], sys.argv[2])

Assuming we put our decorator in log.py and make sure it's on our import path, this should make it available and apply it to all the function definitions in a given source file. For example, this code:

def add(x, y):
    return x + y
    
class A:
    def f(self):
        def g():
            pass

Can be transformed like so:

$ python add_log_decorations.py example.py logged_example.py
$ cat logged_example.py
from log import log

@log
def add(x, y):
    return x + y
    
class A:
    @log
    def f(self):
        @log
        def g():
            pass

Autodecoration

We can actually generalize and refine this notion. First of all, we can inject an arbitrary decorator; second, we can do so to an entire project's directory at once; and third, we can do so in-place, since the chance of corrupting anything significant is so low, and reverting the changes with basic string operations is equally easy. I'll even add a little cherry on top and show you a cool site-customization trick.

First things first: let's call our script autodecorator.py, make it iterate over entire directories, and add or remove @autodecorator lines depending on an additional command-line argument:

import re, sys, pathlib

def add_decorations(path):
    code = path.read_text()
    code = re.sub(r"\n( *)def ", r"\n\1@autodecorator\n\1def ", code)
    path.write_text(code)

def remove_decorations(path):
    code = path.read_text()
    code = re.sub(r"\n *@autodecorator", "", code)
    path.write_text(code)
    
def main(instruction, root):
    root = pathlib.Path(root)
    for path in root.rglob("*.py"):
        if instruction == "add":
            add_decorations(path)
        if instruction == "remove":
            remove_decorations(path)
            
if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

And notice we don't add any import lines: we'll make autodecorator magically available as a builtin with the following command:

$ python autodecorator.py set <file>:<function>

To do this, we'll use the standard site module. Whenever we run python, it imports site, which searches and imports the first sitecustomize.py it finds on its import path; so if we add one in a standard location (like Python's site-packages/ directory), it'll get a chance to execute some code whenever Python runs. This code will add <file>'s directory to sys.path to make sure it's importable, import it, and set <function> as the autodecorator builtin. This way, whatever happens next will have it globally available and properly wired:

# … same as before

def main(instruction, path):
    if instruction in ["add", "remove"]:
        # … same as before
    if instruction == "set":
        path, function = path.split(":")
        set_autodecorator(path, function)

import site

HOOK = """
import builtins, sys
sys.path.append({directory!r})
import {module}
builtins.autodecorator = {module}.{function}
"""

def set_autodecorator(path, function):
    path = pathlib.Path(path).absolute()
    hook = pathlib.Path(site.getsitepackages()[0]) / "sitecustomize.py"
    hook.write_text(HOOK.format(
        directory=str(path.parent),
        module=path.stem,
        function=function,
    ))

Now, what decorator should we try? I think benchmarking is a good candidate: we occasionally need to know how long our functions take, but adding a decorator to each of them manually is tedious, and after we're done we'd have to clean it up. Consider benchmark.py, then:

import atexit, collections, functools, time

benchmarks = collections.defaultdict(list)

def benchmark(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        started = time.monotonic()
        try:
            return f(*args, **kwargs)
        finally:
            elapsed = time.monotonic() - started
            benchmarks[f.__name__].append(elapsed)
    return wrapper

@atexit.register
def print_benchmarks():
    avgs = [(sum(times) / len(times), name) for name, times in
                                            benchmarks.items()]
    avgs.sort(reverse=True)
    for time, name in avgs:
        print(f"{name}: {time:.02f}s")

The benchmark decorator measures how long a decorated function runs and stores a record in benchmarks, so at the end of the program we can average, sort and print the results. Given a (minimally) elaborate package, p:

# p/f.py
import time

def f():
    time.sleep(0.1)
    
# p/g.py
import time

def g():
    time.sleep(0.2)
    
# p/__init__.py
from .f import f
from .g import g

# p/__main__.py
from . import f, g

def main():
    for _ in range(3):
        f()
        g()
        
main()

We can benchmark its performance as easily as:

$ python autodecorator.py set benchmark.py:benchmark
$ python autodecorator.py add p/
$ python -m p
main: 0.92s
g: 0.2s
f: 0.1s
$ python autodecorator.py remove p/

And profiling is just an example, yeah? This tool lets us imbue our entire codebase with arbitrary additional logic – and do so in a decidedly unobtrusive way, magically configuring it on startup and removing all traces of it after we're done. We can even add an autodecorator.py clear command that deletes sitecustomize.py, but that's left as an exercise to the reader.

Asynchronous Programming

Let me give you an even more daring example; but first, a bit of background. Python 3.5 introduced the async and await keywords, which let us multiplex several execution flows on a single core. The way it works is by running an event loop, which knows to switch from one flow to another whenever some flow gets "stuck" – usually because it's waiting for some slow I/O operation to complete, like fetching rows from a database or communicating with some service over the web.

How do we know when such a blocking operation is happening, so we have time to run other flows until this one's result is ready? Easy – we don't; we expect the flows to tell us explicitly. That's what the await keyword is for: it effectively yields control back to the event loop, suspending the current flow until whatever it's awaiting becomes available and its execution can resume. If the whole thing smells like a bunch of generators, it's because it is: prior to the introduction of these special keywords, such "piecewise" functions, known as coroutines, were implemented by way of generators, using yield from to signal when they were about to block.

That's asynchronous programming in a nutshell: writing coroutines rather than plain functions, accounting for blocking I/O operations by preceding them with await, and deploying the whole thing to some event loop that knows how to switch between them on cue. It's great for performance, as it lets us utilize our compute resources much more economically – the problem with it, except some added complexity that doesn't exist in regular, synchronous programming, is that these two paradigms absolutely do not mix.

You see, if asynchronous code calls a synchronous function that does something "heavy", the entire event loop grinds to a halt. Since it's running on a single core, hogging the CPU prevents other flows from making any progress – but synchronous code is blissfully unaware (and in fact, incapable) of yielding control. It invariably assumes it has the entire core to itself, which is why we need multicore constructs like threads to parallelize it.

And vice versa: if synchronous code attempts to call an asynchronous function, it doesn't work either. Much like with generators, a coroutine is not something we can just invoke, but a gradual process that has to be emulated – and not in a simple loop, mind you, but in a way that performs the delegated I/O, and only ever resumes execution when it's ready. In other words, it necessitates an event loop: one could say coroutines are plugins in a framework that handles the aspect of asynchronous I/O, and plugins usually can't run "on their own".

What's worse, asynchronicity is contagious: if f() calls g(), which calls h(), and h is asynchronous – it means that g must await it, making g asynchronous; and that means f must await it, making f asynchronous as well. And if h is synchronous but does something heavy, it means that if f and g are coroutines, they mustn't call it, lest it clogs their event loop. To conclude: what we write in one paradigm simply cannot be reused in the other – at least, as long as I/O is involved, however deep in the call stack it might be hiding.

Syncification

Still, writing libraries that accommodate both styles is highly desirable: take HTTPX, for example, or SQLAlchemy. On one hand, we'd like to use them synchronously, for when squeezing the most out of our hardware is not that important, so there's no reason to burden ourselves with the added complexity of asynchronous code. On the other hand, we'd also like to make the switch when performance does matter – the problem, as we've established, is that almost no code that touches I/O, however indirectly, can be shared between the two stacks.

How do they do it, then? Well – besides designing around I/O, so at least some code can be reused, they just swallow the bitter pill and implement their functionality twice, once in each paradigm. Naturally, they try to keep the interfaces similar:

>>> import httpx
>>> with httpx.Client() as client:
...     response = client.get("http://www.example.com")
>>> # vs:
>>> async with httpx.AsyncClient() as client:
...     response = await client.get("http://www.example.com")

>>> # or…
>>> from sqlalchemy import create_engine
>>> engine = create_engine("sqlite:///db.sqlite3")
>>> with engine.connect() as conn:
...     rows = conn.execute("SELECT * FROM users")
>>> # vs:
>>> from sqlalchemy.ext.asyncio import create_async_engine
>>> engine = create_async_engine("sqlite+aiosqlite:///db.sqlite3")
>>> async with engine.connect() as conn:
...     rows = await conn.execute("SELECT * FROM users")

So we get to choose which one to use. If, however, we also want to accommodate both styles, we end up in the same boat. Suppose, for example, we're writing the webutils/ library, and we'd like to expose functionality that returns the page size of a given URL in either paradigm. First, we implement:

from httpx import Client

def page_size(url):
    with Client() as client:
        response = client.get(url)
        return len(response.content)

And then again, elsewhere:

from httpx import AsyncClient

async def page_size(url):
    async with AsyncClient() as client:
        response = await client.get(url)
        return len(response.content)

This effectively doubles the code we have to write, debug and maintain – so you have to wonder, can't we just derive one version from the other? Making synchronous code asynchronous is impossible, since we can't automatically guess which operations are heavy enough to warrant an await; but going the other way seems straightforward: simply removing all the asyncs and awaits from the second implementation effectively results in the first.

Here's the idea, then: in a process I call syncification, we take all our asynchronous modules, strip them of these two keywords, and put the resulting copies in a sync/ subpackage. This way, users of our library can do:

>>> from webutils import page_size
>>> size = await page_size("http://www.example.com")

Or, if they prefer the synchronous version:

>>> from webutils.sync import page_size
>>> size = page_size("http://www.example.com")

And we only have to write code once. Here's the script to do so:

import pathlib, re, sys

def syncify(root):
    root = pathlib.Path(root)
    sync = root / "sync"
    sync.mkdir(exist_ok=True)
    for path in root.rglob("*.py"):
        if path.parent.name == "sync":
            continue
        code = path.read_text()
        code = re.sub(r"([aA]sync_?|await) *", "", code)
        sync_path = sync / path.name
        sync_path.write_text(code)
        
if __name__ == "__main__":
    syncify(sys.argv[1])

Running python syncify.py webutils/ will iterate over all the files (except the sync/ directory itself) and copy them into sync/, removing any asyncs and awaits – even if they're capitalized or followed by an underscore. The former adjustment will change classes like AsyncClient into Client; the latter we'll address in a moment.

This works for HTTPX, but what if we were using SQLAlchemy to write a fetch function? The differences there are more pronounced: instead of from sqlalchemy we have from sqlalchemy.ext.asyncio, and instead of create_engine with an sqlite:// prefix, we do create_async_engine with sqlite+aiosqlite://. Of course, we can bruteforce this by adding the necessary substitutions to our script, it's brittle; consider, for instance, how different the code for table creation turns out:

>>> from sqlalchemy import MetaData
>>> metadata = MetaData()
>>> metadata.create_all(engine)
>>> # vs.
>>> async with engine.begin() as conn:
...     await conn.run_sync(metadata.create_all)

Addressing this with a regex is possible, but it ain't pretty. So instead, we'll change our approach, and see if we can meet our solution half way.

Bisynchronous Utilities

Let's start with a single, asynchronous implementation:

from .bisync import async_create_engine

async def fetch(table):
    engine = async_create_engine("sqlite:///db.sqlite3")
    async with engine.connect() as conn:
        rows = await conn.execute("SELECT * FROM users")
        for row in rows:
            yield row

But notice we're not using SQLAlchemy directly; instead, we refactor the contentious bits into a bisync.py module, which will be exempt from our syncification process. This means its code will be shared between the two paradigms – not because it can magically run in both, but because we'll use it to isolate scenarios where they diverge substantially, and provide easily replaceable entrypoints for each. In this case:

import sqlalchemy, sqlalchemy.ext.asyncio

def create_engine(url):
    return sqlalchemy.create_engine(url)

def async_create_engine(url):
    scheme, rest = url.split("://")
    dialect = scheme.split("+")[0]
    driver = "aiosqlite" # or {"sqlite": "aiosqlite", …}[dialect]
    url = f"{dialect}+{driver}://{rest}"
    return sqlalchemy.ext.asyncio.create_async_engine(url)

This way, async_create_engine patches the URL and returns an asynchronous engine, which is what we need; and after syncification, it becomes create_engine (hence the optional _ in our regex), which returns a regular engine – what we need again. More generally, we'd have to write two functions with the same signature per scenario: async_f to handle the asynchronous case and f to handle the synchronous one. When our script strips the async_ bit from the first, it effectively reroutes the code to use the second, paradigm-compatible alternative. Similarly, any code that needs to create tables shouldn't do it directly, but use this instead:

from .bisync import async_create_tables
await async_create_tables(engine, metadata)

Which will become:

from .bisync import create_tables
create_tables(engine, metadata)

Both of which we can implement appropriately in the safe haven of bisync.py:

# … same as before
def create_tables(engine, metadata):
    metadata.create_all(engine)
    
async def async_create_tables(engine, metadata):
    async with engine.begin() as conn:
        await conn.run_sync(metadata.create_all)

Such bisynchronous utilities can be arbitrarily complex: they can even use inherently asynchronous functionality, like tasks, provided that we implement a synchronous equivalent (e.g. with threads). Suppose, for example, that we'd want our page_size function to accept multiple URLs and calculate their sizes concurrently. Doing something like:

import asyncio
from httpx import AsyncClient

async def page_size(*urls):
    async with AsyncClient() as client:
        tasks = [asyncio.create_task(client.get(url)) for url in urls]
        responses = asyncio.gather(*tasks)
        return [len(response.content) for response in responses]

Is out of the question, since there's no reasonable way to syncify this code via search-and-replace (or even AST). Doing this, however:

from httpx import AsyncClient
from .bisync import async_parallelize

async def page_size(*urls):
    async with AsyncClient() as client:
        responses = await async_parallelize(client.get, urls)
        return [len(response.content) for response in responses]

Would work, since the syncified version:

from httpx import Client
from .bisync import parallelize

def page_size(*urls):
    with Client() as client:
        responses = parallelize(client.get, urls)
        return [len(response.content) for response in responses]

Makes just as much sense, provided we implement async_parallelize and parallelize accordingly:

import asyncio, concurrent.futures

def async_parallelize(f, args):
    tasks = [asyncio.create_task(f(arg)) for arg in args]
    return asyncio.gather(*tasks)
    
def parallelize(f, args):
    with concurrent.futures.ThreadPoolExecutor() as executor:
        return list(executor.map(f, args))

Import Normalization

One issue we have to address with syncification is that it screws up our project structure. Right now, it simply takes all the Python files within our package and "flattens" them into a sync subpackage – so modules from different directories that have a similar name will overwrite each other, for one. That actually doesn't bother me too much, since we can decide to call our modules by unique names (meeting our solution half way, yeah?); a much more acute problem is that when one module depends on another, moving them around breaks imports.

With relative imports it's quite obvious: something like from ...x import no longer makes sense from inside <package>.sync.<module>. Absolute imports will still compile, but they will import the wrong thing, going for the asynchronous implementation instead of the synchronous equivalent. So either way, we need to fix this – luckily, a simple search-and-replace will once again do the trick.

Both scenarios can be addressed, but personally I prefer relative imports, so I'll do those. What we need is to catch any line that has the form from .… and point it to the syncified version instead. Since all the synchronous modules are sitting next to each other inside sync/, it means from .x.y.z should become from .z, from ..x.y should become from .y, and from ...x should become from .x – all of which can be done by a single regex:

def syncify(root):
    # … same as before
    code = path.read_text()
    code = re.sub(r"from \.\S*?([a-zA-Z_][a-zA-Z0-9_]*) ", r"from .\1 ", code)
    # … same as before

The only exception is from .bisync, which, assuming it sits at the root of the package, should become from ..bisync – but this doesn't even require a regular expression:

def syncify(root):
    # … same as before
    code = path.read_text()
    code = re.sub(r"from \.\S*?([a-zA-Z_][a-zA-Z0-9_]*) ", r"from .\1 ", code)
    code = code.replace("from .bisync ", "from ..bisync ")
    # … same as before

And this takes care of most import-related problems, if not all. Some issues might crop up, of course, but nothing major: I'll tell you about one I had, just to wrap up nicely. When I work this way, I also duplicate my tests, just to make sure syncification didn't break anything (the probability of both the code and its test mutating in a wrong-yet-coordinated way strikes me as pretty slim). To test asynchronous code, however, we need the pytest-asyncio extension, and a special marker to make Pytest consider coroutines as tests:

import pytest
import pytest_asyncio # might be needed, e.g. for @pytest_asyncio.fixture

from webutils import page_size

pytestmark = pytest.mark.asyncio

async def test_page_size():
    assert await page_size("http://www.example.com") == 1256

To syncify this, we need to remove the pytest_asyncio import and marker; but then, if I don't use pytest anywhere else in the code, the linter complains that import pytest is unused, so we might need to remove it, too. And of course, we need to change from webutils import to from webutils.sync import, so as to wire the tests to our synchronous code. Cleaning this up is a bit particular, but aside from that I can't think of any special handling I had to do in my projects, so this should really cover most our bases:

def syncify(root):
    # … same as before
    tests_root = root.parent / "tests"
    async_tests = tests_root / "async"
    sync_tests = tests_root / "sync"
    sync_tests.mkdir(exist_ok=True)
    for path in async_tests.rglob("*.py"):
        code = path.read_text()
        code = re.sub(f"from {root.name}", f"from {root.name}.sync", code)
        if "import pytest\n" in code:
            code = code.replace("import pytest_asyncio\n", "")
        code = code.replace("pytest_asyncio", "pytest")
        code = re.sub(r"\n*pytestmark = pytest.mark.asyncio", "", code)
        if "import pytest" in code and code.count("pytest") == 1:
            code = code.replace("import pytest\n", "")
        code = re.sub(r"([aA]sync_?|await) *", "", code)
        sync_path = sync_tests / path.relative_to(async_tests)
        sync_path.parent.mkdir(exist_ok=True, parents=True)
        sync_path.write_text(code)

Circular Imports

Imports, as you can see, are a bit of a hassle; not only when we're engaged in mad metaprogramming, but in perfectly reasonable code as well. So before we close, let me take a short detour from manipulating source into the import mechanics themselves – it will pay off, I promise, with a finale that brings us right back to treating code as a string. Consider a simple fileutils/ package, which has a Directory class in one module and a File class in another:

# fileutils/directory.py
from .file import File
class Directory:
    def get_files(self) -> list[File]:
        …
        
# fileutils/file.py
from .directory import Directory
class File:
    @property
    def directory(self) -> Directory:
        …

This code doesn't work, on account of a circular import: directory.py attempts to import file.py, which in turn attempts to import directory.py, at which point the interpreter crashes with an ImportError. This, despite the fact that a circular dependencies between files and directories is perfectly normal, not due to some contrived design on our part.

Python's typing library comes equipped to handle such cases with the special TYPE_CHECKING flag, which lets us place otherwise problematic dependencies in an if-statement that will be False at runtime:

# fileutils/directory.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .file import File
# … same as before

# fileutils/file.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .directory import Directory
# … same as before

This way, our code no longer crashes – at least, not immediately. The problem is that our particular circular dependency exists in runtime as well; besides their annotations, the methods in question will have to instantiate the actual object in their bodies, e.g.:

class File:
    @property
    def directory(self) -> Directory:
        return Directory(…)

At which point our program raises a NameError, since Directory is never defined when the TYPE_CHECKING clause is skipped. Luckily, there are several ways to bypass this, too – we can, for instance, place the problematic imports at the bottom of the file instead of its top:

# fileutils/directory.py
from __future__ import annotations
class Directory:
    def get_files(self) -> list[File]:
        …
from .file import File

# fileutils/file.py
from __future__ import annotations
class File:
    @property
    def directory(self) -> Directory:
        …
from .directory import Directory

We need from __future__ import annotations to defer annotation resolution, but otherwise this works, thanks to Python's support of partial initialization: by the time directory.py attempts to import file.py, the Directory class is already defined, so when file.py attempts to import it from directory.py it actually works, despite the fact directory.py as a whole is still being evaluated.

Still, putting imports at the bottom feel a bit awkward; Google's coding standards recommend doing this instead:

# fileutils/file.py
from typing import TYPE_CHECKING
from . import directory
if TYPE_CHECKING:
    from .directory import Directory
    
class File:
    @property
    def directory(self) -> Directory:
        return directory.Directory(…)

By importing the module instead of the class, we're once again relying on partial initialization: the statement succeeds even though it's still being evaluated, and by the time it's actually used, its objects are already fully defined. Using it for annotations, however, would access it too early, so we need to combine this with the TYPE_CHECKING method; I can't say the result looks very elegant either.

What's worse, as our project gets big, we might have subpackages; and if we want to expose their internals by importing them in the subpackage __init__.py, any circularity between them becomes untenable – so I'd like to show you one more trick; not exactly about source code manipulation per se, but a program that reads its own source code to recalibrate itself – given our chapter's subject and progression, I thought it might fit.

Lazy Imports

Imagine we could just do import <package> everywhere, and accessing any name we wish to expose would locate and evaluate it on demand. This not only solves circularity problems, but makes importing blazing fast, since various parts of our code are evaluated only when they're needed. We'd still need TYPE_CHECKING for annotations, so as not to resolve their classes until they're actually required, but the plan is to have everything centralized in a single, automatically-managed point:

from typing import TYPE_CHECKING
import fileutils
if TYPE_CHECKING:
    from fileutils import Directory
    
class File:
    @property
    def directory(self) -> Directory:
        return fileutils.Directory(…)

Doesn't look too different from the previous version, but if we have multiple imports, or they're located in inter-dependent subpackages, trust me – this is much simpler. Plus, we no longer need to choose between relative and absolute styles, or care about where each part is located, since we only ever use the root package.

That is, except in the root __init__.py, where we manage all the names that need to be exposed – either as part of our public API, or because we need them elsewhere in our package. It might feel wrong to mix the two, effectively exporting internals – but in my experience, especially when typing is used, accessing every corner of the package becomes necessary, and it's quite annoying to go fishing for definitions in obscure modules, as is ending up with a long list of imports from all over the place. Here's a snippet of my imports when working with the OpenAI library:

from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as StreamChoice
from openai.types.chat.chat_completion_content_part_image_param import ImageURL
from openai.types.chat.chat_completion_content_part_param import File
from openai.types.chat.chat_completion_message_tool_call_param import Function

Yeah, not very fun.

Anyway, how do write our __init__.py? Since we're going to expose its attributes dynamically, it will initially have no attributes at all, which means that if we want to support things like typing and autocompletion, we'd have to bring everything forth in the TYPE_CHECKING clause:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .file import File
    from .directory import Directory

Once we do that, however, we can reuse these definitions – presumably, there'd be a lot, not just the two of our toy example. When we attempt to load e.g. File, our lazy loader will parse this __init__.py to match the names to their sources, then import the relevant source and extract the name, caching the result for good measure (I'd also cache the imported module, if Python's import system wouldn't do that for us anyway). Let's start, then, by defining an importer class in the same __init__.py:

# … same as before

class __Importer:

    def __init__(self):
        self.sources = {}
        self.objects = {}
        self.re = __import__("re")
        self.importlib = __import__("importlib")

And notice I'm trying to avoid polluting the package namespace, hence the __ prefix and the internal imports of a couple libraries we'll need in a moment. To load the sources, as discussed, we do:

class __Importer:
    # … same as before
    def load_sources(self):
        code = open(__file__).read()
        for source, names in self.re.findall(r"from (.*?) import (.*)", code):
            if source.startswith("."):
                for name in names.split(","):
                    self.sources[name.strip()] = source

That is, it reads and parses its own source code, extracting relative import lines to end up with File mapped to .file and Directory to .directory. Then:

class __Importer:
    # … same as before
    def __call__(self, name):
        if not self.sources:
            self.load_sources()
        if name not in self.objects:
            if name not in self.sources:
                raise AttributeError(f"module {__name__!r} has no "
                                    f"attribute {name!r}")
            source = self.sources[name]
            module = self.importlib.import_module(source, __package__)
            self.objects[name] = getattr(module, name)
        return self.objects[name]

Which, as promised, loads and extracts the relevant file. Here, I'm taking advantage of the fact that importlib.import_module knows how to resolve relative locations (like .file) given a package; and since we're operating from within the __init__.py of said package, __package__ it is. Last but not least – all of this happens in __call__, which means our importer object is callable, and all that's left is to bind as our package's name resolver:

# … same as before
__getattr__ = __Importer()

Just like the __getattr__ method is invoked to dynamically resolve attributes, so is the global __getattr__ of a module called on names that aren't explicitly defined in its scope. The result:

>>> import fileutils
>>> fileutils.Directory
<class 'fileutils.directory.Directory'>
>>> fileutils.File
<class 'fileutils.file.File'>

All in all, not bad for some 20 lines – and never having to worry about imports again. Like I said, we don't exactly manipulate source code in this example, but we do parse it to determine which name comes whence; the lesson I'm trying to impart is not so much this or that method, but the fact that code is, ultimately, a string – so sometimes, thinking of it as such comes in handy.