pymelos 0.1.3__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 (57) hide show
  1. pymelos/__init__.py +63 -0
  2. pymelos/__main__.py +6 -0
  3. pymelos/cli/__init__.py +5 -0
  4. pymelos/cli/__main__.py +6 -0
  5. pymelos/cli/app.py +527 -0
  6. pymelos/cli/commands/__init__.py +1 -0
  7. pymelos/cli/commands/init.py +151 -0
  8. pymelos/commands/__init__.py +84 -0
  9. pymelos/commands/add.py +77 -0
  10. pymelos/commands/base.py +108 -0
  11. pymelos/commands/bootstrap.py +154 -0
  12. pymelos/commands/changed.py +161 -0
  13. pymelos/commands/clean.py +142 -0
  14. pymelos/commands/exec.py +116 -0
  15. pymelos/commands/list.py +128 -0
  16. pymelos/commands/release.py +258 -0
  17. pymelos/commands/run.py +160 -0
  18. pymelos/compat.py +14 -0
  19. pymelos/config/__init__.py +47 -0
  20. pymelos/config/loader.py +132 -0
  21. pymelos/config/schema.py +236 -0
  22. pymelos/errors.py +139 -0
  23. pymelos/execution/__init__.py +32 -0
  24. pymelos/execution/parallel.py +249 -0
  25. pymelos/execution/results.py +172 -0
  26. pymelos/execution/runner.py +171 -0
  27. pymelos/filters/__init__.py +27 -0
  28. pymelos/filters/chain.py +101 -0
  29. pymelos/filters/ignore.py +60 -0
  30. pymelos/filters/scope.py +90 -0
  31. pymelos/filters/since.py +98 -0
  32. pymelos/git/__init__.py +69 -0
  33. pymelos/git/changes.py +153 -0
  34. pymelos/git/commits.py +174 -0
  35. pymelos/git/repo.py +210 -0
  36. pymelos/git/tags.py +242 -0
  37. pymelos/py.typed +0 -0
  38. pymelos/types.py +16 -0
  39. pymelos/uv/__init__.py +44 -0
  40. pymelos/uv/client.py +167 -0
  41. pymelos/uv/publish.py +162 -0
  42. pymelos/uv/sync.py +168 -0
  43. pymelos/versioning/__init__.py +57 -0
  44. pymelos/versioning/changelog.py +189 -0
  45. pymelos/versioning/conventional.py +216 -0
  46. pymelos/versioning/semver.py +249 -0
  47. pymelos/versioning/updater.py +146 -0
  48. pymelos/workspace/__init__.py +33 -0
  49. pymelos/workspace/discovery.py +138 -0
  50. pymelos/workspace/graph.py +238 -0
  51. pymelos/workspace/package.py +191 -0
  52. pymelos/workspace/workspace.py +218 -0
  53. pymelos-0.1.3.dist-info/METADATA +106 -0
  54. pymelos-0.1.3.dist-info/RECORD +57 -0
  55. pymelos-0.1.3.dist-info/WHEEL +4 -0
  56. pymelos-0.1.3.dist-info/entry_points.txt +2 -0
  57. pymelos-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,238 @@
1
+ """Dependency graph building and topological sorting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from graphlib import CycleError, TopologicalSorter
8
+
9
+ from pymelos.errors import CyclicDependencyError
10
+ from pymelos.workspace.package import Package
11
+
12
+
13
+ @dataclass
14
+ class DependencyGraph:
15
+ """Package dependency graph with topological ordering support.
16
+
17
+ Attributes:
18
+ packages: Dictionary of package name to Package.
19
+ _sorter: Cached TopologicalSorter instance.
20
+ """
21
+
22
+ packages: dict[str, Package]
23
+ _edges: dict[str, set[str]] = field(default_factory=dict, init=False)
24
+ _reverse_edges: dict[str, set[str]] = field(default_factory=dict, init=False)
25
+
26
+ def __post_init__(self) -> None:
27
+ """Build the dependency edges."""
28
+ # Initialize edge sets
29
+ for name in self.packages:
30
+ self._edges[name] = set()
31
+ self._reverse_edges[name] = set()
32
+
33
+ # Build edges: package -> its workspace dependencies
34
+ for name, package in self.packages.items():
35
+ for dep in package.workspace_dependencies:
36
+ # Normalize and check if dependency exists in workspace
37
+ normalized_dep = dep.lower().replace("-", "_")
38
+ for pkg_name in self.packages:
39
+ if pkg_name.lower().replace("-", "_") == normalized_dep:
40
+ self._edges[name].add(pkg_name)
41
+ self._reverse_edges[pkg_name].add(name)
42
+ break
43
+
44
+ @property
45
+ def roots(self) -> list[Package]:
46
+ """Get packages with no dependencies (leaf nodes)."""
47
+ return [self.packages[name] for name, deps in self._edges.items() if not deps]
48
+
49
+ @property
50
+ def leaves(self) -> list[Package]:
51
+ """Get packages that nothing depends on (top-level packages)."""
52
+ return [
53
+ self.packages[name]
54
+ for name, dependents in self._reverse_edges.items()
55
+ if not dependents
56
+ ]
57
+
58
+ def get_dependencies(self, name: str) -> list[Package]:
59
+ """Get direct dependencies of a package.
60
+
61
+ Args:
62
+ name: Package name.
63
+
64
+ Returns:
65
+ List of packages this package depends on.
66
+ """
67
+ deps = self._edges.get(name, set())
68
+ return [self.packages[d] for d in deps if d in self.packages]
69
+
70
+ def get_dependents(self, name: str) -> list[Package]:
71
+ """Get packages that depend on this package.
72
+
73
+ Args:
74
+ name: Package name.
75
+
76
+ Returns:
77
+ List of packages that depend on this package.
78
+ """
79
+ deps = self._reverse_edges.get(name, set())
80
+ return [self.packages[d] for d in deps if d in self.packages]
81
+
82
+ def get_transitive_dependencies(self, name: str) -> list[Package]:
83
+ """Get all transitive dependencies of a package.
84
+
85
+ Args:
86
+ name: Package name.
87
+
88
+ Returns:
89
+ List of all packages this package transitively depends on.
90
+ """
91
+ result: set[str] = set()
92
+ stack = list(self._edges.get(name, set()))
93
+
94
+ while stack:
95
+ dep = stack.pop()
96
+ if dep not in result:
97
+ result.add(dep)
98
+ stack.extend(self._edges.get(dep, set()))
99
+
100
+ return [self.packages[d] for d in result if d in self.packages]
101
+
102
+ def get_transitive_dependents(self, name: str) -> list[Package]:
103
+ """Get all packages that transitively depend on this package.
104
+
105
+ Args:
106
+ name: Package name.
107
+
108
+ Returns:
109
+ List of all packages that transitively depend on this package.
110
+ """
111
+ result: set[str] = set()
112
+ stack = list(self._reverse_edges.get(name, set()))
113
+
114
+ while stack:
115
+ dep = stack.pop()
116
+ if dep not in result:
117
+ result.add(dep)
118
+ stack.extend(self._reverse_edges.get(dep, set()))
119
+
120
+ return [self.packages[d] for d in result if d in self.packages]
121
+
122
+ def get_affected_packages(self, changed: set[str]) -> list[Package]:
123
+ """Get all packages affected by changes to the given packages.
124
+
125
+ This includes the changed packages themselves plus all their
126
+ transitive dependents.
127
+
128
+ Args:
129
+ changed: Set of changed package names.
130
+
131
+ Returns:
132
+ List of all affected packages.
133
+ """
134
+ affected: set[str] = set(changed)
135
+
136
+ for name in changed:
137
+ if name in self.packages:
138
+ dependents = self.get_transitive_dependents(name)
139
+ affected.update(p.name for p in dependents)
140
+
141
+ return [self.packages[n] for n in affected if n in self.packages]
142
+
143
+ def topological_order(self) -> Iterator[Package]:
144
+ """Iterate packages in topological order (dependencies first).
145
+
146
+ Yields:
147
+ Packages in dependency order.
148
+
149
+ Raises:
150
+ CyclicDependencyError: If a cycle is detected.
151
+ """
152
+ sorter: TopologicalSorter[str] = TopologicalSorter()
153
+
154
+ for name, deps in self._edges.items():
155
+ sorter.add(name, *deps)
156
+
157
+ try:
158
+ sorter.prepare()
159
+ except CycleError as e:
160
+ # Extract cycle from error
161
+ cycle = list(e.args[1]) if len(e.args) > 1 else []
162
+ raise CyclicDependencyError(cycle) from e
163
+
164
+ while sorter.is_active():
165
+ for name in sorter.get_ready():
166
+ yield self.packages[name]
167
+ sorter.done(name)
168
+
169
+ def parallel_batches(self) -> Iterator[list[Package]]:
170
+ """Iterate packages in batches that can be executed in parallel.
171
+
172
+ Each batch contains packages that can run concurrently because their
173
+ dependencies are all satisfied.
174
+
175
+ Yields:
176
+ Lists of packages that can run in parallel.
177
+
178
+ Raises:
179
+ CyclicDependencyError: If a cycle is detected.
180
+ """
181
+ sorter: TopologicalSorter[str] = TopologicalSorter()
182
+
183
+ for name, deps in self._edges.items():
184
+ sorter.add(name, *deps)
185
+
186
+ try:
187
+ sorter.prepare()
188
+ except CycleError as e:
189
+ cycle = list(e.args[1]) if len(e.args) > 1 else []
190
+ raise CyclicDependencyError(cycle) from e
191
+
192
+ while sorter.is_active():
193
+ ready = list(sorter.get_ready())
194
+ if ready:
195
+ yield [self.packages[name] for name in ready]
196
+ for name in ready:
197
+ sorter.done(name)
198
+
199
+ def reverse_topological_order(self) -> Iterator[Package]:
200
+ """Iterate packages in reverse topological order (dependents first).
201
+
202
+ Useful for operations like cleaning that should process dependents
203
+ before their dependencies.
204
+
205
+ Yields:
206
+ Packages in reverse dependency order.
207
+ """
208
+ # Collect in list and reverse
209
+ ordered = list(self.topological_order())
210
+ yield from reversed(ordered)
211
+
212
+ def subgraph(self, names: set[str]) -> DependencyGraph:
213
+ """Create a subgraph containing only the specified packages.
214
+
215
+ Args:
216
+ names: Set of package names to include.
217
+
218
+ Returns:
219
+ New DependencyGraph with only the specified packages.
220
+ """
221
+ filtered_packages = {name: pkg for name, pkg in self.packages.items() if name in names}
222
+ return DependencyGraph(packages=filtered_packages)
223
+
224
+ def to_dict(self) -> dict[str, list[str]]:
225
+ """Convert graph to adjacency list representation.
226
+
227
+ Returns:
228
+ Dictionary mapping package names to their dependency names.
229
+ """
230
+ return {name: sorted(deps) for name, deps in self._edges.items()}
231
+
232
+ def __len__(self) -> int:
233
+ """Number of packages in the graph."""
234
+ return len(self.packages)
235
+
236
+ def __contains__(self, name: str) -> bool:
237
+ """Check if a package is in the graph."""
238
+ return name in self.packages
@@ -0,0 +1,191 @@
1
+ """Package model and metadata extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from pymelos.compat import tomllib
9
+ from pymelos.errors import ConfigurationError
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Package:
14
+ """Represents a package in the monorepo.
15
+
16
+ Attributes:
17
+ name: Package name from pyproject.toml.
18
+ path: Absolute path to the package directory.
19
+ version: Package version string.
20
+ description: Package description.
21
+ dependencies: Set of runtime dependency package names.
22
+ dev_dependencies: Set of dev dependency package names.
23
+ workspace_dependencies: Set of local workspace package names this depends on.
24
+ scripts: Package-level scripts from pyproject.toml.
25
+ """
26
+
27
+ name: str
28
+ path: Path
29
+ version: str
30
+ description: str | None = None
31
+ dependencies: frozenset[str] = field(default_factory=frozenset)
32
+ dev_dependencies: frozenset[str] = field(default_factory=frozenset)
33
+ workspace_dependencies: frozenset[str] = field(default_factory=frozenset)
34
+ scripts: dict[str, str] = field(default_factory=dict)
35
+
36
+ @property
37
+ def pyproject_path(self) -> Path:
38
+ """Path to the package's pyproject.toml."""
39
+ return self.path / "pyproject.toml"
40
+
41
+ @property
42
+ def src_path(self) -> Path:
43
+ """Path to the package's src directory."""
44
+ return self.path / "src"
45
+
46
+ @property
47
+ def tests_path(self) -> Path:
48
+ """Path to the package's tests directory."""
49
+ return self.path / "tests"
50
+
51
+ def has_dependency(self, name: str) -> bool:
52
+ """Check if this package depends on another package."""
53
+ return name in self.dependencies or name in self.workspace_dependencies
54
+
55
+ def has_workspace_dependency(self, other: Package) -> bool:
56
+ """Check if this package depends on another workspace package."""
57
+ return other.name in self.workspace_dependencies
58
+
59
+
60
+ def parse_dependency_name(dep: str) -> str:
61
+ """Extract package name from a dependency specifier.
62
+
63
+ Examples:
64
+ "requests>=2.0" -> "requests"
65
+ "numpy[extra]" -> "numpy"
66
+ "my-pkg @ file://..." -> "my-pkg"
67
+ """
68
+ # Handle URL-based dependencies
69
+ if " @ " in dep:
70
+ dep = dep.split(" @ ")[0]
71
+
72
+ # Handle extras
73
+ if "[" in dep:
74
+ dep = dep.split("[")[0]
75
+
76
+ # Handle version specifiers
77
+ for sep in (">=", "<=", "==", "!=", ">", "<", "~=", ";"):
78
+ if sep in dep:
79
+ dep = dep.split(sep)[0]
80
+
81
+ return dep.strip().lower().replace("-", "_")
82
+
83
+
84
+ def load_package(path: Path, workspace_packages: set[str] | None = None) -> Package:
85
+ """Load a package from its directory.
86
+
87
+ Args:
88
+ path: Path to the package directory.
89
+ workspace_packages: Set of known workspace package names for detecting
90
+ local dependencies.
91
+
92
+ Returns:
93
+ Package instance with metadata from pyproject.toml.
94
+
95
+ Raises:
96
+ ConfigurationError: If pyproject.toml is missing or invalid.
97
+ """
98
+ pyproject_path = path / "pyproject.toml"
99
+ if not pyproject_path.is_file():
100
+ raise ConfigurationError(
101
+ "No pyproject.toml found in package directory",
102
+ path=path,
103
+ )
104
+
105
+ try:
106
+ with open(pyproject_path, "rb") as f:
107
+ data = tomllib.load(f)
108
+ except tomllib.TOMLDecodeError as e:
109
+ raise ConfigurationError(
110
+ f"Invalid pyproject.toml: {e}",
111
+ path=pyproject_path,
112
+ ) from e
113
+
114
+ project = data.get("project", {})
115
+ if not project:
116
+ raise ConfigurationError(
117
+ "pyproject.toml missing [project] section",
118
+ path=pyproject_path,
119
+ )
120
+
121
+ name = project.get("name")
122
+ if not name:
123
+ raise ConfigurationError(
124
+ "pyproject.toml missing project.name",
125
+ path=pyproject_path,
126
+ )
127
+
128
+ version = project.get("version", "0.0.0")
129
+ description = project.get("description")
130
+
131
+ # Parse dependencies
132
+ raw_deps = project.get("dependencies", [])
133
+ dependencies = frozenset(parse_dependency_name(d) for d in raw_deps if isinstance(d, str))
134
+
135
+ # Parse dev dependencies from optional-dependencies
136
+ optional_deps = project.get("optional-dependencies", {})
137
+ dev_deps_list = optional_deps.get("dev", [])
138
+ dev_dependencies = frozenset(
139
+ parse_dependency_name(d) for d in dev_deps_list if isinstance(d, str)
140
+ )
141
+
142
+ # Identify workspace dependencies from uv sources
143
+ uv_config = data.get("tool", {}).get("uv", {})
144
+ uv_sources = uv_config.get("sources", {})
145
+ workspace_deps: set[str] = set()
146
+
147
+ for dep_name, source in uv_sources.items():
148
+ if isinstance(source, dict) and source.get("workspace"):
149
+ workspace_deps.add(dep_name.lower().replace("-", "_"))
150
+
151
+ # Also check if any dependencies match known workspace packages
152
+ if workspace_packages:
153
+ for dep in dependencies | dev_dependencies:
154
+ normalized = dep.lower().replace("-", "_")
155
+ if normalized in workspace_packages:
156
+ workspace_deps.add(normalized)
157
+
158
+ # Parse scripts from pyproject.toml
159
+ scripts = project.get("scripts", {})
160
+
161
+ return Package(
162
+ name=name,
163
+ path=path.resolve(),
164
+ version=version,
165
+ description=description,
166
+ dependencies=dependencies,
167
+ dev_dependencies=dev_dependencies,
168
+ workspace_dependencies=frozenset(workspace_deps),
169
+ scripts=dict(scripts) if scripts else {},
170
+ )
171
+
172
+
173
+ def get_package_name_from_path(path: Path) -> str | None:
174
+ """Try to extract package name from pyproject.toml without full loading.
175
+
176
+ Args:
177
+ path: Path to the package directory.
178
+
179
+ Returns:
180
+ Package name if found, None otherwise.
181
+ """
182
+ pyproject_path = path / "pyproject.toml"
183
+ if not pyproject_path.is_file():
184
+ return None
185
+
186
+ try:
187
+ with open(pyproject_path, "rb") as f:
188
+ data = tomllib.load(f)
189
+ return data.get("project", {}).get("name")
190
+ except (tomllib.TOMLDecodeError, OSError):
191
+ return None
@@ -0,0 +1,218 @@
1
+ """Workspace aggregate root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from pymelos.config import PyMelosConfig, load_config
10
+ from pymelos.errors import PackageNotFoundError
11
+ from pymelos.workspace.discovery import discover_packages
12
+ from pymelos.workspace.graph import DependencyGraph
13
+ from pymelos.workspace.package import Package
14
+
15
+
16
+ @dataclass
17
+ class Workspace:
18
+ """The root aggregate for a pymelos workspace.
19
+
20
+ Attributes:
21
+ root: Path to workspace root directory.
22
+ config: Loaded configuration from pymelos.yaml.
23
+ config_path: Path to the pymelos.yaml file.
24
+ packages: Dictionary mapping package names to Package instances.
25
+ """
26
+
27
+ root: Path
28
+ config: PyMelosConfig
29
+ config_path: Path
30
+ packages: dict[str, Package] = field(default_factory=dict)
31
+ _graph: DependencyGraph | None = field(default=None, init=False, repr=False)
32
+
33
+ @classmethod
34
+ def discover(cls, start_path: Path | None = None) -> Workspace:
35
+ """Discover and load workspace from current or specified directory.
36
+
37
+ Args:
38
+ start_path: Directory to start searching from. Defaults to cwd.
39
+
40
+ Returns:
41
+ Loaded Workspace instance.
42
+
43
+ Raises:
44
+ WorkspaceNotFoundError: If no pymelos.yaml found.
45
+ ConfigurationError: If configuration is invalid.
46
+ """
47
+ if start_path is None:
48
+ start_path = Path.cwd()
49
+
50
+ config, config_path = load_config(start_path=start_path)
51
+ root = config_path.parent
52
+
53
+ packages = discover_packages(root, config)
54
+
55
+ return cls(
56
+ root=root,
57
+ config=config,
58
+ config_path=config_path,
59
+ packages=packages,
60
+ )
61
+
62
+ @classmethod
63
+ def from_config(cls, config_path: Path) -> Workspace:
64
+ """Load workspace from explicit config file path.
65
+
66
+ Args:
67
+ config_path: Path to pymelos.yaml.
68
+
69
+ Returns:
70
+ Loaded Workspace instance.
71
+ """
72
+ config, config_path = load_config(path=config_path)
73
+ root = config_path.parent
74
+
75
+ packages = discover_packages(root, config)
76
+
77
+ return cls(
78
+ root=root,
79
+ config=config,
80
+ config_path=config_path,
81
+ packages=packages,
82
+ )
83
+
84
+ @property
85
+ def name(self) -> str:
86
+ """Workspace name from configuration."""
87
+ return self.config.name
88
+
89
+ @property
90
+ def graph(self) -> DependencyGraph:
91
+ """Dependency graph for packages."""
92
+ if self._graph is None:
93
+ self._graph = DependencyGraph(packages=self.packages)
94
+ return self._graph
95
+
96
+ def get_package(self, name: str) -> Package:
97
+ """Get a package by name.
98
+
99
+ Args:
100
+ name: Package name.
101
+
102
+ Returns:
103
+ Package instance.
104
+
105
+ Raises:
106
+ PackageNotFoundError: If package doesn't exist.
107
+ """
108
+ package = self.packages.get(name)
109
+ if package is None:
110
+ raise PackageNotFoundError(name, list(self.packages.keys()))
111
+ return package
112
+
113
+ def has_package(self, name: str) -> bool:
114
+ """Check if a package exists in the workspace.
115
+
116
+ Args:
117
+ name: Package name.
118
+
119
+ Returns:
120
+ True if package exists.
121
+ """
122
+ return name in self.packages
123
+
124
+ def filter_packages(
125
+ self,
126
+ scope: str | None = None,
127
+ ignore: list[str] | None = None,
128
+ names: list[str] | None = None,
129
+ ) -> list[Package]:
130
+ """Filter packages by criteria.
131
+
132
+ Args:
133
+ scope: Comma-separated names or glob patterns.
134
+ ignore: Patterns to exclude.
135
+ names: Explicit list of package names.
136
+
137
+ Returns:
138
+ List of matching packages.
139
+ """
140
+ from pymelos.filters import apply_filters
141
+
142
+ return apply_filters(
143
+ packages=list(self.packages.values()),
144
+ scope=scope,
145
+ ignore=ignore,
146
+ names=names,
147
+ )
148
+
149
+ def topological_order(
150
+ self,
151
+ packages: list[Package] | None = None,
152
+ ) -> Iterator[Package]:
153
+ """Iterate packages in dependency order.
154
+
155
+ Args:
156
+ packages: Subset of packages to order. Defaults to all.
157
+
158
+ Yields:
159
+ Packages in topological order.
160
+ """
161
+ if packages is None:
162
+ yield from self.graph.topological_order()
163
+ else:
164
+ names = {p.name for p in packages}
165
+ subgraph = self.graph.subgraph(names)
166
+ yield from subgraph.topological_order()
167
+
168
+ def parallel_batches(
169
+ self,
170
+ packages: list[Package] | None = None,
171
+ ) -> Iterator[list[Package]]:
172
+ """Iterate packages in parallel-safe batches.
173
+
174
+ Args:
175
+ packages: Subset of packages. Defaults to all.
176
+
177
+ Yields:
178
+ Batches of packages that can run in parallel.
179
+ """
180
+ if packages is None:
181
+ yield from self.graph.parallel_batches()
182
+ else:
183
+ names = {p.name for p in packages}
184
+ subgraph = self.graph.subgraph(names)
185
+ yield from subgraph.parallel_batches()
186
+
187
+ def get_affected_packages(self, changed: list[Package]) -> list[Package]:
188
+ """Get all packages affected by changes to given packages.
189
+
190
+ Args:
191
+ changed: List of changed packages.
192
+
193
+ Returns:
194
+ All affected packages including transitive dependents.
195
+ """
196
+ changed_names = {p.name for p in changed}
197
+ affected = self.graph.get_affected_packages(changed_names)
198
+ return list(affected)
199
+
200
+ def refresh(self) -> None:
201
+ """Reload packages from disk.
202
+
203
+ Call this after making changes to pyproject.toml files.
204
+ """
205
+ self.packages = discover_packages(self.root, self.config)
206
+ self._graph = None
207
+
208
+ def __len__(self) -> int:
209
+ """Number of packages in workspace."""
210
+ return len(self.packages)
211
+
212
+ def __iter__(self) -> Iterator[Package]:
213
+ """Iterate over all packages."""
214
+ return iter(self.packages.values())
215
+
216
+ def __contains__(self, name: str) -> bool:
217
+ """Check if package name exists."""
218
+ return name in self.packages