devflow-toolkit 0.3.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.
- devflow/__init__.py +0 -0
- devflow/adapters/__init__.py +47 -0
- devflow/adapters/agents.py +49 -0
- devflow/adapters/base.py +81 -0
- devflow/adapters/claude_code.py +120 -0
- devflow/adapters/codex.py +111 -0
- devflow/adapters/detector.py +118 -0
- devflow/adapters/instructions.py +263 -0
- devflow/adapters/kiro.py +211 -0
- devflow/adapters/opencode.py +237 -0
- devflow/adapters/registry.py +41 -0
- devflow/cli.py +39 -0
- devflow/commands/__init__.py +6 -0
- devflow/commands/approve.py +98 -0
- devflow/commands/harness.py +98 -0
- devflow/commands/init.py +544 -0
- devflow/commands/spec.py +514 -0
- devflow/commands/status.py +156 -0
- devflow/core/__init__.py +15 -0
- devflow/core/architecture.py +469 -0
- devflow/core/artifacts/__init__.py +29 -0
- devflow/core/artifacts/errors.py +19 -0
- devflow/core/artifacts/models.py +82 -0
- devflow/core/artifacts/store.py +130 -0
- devflow/core/clock.py +67 -0
- devflow/core/config.py +706 -0
- devflow/core/constitution.py +166 -0
- devflow/core/discovery.py +1304 -0
- devflow/core/ports.py +159 -0
- devflow/core/profile_manager.py +311 -0
- devflow/core/project.py +533 -0
- devflow/core/spec_workflow.py +932 -0
- devflow/core/specification.py +2443 -0
- devflow/core/state/__init__.py +75 -0
- devflow/core/state/errors.py +39 -0
- devflow/core/state/events.py +725 -0
- devflow/core/state/locator.py +116 -0
- devflow/core/state/lock.py +69 -0
- devflow/core/state/manager.py +960 -0
- devflow/core/state/models.py +164 -0
- devflow/core/state/transitions.py +114 -0
- devflow/models/__init__.py +6 -0
- devflow/models/profile.py +63 -0
- devflow/models/project.py +47 -0
- devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
- devflow/profiles/ai-rag/profile.yaml +28 -0
- devflow/profiles/base/constitution/architecture.md +58 -0
- devflow/profiles/base/constitution/code-standards.md +85 -0
- devflow/profiles/base/constitution/constitution.md +85 -0
- devflow/profiles/base/profile.yaml +15 -0
- devflow/profiles/django-backend/constitution/django-standards.md +64 -0
- devflow/profiles/django-backend/profile.yaml +24 -0
- devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
- devflow/profiles/fastapi-backend/profile.yaml +23 -0
- devflow/prompts/__init__.py +200 -0
- devflow/prompts/discovery.md +119 -0
- devflow/prompts/spec.greenfield.md +149 -0
- devflow/prompts/spec.md +116 -0
- devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
- devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
- devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
- devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
devflow/core/ports.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Ports and data contracts for harness-independent workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from devflow.core.config import AgentPermissions, DevFlowConfig
|
|
11
|
+
from devflow.core.constitution import Constitution
|
|
12
|
+
from devflow.models.profile import Profile
|
|
13
|
+
from devflow.models.project import Project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdapterError(Exception):
|
|
17
|
+
"""Raised when a harness adapter cannot prepare or run an agent."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HarnessExecutionError(AdapterError):
|
|
21
|
+
"""Raised when the harness executable cannot be started."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SetupContext:
|
|
26
|
+
"""Project information needed to generate harness integration files."""
|
|
27
|
+
|
|
28
|
+
root: Path
|
|
29
|
+
project: Project
|
|
30
|
+
config: DevFlowConfig
|
|
31
|
+
profiles: tuple[Profile, ...]
|
|
32
|
+
constitution: Constitution
|
|
33
|
+
permissions: AgentPermissions
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def constitution_files(self) -> tuple[str, ...]:
|
|
37
|
+
"""Return the constitution filenames in profile order."""
|
|
38
|
+
return tuple(
|
|
39
|
+
document.name for document in self.constitution.documents
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RunContext:
|
|
45
|
+
"""Information needed to invoke a harness for one specification."""
|
|
46
|
+
|
|
47
|
+
root: Path
|
|
48
|
+
prompt: str
|
|
49
|
+
spec_directory: Path
|
|
50
|
+
spec_path: Path
|
|
51
|
+
mode: str
|
|
52
|
+
permissions: AgentPermissions
|
|
53
|
+
output_path: Path | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class FileChange:
|
|
58
|
+
"""A file created, updated or skipped during harness setup."""
|
|
59
|
+
|
|
60
|
+
path: Path
|
|
61
|
+
status: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SetupResult:
|
|
66
|
+
"""Outcome of generating the integration files for one harness."""
|
|
67
|
+
|
|
68
|
+
changes: tuple[FileChange, ...] = field(default_factory=tuple)
|
|
69
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
70
|
+
|
|
71
|
+
def add(self, change: FileChange) -> SetupResult:
|
|
72
|
+
"""Return a new result with an additional file change."""
|
|
73
|
+
return SetupResult(
|
|
74
|
+
changes=(*self.changes, change),
|
|
75
|
+
warnings=self.warnings,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Invocation:
|
|
81
|
+
"""A fully prepared harness process invocation."""
|
|
82
|
+
|
|
83
|
+
argv: tuple[str, ...]
|
|
84
|
+
cwd: Path
|
|
85
|
+
interactive: bool
|
|
86
|
+
stdin_text: str | None = None
|
|
87
|
+
output_path: Path | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class InvocationResult:
|
|
92
|
+
"""Captured result of a harness invocation."""
|
|
93
|
+
|
|
94
|
+
returncode: int
|
|
95
|
+
stdout: str
|
|
96
|
+
stderr: str
|
|
97
|
+
output_path: Path | None = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def file_output(self) -> str:
|
|
101
|
+
"""Return the adapter-provided output file contents, if any."""
|
|
102
|
+
if self.output_path is None or not self.output_path.is_file():
|
|
103
|
+
return ""
|
|
104
|
+
try:
|
|
105
|
+
return self.output_path.read_text(encoding="utf-8")
|
|
106
|
+
except OSError:
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HarnessPort(ABC):
|
|
111
|
+
"""Provider-independent interface implemented by each harness."""
|
|
112
|
+
|
|
113
|
+
name: str
|
|
114
|
+
display_name: str
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def is_available(self) -> bool:
|
|
118
|
+
"""Return whether the harness executable is available."""
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def setup(self, context: SetupContext) -> SetupResult:
|
|
122
|
+
"""Generate the integration files for this harness."""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def build_run(self, context: RunContext) -> Invocation:
|
|
126
|
+
"""Build the process invocation for a specification request."""
|
|
127
|
+
|
|
128
|
+
def permission_warnings(
|
|
129
|
+
self,
|
|
130
|
+
context: RunContext,
|
|
131
|
+
) -> tuple[str, ...]:
|
|
132
|
+
"""Return limitations of the technical permission enforcement."""
|
|
133
|
+
return ()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class InvocationExecutor(Protocol):
|
|
137
|
+
"""Executes a prepared harness invocation."""
|
|
138
|
+
|
|
139
|
+
def __call__(self, invocation: Invocation) -> InvocationResult:
|
|
140
|
+
"""Run the invocation and return its captured result."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_setup_context(
|
|
144
|
+
*,
|
|
145
|
+
config: DevFlowConfig,
|
|
146
|
+
project: Project,
|
|
147
|
+
profiles: list[Profile] | tuple[Profile, ...],
|
|
148
|
+
constitution: Constitution,
|
|
149
|
+
permissions: AgentPermissions | None = None,
|
|
150
|
+
) -> SetupContext:
|
|
151
|
+
"""Build the shared setup context for a harness adapter."""
|
|
152
|
+
return SetupContext(
|
|
153
|
+
root=config.root,
|
|
154
|
+
project=project,
|
|
155
|
+
config=config,
|
|
156
|
+
profiles=tuple(profiles),
|
|
157
|
+
constitution=constitution,
|
|
158
|
+
permissions=permissions or config.permissions,
|
|
159
|
+
)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Discovery, loading and installation of project profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from devflow.core.project import canonicalize_dependency
|
|
14
|
+
from devflow.models.profile import Profile
|
|
15
|
+
from devflow.models.project import Project
|
|
16
|
+
|
|
17
|
+
_GENERIC_DETECTION_FILES = {
|
|
18
|
+
"pyproject.toml",
|
|
19
|
+
"package.json",
|
|
20
|
+
"requirements.txt",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_BASE_PROFILE = "base"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProfileError(Exception):
|
|
27
|
+
"""Raised when a profile cannot be loaded or installed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ProfileMatch:
|
|
32
|
+
"""A profile detected from project signals."""
|
|
33
|
+
|
|
34
|
+
profile: Profile
|
|
35
|
+
reasons: tuple[str, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ConstitutionInstall:
|
|
40
|
+
"""Result of installing constitution files into a project."""
|
|
41
|
+
|
|
42
|
+
copied: tuple[str, ...]
|
|
43
|
+
skipped: tuple[str, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProfileManager:
|
|
47
|
+
"""Load bundled profiles and detect the ones applicable to a project."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, profiles_root: Path | None = None) -> None:
|
|
50
|
+
self.profiles_root = (
|
|
51
|
+
profiles_root
|
|
52
|
+
or Path(__file__).resolve().parent.parent / "profiles"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def available(self) -> dict[str, Profile]:
|
|
56
|
+
"""Return all bundled profiles indexed by name."""
|
|
57
|
+
if not self.profiles_root.is_dir():
|
|
58
|
+
msg = f"Profiles directory not found: {self.profiles_root}"
|
|
59
|
+
raise ProfileError(msg)
|
|
60
|
+
|
|
61
|
+
profiles: dict[str, Profile] = {}
|
|
62
|
+
for manifest_path in sorted(
|
|
63
|
+
self.profiles_root.glob("*/profile.yaml")
|
|
64
|
+
):
|
|
65
|
+
profile = self._load_manifest(manifest_path)
|
|
66
|
+
profiles[profile.name] = profile
|
|
67
|
+
|
|
68
|
+
if _BASE_PROFILE not in profiles:
|
|
69
|
+
msg = f"Base profile not found in {self.profiles_root}"
|
|
70
|
+
raise ProfileError(msg)
|
|
71
|
+
return profiles
|
|
72
|
+
|
|
73
|
+
def names(self) -> list[str]:
|
|
74
|
+
"""Return the names of all available profiles."""
|
|
75
|
+
return sorted(self.available())
|
|
76
|
+
|
|
77
|
+
def resolve(self, names: list[str]) -> list[Profile]:
|
|
78
|
+
"""Resolve profile names, always including the base profile."""
|
|
79
|
+
available = self.available()
|
|
80
|
+
selected: list[str] = []
|
|
81
|
+
for name in names:
|
|
82
|
+
if name and name != _BASE_PROFILE and name not in selected:
|
|
83
|
+
selected.append(name)
|
|
84
|
+
|
|
85
|
+
unknown = sorted(set(selected) - set(available))
|
|
86
|
+
if unknown:
|
|
87
|
+
known = ", ".join(sorted(available))
|
|
88
|
+
msg = (
|
|
89
|
+
f"Unknown profile(s): {', '.join(unknown)}. "
|
|
90
|
+
f"Available profiles: {known}"
|
|
91
|
+
)
|
|
92
|
+
raise ProfileError(msg)
|
|
93
|
+
|
|
94
|
+
ordered: list[Profile] = [available[_BASE_PROFILE]]
|
|
95
|
+
ordered.extend(available[name] for name in selected)
|
|
96
|
+
return ordered
|
|
97
|
+
|
|
98
|
+
def detect(self, project: Project) -> list[ProfileMatch]:
|
|
99
|
+
"""Detect profiles using dependency and file indicators."""
|
|
100
|
+
matches: list[ProfileMatch] = []
|
|
101
|
+
for profile in self.available().values():
|
|
102
|
+
if profile.name == _BASE_PROFILE:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
reasons: list[str] = []
|
|
106
|
+
dependencies = [
|
|
107
|
+
dependency
|
|
108
|
+
for dependency in profile.detection_dependencies
|
|
109
|
+
if self._dependency_present(
|
|
110
|
+
project.dependencies,
|
|
111
|
+
dependency,
|
|
112
|
+
)
|
|
113
|
+
]
|
|
114
|
+
if dependencies:
|
|
115
|
+
reasons.extend(
|
|
116
|
+
f"dependency: {dependency}"
|
|
117
|
+
for dependency in dependencies
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
files = [
|
|
121
|
+
filename
|
|
122
|
+
for filename in profile.detection_files
|
|
123
|
+
if (project.root / filename).exists()
|
|
124
|
+
]
|
|
125
|
+
strong_files = [
|
|
126
|
+
filename
|
|
127
|
+
for filename in files
|
|
128
|
+
if filename not in _GENERIC_DETECTION_FILES
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
if not reasons and strong_files:
|
|
132
|
+
reasons.extend(f"file: {filename}" for filename in strong_files)
|
|
133
|
+
|
|
134
|
+
if reasons:
|
|
135
|
+
matches.append(
|
|
136
|
+
ProfileMatch(profile=profile, reasons=tuple(reasons))
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return matches
|
|
140
|
+
|
|
141
|
+
def install_constitution(
|
|
142
|
+
self,
|
|
143
|
+
root: Path,
|
|
144
|
+
profiles: list[Profile],
|
|
145
|
+
destination: Path | None = None,
|
|
146
|
+
) -> ConstitutionInstall:
|
|
147
|
+
"""Copy profile constitution files without overwriting project files."""
|
|
148
|
+
resolved_root = root.resolve()
|
|
149
|
+
target_directory = (
|
|
150
|
+
destination or resolved_root / ".devflow" / "constitution"
|
|
151
|
+
).resolve()
|
|
152
|
+
try:
|
|
153
|
+
target_directory.relative_to(resolved_root)
|
|
154
|
+
except ValueError as error:
|
|
155
|
+
msg = (
|
|
156
|
+
"Constitution destination must stay inside the project root: "
|
|
157
|
+
f"{target_directory}"
|
|
158
|
+
)
|
|
159
|
+
raise ProfileError(msg) from error
|
|
160
|
+
|
|
161
|
+
target_directory.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
resolved_target = target_directory.resolve()
|
|
163
|
+
if resolved_target != target_directory:
|
|
164
|
+
msg = (
|
|
165
|
+
"Constitution destination changed while it was being created: "
|
|
166
|
+
f"{target_directory}"
|
|
167
|
+
)
|
|
168
|
+
raise ProfileError(msg)
|
|
169
|
+
target_directory = resolved_target
|
|
170
|
+
|
|
171
|
+
copied: list[str] = []
|
|
172
|
+
skipped: list[str] = []
|
|
173
|
+
seen: set[str] = set()
|
|
174
|
+
|
|
175
|
+
for profile in profiles:
|
|
176
|
+
for raw_filename in profile.constitution:
|
|
177
|
+
filename = self._constitution_name(raw_filename, profile)
|
|
178
|
+
if filename in seen:
|
|
179
|
+
msg = (
|
|
180
|
+
f"Duplicate constitution file '{filename}' in profile "
|
|
181
|
+
f"'{profile.name}'."
|
|
182
|
+
)
|
|
183
|
+
raise ProfileError(msg)
|
|
184
|
+
seen.add(filename)
|
|
185
|
+
|
|
186
|
+
source = (
|
|
187
|
+
profile.path.resolve() / "constitution" / filename
|
|
188
|
+
).resolve()
|
|
189
|
+
try:
|
|
190
|
+
source.relative_to(profile.path.resolve())
|
|
191
|
+
except ValueError as error:
|
|
192
|
+
msg = (
|
|
193
|
+
f"Constitution file '{filename}' escapes profile "
|
|
194
|
+
f"'{profile.name}'."
|
|
195
|
+
)
|
|
196
|
+
raise ProfileError(msg) from error
|
|
197
|
+
if not source.is_file():
|
|
198
|
+
msg = (
|
|
199
|
+
f"Constitution file '{filename}' is missing from profile "
|
|
200
|
+
f"'{profile.name}'."
|
|
201
|
+
)
|
|
202
|
+
raise ProfileError(msg)
|
|
203
|
+
|
|
204
|
+
target = target_directory / filename
|
|
205
|
+
if target.exists() or target.is_symlink():
|
|
206
|
+
skipped.append(filename)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if not self._copy_exclusive(source, target):
|
|
210
|
+
skipped.append(filename)
|
|
211
|
+
continue
|
|
212
|
+
copied.append(filename)
|
|
213
|
+
|
|
214
|
+
return ConstitutionInstall(
|
|
215
|
+
copied=tuple(copied),
|
|
216
|
+
skipped=tuple(skipped),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def constitution_documents(
|
|
220
|
+
self,
|
|
221
|
+
profiles: list[Profile],
|
|
222
|
+
) -> list[str]:
|
|
223
|
+
"""Return constitution filenames in profile order."""
|
|
224
|
+
return [
|
|
225
|
+
self._constitution_name(filename, profile)
|
|
226
|
+
for profile in profiles
|
|
227
|
+
for filename in profile.constitution
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
def _constitution_name(self, filename: str, profile: Profile) -> str:
|
|
231
|
+
"""Validate a profile constitution entry as a plain file name."""
|
|
232
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
233
|
+
msg = (
|
|
234
|
+
f"Profile '{profile.name}' declares an empty constitution "
|
|
235
|
+
"file name."
|
|
236
|
+
)
|
|
237
|
+
raise ProfileError(msg)
|
|
238
|
+
|
|
239
|
+
name = filename.strip()
|
|
240
|
+
if (
|
|
241
|
+
name in {".", ".."}
|
|
242
|
+
or "/" in name
|
|
243
|
+
or "\\" in name
|
|
244
|
+
or "\x00" in name
|
|
245
|
+
):
|
|
246
|
+
msg = (
|
|
247
|
+
f"Profile '{profile.name}' declares an invalid constitution "
|
|
248
|
+
f"file name: {filename!r}."
|
|
249
|
+
)
|
|
250
|
+
raise ProfileError(msg)
|
|
251
|
+
return name
|
|
252
|
+
|
|
253
|
+
def _copy_exclusive(self, source: Path, target: Path) -> bool:
|
|
254
|
+
"""Copy a file atomically without replacing an existing target."""
|
|
255
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
256
|
+
prefix=f".{target.name}-",
|
|
257
|
+
suffix=".tmp",
|
|
258
|
+
dir=target.parent,
|
|
259
|
+
)
|
|
260
|
+
temporary = Path(temporary_name)
|
|
261
|
+
try:
|
|
262
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
263
|
+
with source.open("rb") as input_handle:
|
|
264
|
+
shutil.copyfileobj(input_handle, output)
|
|
265
|
+
output.flush()
|
|
266
|
+
os.fsync(output.fileno())
|
|
267
|
+
try:
|
|
268
|
+
mode = source.stat().st_mode & 0o777
|
|
269
|
+
os.chmod(temporary, mode or 0o644)
|
|
270
|
+
except OSError:
|
|
271
|
+
pass
|
|
272
|
+
try:
|
|
273
|
+
os.link(temporary, target)
|
|
274
|
+
except FileExistsError:
|
|
275
|
+
return False
|
|
276
|
+
return True
|
|
277
|
+
finally:
|
|
278
|
+
temporary.unlink(missing_ok=True)
|
|
279
|
+
|
|
280
|
+
def _load_manifest(self, path: Path) -> Profile:
|
|
281
|
+
try:
|
|
282
|
+
with path.open(encoding="utf-8") as handle:
|
|
283
|
+
data = yaml.safe_load(handle)
|
|
284
|
+
except OSError as error:
|
|
285
|
+
msg = f"Could not read profile manifest: {path}"
|
|
286
|
+
raise ProfileError(msg) from error
|
|
287
|
+
except yaml.YAMLError as error:
|
|
288
|
+
msg = f"Invalid YAML in profile manifest: {path}"
|
|
289
|
+
raise ProfileError(msg) from error
|
|
290
|
+
|
|
291
|
+
if not isinstance(data, dict):
|
|
292
|
+
msg = f"Profile manifest must contain a mapping: {path}"
|
|
293
|
+
raise ProfileError(msg)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
return Profile.from_manifest(path.parent, data)
|
|
297
|
+
except ValueError as error:
|
|
298
|
+
msg = f"Invalid profile manifest {path}: {error}"
|
|
299
|
+
raise ProfileError(msg) from error
|
|
300
|
+
|
|
301
|
+
def _dependency_present(
|
|
302
|
+
self,
|
|
303
|
+
project_dependencies: set[str],
|
|
304
|
+
profile_dependency: str,
|
|
305
|
+
) -> bool:
|
|
306
|
+
canonical = canonicalize_dependency(profile_dependency)
|
|
307
|
+
return any(
|
|
308
|
+
dependency == canonical
|
|
309
|
+
or dependency.startswith(f"{canonical}-")
|
|
310
|
+
for dependency in project_dependencies
|
|
311
|
+
)
|