Merge lp:~allenap/maas/lazy-module-loading into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Rejected
Rejected by: MAAS Lander
Proposed branch: lp:~allenap/maas/lazy-module-loading
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 377 lines (+292/-13)
4 files modified
src/provisioningserver/utils/__init__.py (+8/-9)
src/provisioningserver/utils/lazy.py (+137/-0)
src/provisioningserver/utils/tests/test_lazy.py (+145/-0)
src/provisioningserver/utils/tests/test_utils.py (+2/-4)
To merge this branch: bzr merge lp:~allenap/maas/lazy-module-loading
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Needs Information
Review via email: mp+274405@code.launchpad.net

Commit message

A lazy module loader to take away those circular import blues.

Description of the change

This has been sitting around since June and I finally got around to finishing it off when travelling home from Seattle. This contains the module importer and an example or two of its use. There's a follow-up branch that replaces most/all of the remaining circular imports with it.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

I think this is a novel way to solve the problem, and I think it could be a step in the right direction.

However, I'm not sure we should land it this late in 1.9. It seems risky to introduce the extra logic now. It seems you are creating "proxy" objects, and I don't fully understand how all of the proxying logic works. For example, do the objects returned by the lazy importer obey "normal" python conventions, such as returning the appropriate type when isinstance() is called?

It seems that another way to solve the circular import problem would be to import modules rather than functions at the top level, so that the specific dependencies we need aren't resolved until the correct time. But that would lead to code like:

from maasserver.models import fabric

...

def foo():
    f = fabric.Fabric()
    # or...
    Fabric = fabric.Fabric
    f = Fabric()

... which seems less than ideal. (but perhaps no worse than the extra 'import' line we have now, or writing a few hundred lines of lazy-importing code!)

review: Needs Information
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Just thought of another thing - this may throw off code introspection tools (such as IPython, PyCharm, etc), which gives me pause...

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.5 KiB)

> I think this is a novel way to solve the problem, and I think it could
> be a step in the right direction.
>
> However, I'm not sure we should land it this late in 1.9. It seems
> risky to introduce the extra logic now. It seems you are creating
> "proxy" objects, and I don't fully understand how all of the proxying
> logic works. For example, do the objects returned by the lazy importer
> obey "normal" python conventions, such as returning the appropriate
> type when isinstance() is called?

https://docs.python.org/2/reference/datamodel.html#customizing-instance-and-subclass-checks
says that isinstance() and issubclass() are implemented by calling
__instancecheck__() and __subclasscheck__() on the type being checked
against, i.e.:

    isintance(thing, TypeA) --> TypeA.__instancecheck__(thing)
    issubclass(TypeA, TypeB) --> TypeB.__subclasscheck__(TypeA)

The Stub instances that this branch deposits override __getattribute__()
to forward attribute look-ups to the lazily-loaded object. This method
always called, even when accessing preexisting attributes. The similar
__getattr__() method is only called for attributes that don't exist.

The Stub instances also override __setattr__(), __delattr__(), and
__call__() but it's possible these are not actually required. I will try
removing them. I will also add isinstance() and issubclass() checks.

It's possible that the one thing that these Stub instances won't fool is
an `is` check, e.g. `stub is Something`, because `is` is not implemented
as a call to a __double_underscore__() method.

>
> It seems that another way to solve the circular import problem would
> be to import modules rather than functions at the top level, so that
> the specific dependencies we need aren't resolved until the correct
> time. But that would lead to code like:

This is a good point. It works up to a point. Subclassing, for example,
demands that we dereference names within the module. However, the code
in this branch can't help that situation either.

We may be able to solve all circular reference problems by only ever
importing modules. We might need to avoid code in packages' __init__.py
too.

This has a few downsides:

* Lint checks will catch fewer naming issues; none currently check that
  a name exists in the target module.

* Patching in tests, while not an ideal practice, would have to be done
  in the owning module rather than in the module under test. This can be
  problematic (patching too much) but nothing we can't work around.

* Readability. It helps to work with short names and we'd lose that.

* A small performance penalty, though nothing we ought to care about.

None of those are particularly bad. Your perspective has made me realise
that lazy-importing is (only?) useful for:

(a) Using short names for convenience and readability.

(b) Deferring work to increase start-up performance.

Now, (b) is irrelevant to us, and (a) might not be a big enough win to
justify this branch's code.

>
> from maasserver.models import fabric
>
> ...
>
> def foo():
> f = fabric.Fabric()
> # or...
> Fabric = fabric.Fabric
> f = Fabric()
>
> ... which seems less than ideal. (but perhaps no worse than th...

Read more...

Revision history for this message
MAAS Lander (maas-lander) wrote :

Transitioned to Git.

lp:maas has now moved from Bzr to Git.
Please propose your branches with Launchpad using Git.

git clone https://git.launchpad.net/maas

Unmerged revisions

4018. By Gavin Panella

Merge trunk, resolving minor conflicts.

4017. By Gavin Panella

Update tests and importer()'s docstring.

4016. By Gavin Panella

Actually add the tests.

4015. By Gavin Panella

Extract much of update_references() into gen_reference_updates().

4014. By Gavin Panella

Remove talk of changing frames; local variables can be read but not written.

4013. By Gavin Panella

Tests for lazy.find() and lazy.update_references().

4012. By Gavin Panella

Move the lazy module importer to a module of its own.

4011. By Gavin Panella

lazy() helper for circular imports and deferred loading, work in progress.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/provisioningserver/utils/__init__.py'
--- src/provisioningserver/utils/__init__.py 2015-06-29 13:42:10 +0000
+++ src/provisioningserver/utils/__init__.py 2015-10-14 14:00:38 +0000
@@ -48,6 +48,7 @@
48 NoConnectionsAvailable,48 NoConnectionsAvailable,
49 NodeAlreadyExists,49 NodeAlreadyExists,
50)50)
51from provisioningserver.utils import lazy
51from provisioningserver.utils.twisted import (52from provisioningserver.utils.twisted import (
52 asynchronous,53 asynchronous,
53 pause,54 pause,
@@ -66,6 +67,11 @@
66)67)
6768
6869
70region = lazy.importer("provisioningserver.rpc.region")
71ClusterConfiguration = lazy.importer(
72 "provisioningserver.config.ClusterConfiguration")
73
74
69maaslog = get_maas_logger("utils")75maaslog = get_maas_logger("utils")
7076
7177
@@ -113,10 +119,6 @@
113 :param power_parameters: The power parameters for the node, as a119 :param power_parameters: The power parameters for the node, as a
114 dict.120 dict.
115 """121 """
116 # Avoid circular dependencies.
117 from provisioningserver.rpc.region import CreateNode
118 from provisioningserver.config import ClusterConfiguration
119
120 with ClusterConfiguration.open() as config:122 with ClusterConfiguration.open() as config:
121 cluster_uuid = config.cluster_uuid123 cluster_uuid = config.cluster_uuid
122124
@@ -139,7 +141,7 @@
139 macs = sorted(set(macs))141 macs = sorted(set(macs))
140 try:142 try:
141 response = yield client(143 response = yield client(
142 CreateNode,144 region.CreateNode,
143 cluster_uuid=cluster_uuid,145 cluster_uuid=cluster_uuid,
144 architecture=arch,146 architecture=arch,
145 power_type=power_type,147 power_type=power_type,
@@ -181,9 +183,6 @@
181 :param system_id: system_id of node to commission.183 :param system_id: system_id of node to commission.
182 :param user: user for the node.184 :param user: user for the node.
183 """185 """
184 # Avoid circular dependencies.
185 from provisioningserver.rpc.region import CommissionNode
186
187 for elapsed, remaining, wait in retries(15, 5, reactor):186 for elapsed, remaining, wait in retries(15, 5, reactor):
188 try:187 try:
189 client = getRegionClient()188 client = getRegionClient()
@@ -197,7 +196,7 @@
197196
198 try:197 try:
199 yield client(198 yield client(
200 CommissionNode,199 region.CommissionNode,
201 system_id=system_id,200 system_id=system_id,
202 user=user)201 user=user)
203 except CommissionNodeFailed as e:202 except CommissionNodeFailed as e:
204203
=== added file 'src/provisioningserver/utils/lazy.py'
--- src/provisioningserver/utils/lazy.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/utils/lazy.py 2015-10-14 14:00:38 +0000
@@ -0,0 +1,137 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Doing things lazily, like importing modules."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = [
16 "importer",
17]
18
19from collections import (
20 MutableMapping,
21 MutableSequence,
22 MutableSet,
23)
24import ctypes
25from functools import partial
26from gc import get_referrers
27from importlib import import_module
28from operator import setitem
29from threading import RLock
30
31# A Python `cell`, used internally to implement variables referenced by
32# multiple scopes. See https://docs.python.org/2/c-api/cell.html.
33PyCell = type((lambda o: lambda: o)(0).func_closure[0])
34
35# Mechanism to delve into Python's brain to *set* the contents of cells.
36PyCell_Set = ctypes.pythonapi.PyCell_Set
37
38
39def find(name):
40 """Find the given `name` as a module or object within."""
41 try:
42 return import_module(name)
43 except ImportError:
44 name, _, last = name.rpartition(".")
45 if len(name) > 0:
46 try:
47 return getattr(find(name), last)
48 except AttributeError:
49 raise ImportError(
50 "Cannot import %s from %s" % (last, name))
51 else:
52 raise
53
54
55def gen_reference_updates(placeholder, actual):
56 """Generate the updates needed to replace `placeholder` with `actual`.
57
58 Currently this can only replaces references within mutable mappings,
59 lists, and sets, and within cells. This returns no-argument callables;
60 these should be called only *after* exhausting this generator.
61 """
62 for referrer in get_referrers(placeholder):
63 if isinstance(referrer, MutableMapping):
64 for key, value in referrer.viewitems():
65 if value is placeholder:
66 yield partial(setitem, referrer, key, actual)
67 elif isinstance(referrer, MutableSequence):
68 for index, value in enumerate(referrer):
69 if value is placeholder:
70 yield partial(setitem, referrer, index, actual)
71 elif isinstance(referrer, MutableSet):
72 yield partial(referrer.discard, placeholder)
73 yield partial(referrer.add, actual)
74 elif isinstance(referrer, PyCell):
75 # This was an attempt to get updates in dicts working, but it does
76 # not appear to have any effect in that regard.
77 yield partial(
78 PyCell_Set, ctypes.py_object(referrer),
79 ctypes.py_object(actual))
80 else:
81 # Don't yet know how to handle this. For example, this might be a
82 # frame (it's not possible to update locals without major surgery)
83 # or an immutable container.
84 pass
85
86
87def update_references(placeholder, actual):
88 """Update references to `placeholder` with `actual`.
89
90 See `gen_reference_updates` for limitations.
91 """
92 updates = gen_reference_updates(placeholder, actual)
93 for update in list(updates):
94 update()
95
96
97def importer(name):
98 """Import the given module lazily.
99
100 This returns a stub object that proxies attribute access and calls through
101 to the lazily imported object. On first attribute access, or on the first
102 call, the stub will be replaced in modules, dicts, lists, and sets
103 throughout the running interpreter.
104
105 There are some limitations. It can't replace references in local
106 variables. It can't replace references to objects without a ``__dict__``
107 attribute when that reference is within a dict.
108
109 :param name: The dotted path to a module or object within that module,
110 e.g. "provisioningserver.config.ClusterConfiguration".
111 """
112
113 def load(store=[], lock=RLock()):
114 with lock:
115 if len(store) == 0:
116 thing = find(name)
117 update_references(stub, thing)
118 store.append(thing)
119 return store[0]
120
121 class Stub:
122
123 def __getattribute__(self, name):
124 return getattr(load(), name)
125
126 def __setattr__(self, name, value):
127 return setattr(load(), name, value)
128
129 def __delattr__(self, name):
130 return delattr(load(), name)
131
132 def __call__(self, *args, **kwargs):
133 return load()(*args, **kwargs)
134
135 stub = Stub()
136
137 return stub
0138
=== added file 'src/provisioningserver/utils/tests/test_lazy.py'
--- src/provisioningserver/utils/tests/test_lazy.py 1970-01-01 00:00:00 +0000
+++ src/provisioningserver/utils/tests/test_lazy.py 2015-10-14 14:00:38 +0000
@@ -0,0 +1,145 @@
1# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `provisioningserver.utils.lazy`."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12str = None
13
14__metaclass__ = type
15__all__ = []
16
17import sys
18
19from maastesting.factory import factory
20from maastesting.testcase import MAASTestCase
21from provisioningserver.utils import lazy
22from testtools.matchers import Contains
23
24
25class TestFind(MAASTestCase):
26 """Tests for `provisioningserver.utils.lazy.find`."""
27
28 def test_finds_builtin_module(self):
29 self.assertIs(sys, lazy.find("sys"))
30
31 def test_finds_module_on_sys_path(self):
32 self.assertIs(lazy, lazy.find("provisioningserver.utils.lazy"))
33
34 def test_finds_attribute_in_builtin(self):
35 self.assertIs(sys.exit, lazy.find("sys.exit"))
36
37 def test_finds_attribute_in_module_on_sys_path(self):
38 self.assertIs(lazy.find, lazy.find(
39 "provisioningserver.utils.lazy.find"))
40
41 def test_raises_ImportError_when_module_cannot_be_found(self):
42 name = factory.make_name()
43 error = self.assertRaises(ImportError, lazy.find, name)
44 self.assertEqual("No module named " + name, unicode(error))
45
46 def test_raises_ImportError_when_attribute_cannot_be_found(self):
47 name = factory.make_name("attr")
48 error = self.assertRaises(ImportError, lazy.find, "sys." + name)
49 self.assertEqual("Cannot import %s from sys" % name, unicode(error))
50
51
52class TestUpdateReferences(MAASTestCase):
53 """Tests for `provisioningserver.utils.lazy.update_references`."""
54
55 def test_does_not_update_locals(self):
56 ref = object()
57 lazy.update_references(ref, sys)
58 self.assertIsNot(ref, sys)
59
60 def test_updates_reference_in_module(self):
61 global ref_in_module
62 ref_in_module = object()
63 self.assertIsNot(sys, ref_in_module)
64 lazy.update_references(ref_in_module, sys)
65 self.assertIs(sys, ref_in_module)
66
67 def test_updates_reference_in_dict_value(self):
68 thing = type(b"Thing", (object,), {})()
69 ref_in_dict = {None: thing}
70 self.assertIsNot(sys, ref_in_dict[None])
71 lazy.update_references(thing, sys)
72 self.assertIs(sys, ref_in_dict[None])
73
74 def test_does_not_update_reference_in_dict_key(self):
75 thing = type(b"Thing", (object,), {})()
76 ref_in_dict = {thing: None}
77 self.assertThat(ref_in_dict, Contains(thing))
78 lazy.update_references(thing, sys)
79 self.assertThat(ref_in_dict, Contains(thing))
80
81 def test_does_not_update_reference_to_dictless_object_in_dict_value(self):
82 thing = type(b"Thing", (object,), {b"__slots__": ()})()
83 ref_in_dict = {None: thing}
84 self.assertIsNot(sys, ref_in_dict[None])
85 lazy.update_references(ref_in_dict[None], sys)
86 self.assertIsNot(sys, ref_in_dict[None])
87
88 def test_does_not_update_reference_to_dictless_object_in_dict_key(self):
89 thing = type(b"Thing", (object,), {b"__slots__": ()})()
90 ref_in_dict = {thing: None}
91 self.assertThat(ref_in_dict, Contains(thing))
92 lazy.update_references(thing, sys)
93 self.assertThat(ref_in_dict, Contains(thing))
94
95 def test_updates_reference_in_list(self):
96 ref_in_list = [object()]
97 self.assertIsNot(sys, ref_in_list[0])
98 lazy.update_references(ref_in_list[0], sys)
99 self.assertIs(sys, ref_in_list[0])
100
101 def test_updates_reference_in_set(self):
102 ref_in_set = {object()}
103 self.assertIsNot(sys, list(ref_in_set)[0])
104 lazy.update_references(list(ref_in_set)[0], sys)
105 self.assertIs(sys, list(ref_in_set)[0])
106
107
108class TestImporter(MAASTestCase):
109 """Tests for `provisioningserver.utils.lazy.importer`."""
110
111 def test_returns_stub(self):
112 stub = lazy.importer("sys")
113 self.assertIsNot(stub, sys)
114 self.assertEqual("Stub", type(stub).__name__)
115
116 def test_does_not_update_import_in_locals(self):
117 stub_in_locals = lazy.importer("sys")
118 self.assertIsNot(sys, stub_in_locals)
119 stub_in_locals.exit # Look-up an attribute.
120 self.assertIsNot(sys, stub_in_locals)
121
122 def test_updates_import_in_module(self):
123 global stub_in_module
124 stub_in_module = lazy.importer("sys")
125 self.assertIsNot(sys, stub_in_module)
126 stub_in_module.exit # Look-up an attribute.
127 self.assertIs(sys, stub_in_module)
128
129 def test_updates_import_in_dict(self):
130 stub_in_dict = {None: lazy.importer("sys")}
131 self.assertIsNot(sys, stub_in_dict[None])
132 stub_in_dict[None].exit # Look-up an attribute.
133 self.assertIs(sys, stub_in_dict[None])
134
135 def test_updates_import_in_list(self):
136 stub_in_list = [lazy.importer("sys")]
137 self.assertIsNot(sys, stub_in_list[0])
138 stub_in_list[0].exit # Look-up an attribute.
139 self.assertIs(sys, stub_in_list[0])
140
141 def test_updates_import_in_set(self):
142 stub_in_set = {lazy.importer("sys")}
143 self.assertIsNot(sys, list(stub_in_set)[0])
144 list(stub_in_set)[0].exit # Look-up an attribute.
145 self.assertIs(sys, list(stub_in_set)[0])
0146
=== modified file 'src/provisioningserver/utils/tests/test_utils.py'
--- src/provisioningserver/utils/tests/test_utils.py 2015-06-29 13:42:10 +0000
+++ src/provisioningserver/utils/tests/test_utils.py 2015-10-14 14:00:38 +0000
@@ -487,9 +487,8 @@
487 system_id = factory.make_name("system-id")487 system_id = factory.make_name("system-id")
488 protocol.CreateNode.return_value = defer.succeed(488 protocol.CreateNode.return_value = defer.succeed(
489 {"system_id": system_id})489 {"system_id": system_id})
490 get_cluster_uuid = self.patch(490 test_uuid = factory.make_UUID()
491 provisioningserver.utils, 'get_cluster_uuid')491 self.useFixture(ClusterConfigurationFixture(cluster_uuid=test_uuid))
492 get_cluster_uuid.return_value = 'cluster-' + factory.make_UUID()
493492
494 uuid = 'node-' + factory.make_UUID()493 uuid = 'node-' + factory.make_UUID()
495 macs = sorted(factory.make_mac_address() for _ in range(3))494 macs = sorted(factory.make_mac_address() for _ in range(3))
@@ -513,7 +512,6 @@
513 system_id = factory.make_name("system-id")512 system_id = factory.make_name("system-id")
514 protocol.CreateNode.return_value = defer.succeed(513 protocol.CreateNode.return_value = defer.succeed(
515 {"system_id": system_id})514 {"system_id": system_id})
516
517 test_uuid = factory.make_UUID()515 test_uuid = factory.make_UUID()
518 self.useFixture(ClusterConfigurationFixture(cluster_uuid=test_uuid))516 self.useFixture(ClusterConfigurationFixture(cluster_uuid=test_uuid))
519517