plain.postgres 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from plain.postgres.db import DatabaseError
6
+
7
+
8
+ class AmbiguityError(Exception):
9
+ """More than one migration matches a name prefix."""
10
+
11
+ pass
12
+
13
+
14
+ class BadMigrationError(Exception):
15
+ """There's a bad migration (unreadable/bad format/etc.)."""
16
+
17
+ pass
18
+
19
+
20
+ class CircularDependencyError(Exception):
21
+ """There's an impossible-to-resolve circular dependency."""
22
+
23
+ pass
24
+
25
+
26
+ class InconsistentMigrationHistory(Exception):
27
+ """An applied migration has some of its dependencies not applied."""
28
+
29
+ pass
30
+
31
+
32
+ class InvalidBasesError(ValueError):
33
+ """A model's base classes can't be resolved."""
34
+
35
+ pass
36
+
37
+
38
+ class NodeNotFoundError(LookupError):
39
+ """An attempt on a node is made that is not available in the graph."""
40
+
41
+ def __init__(self, message: str, node: Any, origin: Any = None) -> None:
42
+ self.message = message
43
+ self.origin = origin
44
+ self.node = node
45
+
46
+ def __str__(self) -> str:
47
+ return self.message
48
+
49
+ def __repr__(self) -> str:
50
+ return f"NodeNotFoundError({self.node!r})"
51
+
52
+
53
+ class MigrationSchemaMissing(DatabaseError):
54
+ pass
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from contextlib import nullcontext
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from ..transaction import atomic
8
+ from .loader import MigrationLoader
9
+ from .migration import Migration
10
+ from .recorder import MigrationRecorder
11
+ from .state import ProjectState
12
+
13
+ if TYPE_CHECKING:
14
+ from plain.postgres.connection import DatabaseConnection
15
+
16
+
17
+ class MigrationExecutor:
18
+ """
19
+ End-to-end migration execution - load migrations and run them up or down
20
+ to a specified set of targets.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ connection: DatabaseConnection,
26
+ progress_callback: Callable[..., Any] | None = None,
27
+ ) -> None:
28
+ self.connection = connection
29
+ self.loader = MigrationLoader(self.connection)
30
+ self.recorder = MigrationRecorder(self.connection)
31
+ self.progress_callback = progress_callback
32
+
33
+ def migration_plan(
34
+ self, targets: list[tuple[str, str]], clean_start: bool = False
35
+ ) -> list[Migration]:
36
+ """
37
+ Given a set of targets, return a list of Migration instances.
38
+ """
39
+ plan = []
40
+ if clean_start:
41
+ applied = {}
42
+ else:
43
+ applied_source = self.loader.applied_migrations or {}
44
+ applied = dict(applied_source)
45
+ for target in targets:
46
+ for migration in self.loader.graph.forwards_plan(target):
47
+ if migration not in applied:
48
+ plan.append(self.loader.graph.nodes[migration])
49
+ applied[migration] = self.loader.graph.nodes[migration]
50
+ return plan
51
+
52
+ def _create_project_state(
53
+ self, with_applied_migrations: bool = False
54
+ ) -> ProjectState:
55
+ """
56
+ Create a project state including all the applications without
57
+ migrations and applied migrations if with_applied_migrations=True.
58
+ """
59
+ state = ProjectState(real_packages=self.loader.unmigrated_packages)
60
+ if with_applied_migrations:
61
+ # Create the forwards plan Plain would follow on an empty database
62
+ full_plan = self.migration_plan(
63
+ self.loader.graph.leaf_nodes(), clean_start=True
64
+ )
65
+ applied_source = self.loader.applied_migrations or {}
66
+ applied_migrations = {
67
+ self.loader.graph.nodes[key]
68
+ for key in applied_source
69
+ if key in self.loader.graph.nodes
70
+ }
71
+ for migration in full_plan:
72
+ if migration in applied_migrations:
73
+ migration.mutate_state(state, preserve=False)
74
+ return state
75
+
76
+ def migrate(
77
+ self,
78
+ targets: list[tuple[str, str]],
79
+ plan: list[Migration] | None = None,
80
+ state: ProjectState | None = None,
81
+ fake: bool = False,
82
+ atomic_batch: bool = False,
83
+ ) -> ProjectState:
84
+ """
85
+ Migrate the database up to the given targets.
86
+
87
+ Plain first needs to create all project states before a migration is
88
+ (un)applied and in a second step run all the database operations.
89
+
90
+ atomic_batch: Whether to run all migrations in a single transaction.
91
+ """
92
+ # The plain_migrations table must be present to record applied
93
+ # migrations, but don't create it if there are no migrations to apply.
94
+ if plan == []:
95
+ if not self.recorder.has_table():
96
+ return self._create_project_state(with_applied_migrations=False)
97
+ else:
98
+ self.recorder.ensure_schema()
99
+
100
+ if plan is None:
101
+ plan = self.migration_plan(targets)
102
+ # Create the forwards plan Plain would follow on an empty database
103
+ full_plan = self.migration_plan(
104
+ self.loader.graph.leaf_nodes(), clean_start=True
105
+ )
106
+
107
+ if not plan:
108
+ if state is None:
109
+ # The resulting state should include applied migrations.
110
+ state = self._create_project_state(with_applied_migrations=True)
111
+ else:
112
+ if state is None:
113
+ # The resulting state should still include applied migrations.
114
+ state = self._create_project_state(with_applied_migrations=True)
115
+
116
+ migrations_to_run = set(plan)
117
+
118
+ # Choose context manager based on atomic_batch
119
+ batch_context = atomic if (atomic_batch and len(plan) > 1) else nullcontext
120
+
121
+ with batch_context():
122
+ for migration in full_plan:
123
+ if not migrations_to_run:
124
+ # We remove every migration that we applied from these sets so
125
+ # that we can bail out once the last migration has been applied
126
+ # and don't always run until the very end of the migration
127
+ # process.
128
+ break
129
+ if migration in migrations_to_run:
130
+ if "models_registry" not in state.__dict__:
131
+ state.models_registry # Render all -- performance critical
132
+ state = self.apply_migration(state, migration, fake=fake)
133
+ migrations_to_run.remove(migration)
134
+
135
+ self.check_replacements()
136
+
137
+ assert state is not None
138
+ return state
139
+
140
+ def apply_migration(
141
+ self, state: ProjectState, migration: Migration, fake: bool = False
142
+ ) -> ProjectState:
143
+ """Run a migration forwards."""
144
+ migration_recorded = False
145
+ if self.progress_callback:
146
+ self.progress_callback("apply_start", migration=migration, fake=fake)
147
+ if not fake:
148
+ # Alright, do it normally
149
+ with self.connection.schema_editor(
150
+ atomic=migration.atomic
151
+ ) as schema_editor:
152
+ state = migration.apply(
153
+ state, schema_editor, operation_callback=self.progress_callback
154
+ )
155
+ if not schema_editor.deferred_sql:
156
+ self.record_migration(migration)
157
+ migration_recorded = True
158
+ if not migration_recorded:
159
+ self.record_migration(migration)
160
+ # Report progress
161
+ if self.progress_callback:
162
+ self.progress_callback("apply_success", migration=migration, fake=fake)
163
+ return state
164
+
165
+ def record_migration(self, migration: Migration) -> None:
166
+ # For replacement migrations, record individual statuses
167
+ if migration.replaces:
168
+ for package_label, name in migration.replaces:
169
+ self.recorder.record_applied(package_label, name)
170
+ else:
171
+ self.recorder.record_applied(migration.package_label, migration.name)
172
+
173
+ def check_replacements(self) -> None:
174
+ """
175
+ Mark replacement migrations applied if their replaced set all are.
176
+
177
+ Do this unconditionally on every migrate, rather than just when
178
+ migrations are applied or unapplied, to correctly handle the case
179
+ when a new squash migration is pushed to a deployment that already had
180
+ all its replaced migrations applied. In this case no new migration will
181
+ be applied, but the applied state of the squashed migration must be
182
+ maintained.
183
+ """
184
+ applied = self.recorder.applied_migrations()
185
+ for key, migration in self.loader.replacements.items():
186
+ all_applied = all(m in applied for m in migration.replaces)
187
+ if all_applied and key not in applied:
188
+ self.recorder.record_applied(*key)
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import total_ordering
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ from plain.postgres.migrations.state import ProjectState
7
+
8
+ from .exceptions import CircularDependencyError, NodeNotFoundError
9
+
10
+ if TYPE_CHECKING:
11
+ from plain.postgres.migrations.migration import Migration
12
+
13
+
14
+ @total_ordering
15
+ class Node:
16
+ """
17
+ A single node in the migration graph. Contains direct links to adjacent
18
+ nodes in either direction.
19
+ """
20
+
21
+ def __init__(self, key: tuple[str, str]):
22
+ self.key = key
23
+ self.children: set[Node] = set()
24
+ self.parents: set[Node] = set()
25
+
26
+ def __eq__(self, other: object) -> bool:
27
+ if isinstance(other, Node):
28
+ return self.key == other.key
29
+ return self.key == other
30
+
31
+ def __lt__(self, other: object) -> bool:
32
+ if isinstance(other, Node):
33
+ return self.key < other.key
34
+ if isinstance(other, tuple):
35
+ return self.key < cast(tuple[str, str], other)
36
+ return NotImplemented
37
+
38
+ def __hash__(self) -> int:
39
+ return hash(self.key)
40
+
41
+ def __getitem__(self, item: int) -> str:
42
+ return self.key[item]
43
+
44
+ def __str__(self) -> str:
45
+ return str(self.key)
46
+
47
+ def __repr__(self) -> str:
48
+ return f"<{self.__class__.__name__}: ({self.key[0]!r}, {self.key[1]!r})>"
49
+
50
+ def add_child(self, child: Node) -> None:
51
+ self.children.add(child)
52
+
53
+ def add_parent(self, parent: Node) -> None:
54
+ self.parents.add(parent)
55
+
56
+
57
+ class DummyNode(Node):
58
+ """
59
+ A node that doesn't correspond to a migration file on disk.
60
+ (A squashed migration that was removed, for example.)
61
+
62
+ After the migration graph is processed, all dummy nodes should be removed.
63
+ If there are any left, a nonexistent dependency error is raised.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ key: tuple[str, str],
69
+ origin: Migration | tuple[str, str] | None,
70
+ error_message: str,
71
+ ):
72
+ super().__init__(key)
73
+ self.origin = origin
74
+ self.error_message = error_message
75
+
76
+ def raise_error(self) -> None:
77
+ raise NodeNotFoundError(self.error_message, self.key, origin=self.origin)
78
+
79
+
80
+ class MigrationGraph:
81
+ """
82
+ Represent the digraph of all migrations in a project.
83
+
84
+ Each migration is a node, and each dependency is an edge. There are
85
+ no implicit dependencies between numbered migrations - the numbering is
86
+ merely a convention to aid file listing. Every new numbered migration
87
+ has a declared dependency to the previous number, meaning that VCS
88
+ branch merges can be detected and resolved.
89
+
90
+ Migrations files can be marked as replacing another set of migrations -
91
+ this is to support the "squash" feature. The graph handler isn't responsible
92
+ for these; instead, the code to load them in here should examine the
93
+ migration files and if the replaced migrations are all either unapplied
94
+ or not present, it should ignore the replaced ones, load in just the
95
+ replacing migration, and repoint any dependencies that pointed to the
96
+ replaced migrations to point to the replacing one.
97
+
98
+ A node should be a tuple: (app_path, migration_name). The tree special-cases
99
+ things within an app - namely, root nodes and leaf nodes ignore dependencies
100
+ to other packages.
101
+ """
102
+
103
+ def __init__(self):
104
+ self.node_map: dict[tuple[str, str], Node] = {}
105
+ self.nodes: dict[tuple[str, str], Migration | None] = {}
106
+
107
+ def add_node(self, key: tuple[str, str], migration: Migration) -> None:
108
+ assert key not in self.node_map
109
+ node = Node(key)
110
+ self.node_map[key] = node
111
+ self.nodes[key] = migration
112
+
113
+ def add_dummy_node(
114
+ self,
115
+ key: tuple[str, str],
116
+ origin: Migration | tuple[str, str] | None,
117
+ error_message: str,
118
+ ) -> None:
119
+ node = DummyNode(key, origin, error_message)
120
+ self.node_map[key] = node
121
+ self.nodes[key] = None
122
+
123
+ def add_dependency(
124
+ self,
125
+ migration: Migration | tuple[str, str] | None,
126
+ child: tuple[str, str],
127
+ parent: tuple[str, str],
128
+ skip_validation: bool = False,
129
+ ) -> None:
130
+ """
131
+ This may create dummy nodes if they don't yet exist. If
132
+ `skip_validation=True`, validate_consistency() should be called
133
+ afterward.
134
+ """
135
+ if child not in self.nodes:
136
+ error_message = (
137
+ f"Migration {migration} dependencies reference nonexistent"
138
+ f" child node {child!r}"
139
+ )
140
+ self.add_dummy_node(child, migration, error_message)
141
+ if parent not in self.nodes:
142
+ error_message = (
143
+ f"Migration {migration} dependencies reference nonexistent"
144
+ f" parent node {parent!r}"
145
+ )
146
+ self.add_dummy_node(parent, migration, error_message)
147
+ self.node_map[child].add_parent(self.node_map[parent])
148
+ self.node_map[parent].add_child(self.node_map[child])
149
+ if not skip_validation:
150
+ self.validate_consistency()
151
+
152
+ def remove_replaced_nodes(
153
+ self, replacement: tuple[str, str], replaced: list[tuple[str, str]]
154
+ ) -> None:
155
+ """
156
+ Remove each of the `replaced` nodes (when they exist). Any
157
+ dependencies that were referencing them are changed to reference the
158
+ `replacement` node instead.
159
+ """
160
+ # Cast list of replaced keys to set to speed up lookup later.
161
+ replaced_set: set[tuple[str, str]] = set(replaced)
162
+ try:
163
+ replacement_node = self.node_map[replacement]
164
+ except KeyError as err:
165
+ raise NodeNotFoundError(
166
+ f"Unable to find replacement node {replacement!r}. It was either never added"
167
+ " to the migration graph, or has been removed.",
168
+ replacement,
169
+ ) from err
170
+ for replaced_key in replaced_set:
171
+ self.nodes.pop(replaced_key, None)
172
+ replaced_node = self.node_map.pop(replaced_key, None)
173
+ if replaced_node:
174
+ for child in replaced_node.children:
175
+ child.parents.remove(replaced_node)
176
+ # We don't want to create dependencies between the replaced
177
+ # node and the replacement node as this would lead to
178
+ # self-referencing on the replacement node at a later iteration.
179
+ if child.key not in replaced_set:
180
+ replacement_node.add_child(child)
181
+ child.add_parent(replacement_node)
182
+ for parent in replaced_node.parents:
183
+ parent.children.remove(replaced_node)
184
+ # Again, to avoid self-referencing.
185
+ if parent.key not in replaced_set:
186
+ replacement_node.add_parent(parent)
187
+ parent.add_child(replacement_node)
188
+
189
+ def remove_replacement_node(
190
+ self, replacement: tuple[str, str], replaced: list[tuple[str, str]]
191
+ ) -> None:
192
+ """
193
+ The inverse operation to `remove_replaced_nodes`. Almost. Remove the
194
+ replacement node `replacement` and remap its child nodes to `replaced`
195
+ - the list of nodes it would have replaced. Don't remap its parent
196
+ nodes as they are expected to be correct already.
197
+ """
198
+ self.nodes.pop(replacement, None)
199
+ try:
200
+ replacement_node = self.node_map.pop(replacement)
201
+ except KeyError as err:
202
+ raise NodeNotFoundError(
203
+ f"Unable to remove replacement node {replacement!r}. It was either never added"
204
+ " to the migration graph, or has been removed already.",
205
+ replacement,
206
+ ) from err
207
+ replaced_nodes: set[Node] = set()
208
+ replaced_nodes_parents: set[Node] = set()
209
+ for key in replaced:
210
+ replaced_node = self.node_map.get(key)
211
+ if replaced_node:
212
+ replaced_nodes.add(replaced_node)
213
+ replaced_nodes_parents |= replaced_node.parents
214
+ # We're only interested in the latest replaced node, so filter out
215
+ # replaced nodes that are parents of other replaced nodes.
216
+ replaced_nodes -= replaced_nodes_parents
217
+ for child in replacement_node.children:
218
+ child.parents.remove(replacement_node)
219
+ for replaced_node in replaced_nodes:
220
+ replaced_node.add_child(child)
221
+ child.add_parent(replaced_node)
222
+ for parent in replacement_node.parents:
223
+ parent.children.remove(replacement_node)
224
+ # NOTE: There is no need to remap parent dependencies as we can
225
+ # assume the replaced nodes already have the correct ancestry.
226
+
227
+ def validate_consistency(self) -> None:
228
+ """Ensure there are no dummy nodes remaining in the graph."""
229
+ [n.raise_error() for n in self.node_map.values() if isinstance(n, DummyNode)]
230
+
231
+ def forwards_plan(self, target: tuple[str, str]) -> list[tuple[str, str]]:
232
+ """
233
+ Given a node, return a list of which previous nodes (dependencies) must
234
+ be applied, ending with the node itself. This is the list you would
235
+ follow if applying the migrations to a database.
236
+ """
237
+ if target not in self.nodes:
238
+ raise NodeNotFoundError(f"Node {target!r} not a valid node", target)
239
+ return self.iterative_dfs(self.node_map[target])
240
+
241
+ def iterative_dfs(
242
+ self, start: Node, forwards: bool = True
243
+ ) -> list[tuple[str, str]]:
244
+ """Iterative depth-first search for finding dependencies."""
245
+ visited: list[tuple[str, str]] = []
246
+ visited_set: set[Node] = set()
247
+ stack: list[tuple[Node, bool]] = [(start, False)]
248
+ while stack:
249
+ node, processed = stack.pop()
250
+ if node in visited_set:
251
+ pass
252
+ elif processed:
253
+ visited_set.add(node)
254
+ visited.append(node.key)
255
+ else:
256
+ stack.append((node, True))
257
+ stack += [
258
+ (n, False)
259
+ for n in sorted(node.parents if forwards else node.children)
260
+ ]
261
+ return visited
262
+
263
+ def root_nodes(self, app: str | None = None) -> list[tuple[str, str]]:
264
+ """
265
+ Return all root nodes - that is, nodes with no dependencies inside
266
+ their app. These are the starting point for an app.
267
+ """
268
+ roots: set[tuple[str, str]] = set()
269
+ for node in self.nodes:
270
+ if all(key[0] != node[0] for key in self.node_map[node].parents) and (
271
+ not app or app == node[0]
272
+ ):
273
+ roots.add(node)
274
+ return sorted(roots)
275
+
276
+ def leaf_nodes(self, app: str | None = None) -> list[tuple[str, str]]:
277
+ """
278
+ Return all leaf nodes - that is, nodes with no dependents in their app.
279
+ These are the "most current" version of an app's schema.
280
+ Having more than one per app is technically an error, but one that
281
+ gets handled further up, in the interactive command - it's usually the
282
+ result of a VCS merge and needs some user input.
283
+ """
284
+ leaves: set[tuple[str, str]] = set()
285
+ for node in self.nodes:
286
+ if all(key[0] != node[0] for key in self.node_map[node].children) and (
287
+ not app or app == node[0]
288
+ ):
289
+ leaves.add(node)
290
+ return sorted(leaves)
291
+
292
+ def ensure_not_cyclic(self) -> None:
293
+ # Algo from GvR:
294
+ # https://neopythonic.blogspot.com/2009/01/detecting-cycles-in-directed-graph.html
295
+ todo: set[tuple[str, str]] = set(self.nodes)
296
+ while todo:
297
+ node = todo.pop()
298
+ stack: list[tuple[str, str]] = [node]
299
+ while stack:
300
+ top = stack[-1]
301
+ for child in self.node_map[top].children:
302
+ # Use child.key instead of child to speed up the frequent
303
+ # hashing.
304
+ node = child.key
305
+ if node in stack:
306
+ cycle = stack[stack.index(node) :]
307
+ raise CircularDependencyError(
308
+ ", ".join("{}.{}".format(*n) for n in cycle)
309
+ )
310
+ if node in todo:
311
+ stack.append(node)
312
+ todo.remove(node)
313
+ break
314
+ else:
315
+ node = stack.pop()
316
+
317
+ def __str__(self) -> str:
318
+ return "Graph: {} nodes, {} edges".format(*self._nodes_and_edges())
319
+
320
+ def __repr__(self) -> str:
321
+ nodes, edges = self._nodes_and_edges()
322
+ return f"<{self.__class__.__name__}: nodes={nodes}, edges={edges}>"
323
+
324
+ def _nodes_and_edges(self) -> tuple[int, int]:
325
+ return len(self.nodes), sum(
326
+ len(node.parents) for node in self.node_map.values()
327
+ )
328
+
329
+ def _generate_plan(
330
+ self, nodes: list[tuple[str, str]], at_end: bool
331
+ ) -> list[tuple[str, str]]:
332
+ plan: list[tuple[str, str]] = []
333
+ for node in nodes:
334
+ for migration in self.forwards_plan(node):
335
+ if migration not in plan and (at_end or migration not in nodes):
336
+ plan.append(migration)
337
+ return plan
338
+
339
+ def make_state(
340
+ self,
341
+ nodes: tuple[str, str] | list[tuple[str, str]] | None = None,
342
+ at_end: bool = True,
343
+ real_packages: Any = None,
344
+ ) -> ProjectState:
345
+ """
346
+ Given a migration node or nodes, return a complete ProjectState for it.
347
+ If at_end is False, return the state before the migration has run.
348
+ If nodes is not provided, return the overall most current project state.
349
+ """
350
+ if nodes is None:
351
+ nodes = list(self.leaf_nodes())
352
+ if not nodes:
353
+ return ProjectState()
354
+ if not isinstance(nodes[0], tuple):
355
+ nodes = cast(list[tuple[str, str]], [nodes])
356
+ assert isinstance(nodes, list) # Type narrowing after checks above
357
+ plan = self._generate_plan(nodes, at_end)
358
+ project_state = ProjectState(real_packages=real_packages)
359
+ for node in plan:
360
+ project_state = self.nodes[node].mutate_state(project_state, preserve=False) # type: ignore[attr-defined]
361
+ return project_state
362
+
363
+ def __contains__(self, node: tuple[str, str]) -> bool:
364
+ return node in self.nodes