Skip to content

Commit 01d0098

Browse files
authored
Merge pull request #227 from Peter554/closed-layers
Closed layers
2 parents 0abffb2 + 5c6a9cf commit 01d0098

10 files changed

Lines changed: 335 additions & 14 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
Changelog
33
=========
44

5+
Unreleased
6+
----------
7+
8+
* Add closed layers to layer contract.
9+
510
3.9 (2025-05-05)
611
----------------
712

docs/usage.rst

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ Higher level analysis
304304
passing ``independent=False`` when instantiating the :class:`.Layer`. For convenience, if a layer consists
305305
only of one module name then a string may be passed in place of the :class:`.Layer` object. Additionally, if
306306
the layer consists of multiple *independent* modules, that can be passed as a set of strings instead of a
307-
:class:`.Layer` object.
307+
:class:`.Layer` object. A closed layer may be created by passing ``closed=True`` to prevent higher layers
308+
from importing directly from layers below the closed layer (see `Closed layers`_ section below).
308309
*Any modules specified that don't exist in the graph will be silently ignored.*
309310
:param set[str] containers: The parent modules of the layers, as absolute names that you could
310311
import, such as ``mypackage.foo``. (Optional.)
@@ -409,6 +410,18 @@ Higher level analysis
409410
),
410411
)
411412

413+
Closed layers
414+
^^^^^^^^^^^^^
415+
416+
A closed layer may be created by passing ``closed=True``. Closed layers provide an additional
417+
constraint in your architecture that prevents higher layers from "reaching through" to access
418+
lower layers directly. Imports from higher to lower layers cannot bypass closed layers - the
419+
closed layer must be included in the import chain.
420+
421+
This is particularly useful for enforcing architectural boundaries where you want to hide
422+
implementation details of lower layers and ensure that higher layers only interact with
423+
the public interface provided by the closed layer.
424+
412425
Return value
413426
^^^^^^^^^^^^
414427

@@ -575,4 +588,4 @@ Module expressions
575588
- ``mypackage.foo*``: is not a valid expression. (The wildcard must replace a whole module name.)
576589

577590
.. _namespace packages: https://docs.python.org/3/glossary.html#term-namespace-package
578-
.. _namespace portion: https://docs.python.org/3/glossary.html#term-portion
591+
.. _namespace portion: https://docs.python.org/3/glossary.html#term-portion

rust/src/graph/higher_order_queries.rs

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ pub struct Level {
1616

1717
#[getset(get_copy = "pub")]
1818
independent: bool,
19+
20+
#[getset(get_copy = "pub")]
21+
closed: bool,
1922
}
2023

2124
#[derive(Debug, Clone, PartialEq, Eq, new, Getters)]
@@ -57,7 +60,7 @@ impl Graph {
5760
.flat_map(|m| m.conv::<FxHashSet<_>>().with_descendants(self))
5861
.collect::<FxHashSet<_>>();
5962

60-
self.generate_module_permutations(levels)
63+
self.generate_illegal_import_permutations_for_layers(levels)
6164
.into_par_iter()
6265
.try_fold(
6366
Vec::new,
@@ -81,15 +84,20 @@ impl Graph {
8184
)
8285
}
8386

84-
fn generate_module_permutations(&self, levels: &[Level]) -> Vec<(ModuleToken, ModuleToken)> {
85-
let mut permutations = vec![];
87+
/// Returns a set of tuples (importer, imported) describing the illegal
88+
/// import permutations for the given layers.
89+
fn generate_illegal_import_permutations_for_layers(
90+
&self,
91+
levels: &[Level],
92+
) -> FxHashSet<(ModuleToken, ModuleToken)> {
93+
let mut permutations = FxHashSet::default();
8694

8795
for (index, level) in levels.iter().enumerate() {
8896
for module in &level.layers {
8997
// Should not be imported by lower layers.
9098
for lower_level in &levels[index + 1..] {
9199
for lower_module in &lower_level.layers {
92-
permutations.push((*lower_module, *module));
100+
permutations.insert((*lower_module, *module));
93101
}
94102
}
95103

@@ -99,8 +107,19 @@ impl Graph {
99107
if sibling_module == module {
100108
continue;
101109
}
102-
permutations.push((*module, *sibling_module));
110+
permutations.insert((*module, *sibling_module));
111+
}
112+
}
113+
114+
// Should not be imported by higher layers if there is a closed layer inbetween.
115+
let mut closed = false;
116+
for higher_level in levels[..index].iter().rev() {
117+
if closed {
118+
for higher_module in &higher_level.layers {
119+
permutations.insert((*higher_module, *module));
120+
}
103121
}
122+
closed |= higher_level.closed;
104123
}
105124
}
106125
}
@@ -208,3 +227,106 @@ impl Graph {
208227
)
209228
}
210229
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::*;
234+
use crate::graph::Graph;
235+
use rustc_hash::FxHashSet;
236+
237+
#[test]
238+
fn test_generate_module_permutations_simple_layers() {
239+
let mut graph = Graph::default();
240+
241+
let top_module = graph.get_or_add_module("app.top").token;
242+
let middle_module = graph.get_or_add_module("app.middle").token;
243+
let bottom_module = graph.get_or_add_module("app.bottom").token;
244+
245+
let mut top_layer = FxHashSet::default();
246+
top_layer.insert(top_module);
247+
248+
let mut middle_layer = FxHashSet::default();
249+
middle_layer.insert(middle_module);
250+
251+
let mut bottom_layer = FxHashSet::default();
252+
bottom_layer.insert(bottom_module);
253+
254+
let top_level = Level::new(top_layer, false, false);
255+
let middle_level = Level::new(middle_layer, false, false);
256+
let bottom_level = Level::new(bottom_layer, false, false);
257+
258+
let levels = vec![top_level, middle_level, bottom_level];
259+
260+
let permutations = graph.generate_illegal_import_permutations_for_layers(&levels);
261+
262+
assert_eq!(
263+
permutations,
264+
FxHashSet::from_iter([
265+
(bottom_module, middle_module),
266+
(bottom_module, top_module),
267+
(middle_module, top_module),
268+
])
269+
);
270+
}
271+
272+
#[test]
273+
fn test_generate_module_permutations_independent_layer() {
274+
let mut graph = Graph::default();
275+
276+
let module_a = graph.get_or_add_module("app.independent.a").token;
277+
let module_b = graph.get_or_add_module("app.independent.b").token;
278+
279+
let mut independent_layer = FxHashSet::default();
280+
independent_layer.insert(module_a);
281+
independent_layer.insert(module_b);
282+
283+
let independent_level = Level::new(independent_layer, true, false);
284+
285+
let levels = vec![independent_level];
286+
287+
let permutations = graph.generate_illegal_import_permutations_for_layers(&levels);
288+
289+
assert_eq!(
290+
permutations,
291+
FxHashSet::from_iter([(module_a, module_b), (module_b, module_a),])
292+
);
293+
}
294+
295+
#[test]
296+
fn test_generate_module_permutations_closed_layer() {
297+
let mut graph = Graph::default();
298+
299+
// Create three layers with the middle one closed
300+
let top_module = graph.get_or_add_module("app.top").token;
301+
let middle_module = graph.get_or_add_module("app.middle").token;
302+
let bottom_module = graph.get_or_add_module("app.bottom").token;
303+
304+
let mut top_layer = FxHashSet::default();
305+
top_layer.insert(top_module);
306+
307+
let mut middle_layer = FxHashSet::default();
308+
middle_layer.insert(middle_module);
309+
310+
let mut bottom_layer = FxHashSet::default();
311+
bottom_layer.insert(bottom_module);
312+
313+
let top_level = Level::new(top_layer, false, false);
314+
let middle_level = Level::new(middle_layer, false, true); // Closed layer
315+
let bottom_level = Level::new(bottom_layer, false, false);
316+
317+
let levels = vec![top_level, middle_level, bottom_level];
318+
319+
let permutations = graph.generate_illegal_import_permutations_for_layers(&levels);
320+
321+
assert_eq!(
322+
permutations,
323+
FxHashSet::from_iter([
324+
(bottom_module, middle_module),
325+
(bottom_module, top_module),
326+
(middle_module, top_module),
327+
// Top should not import Bottom due to closed middle layer
328+
(top_module, bottom_module),
329+
])
330+
);
331+
}
332+
}

rust/src/lib.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,14 @@ impl GraphWrapper {
569569
.extract::<bool>()
570570
.unwrap();
571571

572-
levels.push(Level::new(layers, independent));
572+
let closed = level_dict
573+
.get_item("closed")
574+
.unwrap()
575+
.unwrap()
576+
.extract::<bool>()
577+
.unwrap();
578+
579+
levels.push(Level::new(layers, independent, closed));
573580
}
574581
levels_by_container.push(levels);
575582
}

rust/tests/large.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ fn test_large_graph_deep_layers() {
4242
.token()
4343
.conv::<FxHashSet<_>>(),
4444
true,
45+
false,
4546
)
4647
})
4748
.collect();

src/grimp/adaptors/graph.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,11 @@ def find_illegal_dependencies_for_layers(
161161
try:
162162
result = self._rustgraph.find_illegal_dependencies_for_layers(
163163
layers=tuple(
164-
{"layers": layer.module_tails, "independent": layer.independent}
164+
{
165+
"layers": layer.module_tails,
166+
"independent": layer.independent,
167+
"closed": layer.closed,
168+
}
165169
for layer in layers
166170
),
167171
containers=set(containers) if containers else set(),
@@ -201,9 +205,9 @@ def _parse_layers(layers: Sequence[Layer | str | set[str]]) -> tuple[Layer, ...]
201205
if isinstance(layer, Layer):
202206
out_layers.append(layer)
203207
elif isinstance(layer, str):
204-
out_layers.append(Layer(layer, independent=True))
208+
out_layers.append(Layer(layer))
205209
else:
206-
out_layers.append(Layer(*tuple(layer), independent=True))
210+
out_layers.append(Layer(*tuple(layer)))
207211
return tuple(out_layers)
208212

209213

src/grimp/application/ports/graph.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,11 @@ def find_illegal_dependencies_for_layers(
373373
compatibility it is also possible to pass a simple `set[str]` to describe a layer. In this
374374
case the sibling modules within the layer will be considered independent.
375375
376+
By default layers are open. `Layer.closed` can be set to True to create a closed layer.
377+
Imports from higher to lower layers cannot bypass closed layers - the closed layer must be
378+
included in the import chain. For example, given the layers high -> mid (closed) -> low then
379+
all import chains from high -> low must go via mid.
380+
376381
Arguments:
377382
378383
- layers: A sequence, each element of which consists either of a `Layer`, the name

src/grimp/domain/valueobjects.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,15 @@ class Layer:
6969

7070
module_tails: Set[str]
7171
independent: bool
72+
closed: bool
7273

7374
# A custom `__init__` is needed since `module_tails` is a variadic argument.
74-
def __init__(self, *module_tails: str, independent: bool = True) -> None:
75+
def __init__(self, *module_tails: str, independent: bool = True, closed: bool = False) -> None:
7576
# `object.__setattr__` is needed since the dataclass is frozen.
7677
object.__setattr__(self, "module_tails", set(module_tails))
7778
object.__setattr__(self, "independent", independent)
79+
object.__setattr__(self, "closed", closed)
7880

7981
def __str__(self) -> str:
80-
return f"{self.module_tails}, independent={self.independent}"
82+
module_tails = sorted(self.module_tails)
83+
return f"{module_tails}, independent={self.independent}, closed={self.closed}"

0 commit comments

Comments
 (0)