|
6 | 6 | from pathlib import Path, PureWindowsPath |
7 | 7 | import sys |
8 | 8 | from typing import Any |
| 9 | +import uuid |
9 | 10 |
|
10 | 11 | from rich.console import Console |
11 | 12 | import tomli |
@@ -221,3 +222,121 @@ def get_project_description( |
221 | 222 | return _get_project_attribute( |
222 | 223 | pyproject_path, ["project", "description"], require=require |
223 | 224 | ) |
| 225 | + |
| 226 | + |
| 227 | +_PROJECT_ID_KEY = "project_id" |
| 228 | + |
| 229 | + |
| 230 | +def get_project_id(pyproject_path: str | Path = "pyproject.toml") -> str | None: |
| 231 | + """Return ``[tool.crewai].project_id`` if the project has one. |
| 232 | +
|
| 233 | + Read-only and safe to call from library code: it never creates or modifies |
| 234 | + anything. Use this everywhere except the CLI commands that are allowed to |
| 235 | + mint an id (see :func:`get_or_create_project_id`). |
| 236 | +
|
| 237 | + Args: |
| 238 | + pyproject_path: Path to the project's ``pyproject.toml``. |
| 239 | +
|
| 240 | + Returns: |
| 241 | + The project id, or None when the file is missing, unreadable, or has |
| 242 | + no id configured. |
| 243 | + """ |
| 244 | + try: |
| 245 | + pyproject_data = read_toml(pyproject_path) |
| 246 | + except (OSError, tomli.TOMLDecodeError): |
| 247 | + return None |
| 248 | + |
| 249 | + project_id = get_crewai_project_config(pyproject_data).get(_PROJECT_ID_KEY) |
| 250 | + return project_id if isinstance(project_id, str) and project_id else None |
| 251 | + |
| 252 | + |
| 253 | +def get_or_create_project_id( |
| 254 | + pyproject_path: str | Path = "pyproject.toml", |
| 255 | +) -> tuple[str | None, bool]: |
| 256 | + """Return the project's id, minting and persisting one if absent. |
| 257 | +
|
| 258 | + Writes ``project_id`` into the ``[tool.crewai]`` table so it is committed |
| 259 | + with the repository. That makes it stable across machines, teammates, CI, |
| 260 | + and containers - unlike a machine- or user-derived identifier. |
| 261 | +
|
| 262 | + Only CLI commands the user explicitly invoked should call this. Library |
| 263 | + code must use :func:`get_project_id` instead; silently rewriting a user's |
| 264 | + ``pyproject.toml`` during ``Crew.kickoff()`` would be surprising. |
| 265 | +
|
| 266 | + Args: |
| 267 | + pyproject_path: Path to the project's ``pyproject.toml``. |
| 268 | +
|
| 269 | + Returns: |
| 270 | + A ``(project_id, created)`` tuple. ``created`` is True only when an id |
| 271 | + was minted and written on this call, so callers can tell the user. Both |
| 272 | + values are ``(None, False)`` when the file is missing or not writable - |
| 273 | + this is best-effort and never raises. |
| 274 | + """ |
| 275 | + existing = get_project_id(pyproject_path) |
| 276 | + if existing: |
| 277 | + return existing, False |
| 278 | + |
| 279 | + path = Path(pyproject_path) |
| 280 | + if not path.is_file(): |
| 281 | + return None, False |
| 282 | + |
| 283 | + try: |
| 284 | + content = path.read_text(encoding="utf-8") |
| 285 | + except OSError: |
| 286 | + return None, False |
| 287 | + |
| 288 | + project_id = str(uuid.uuid4()) |
| 289 | + updated = _insert_project_id(content, project_id) |
| 290 | + if updated is None: |
| 291 | + return None, False |
| 292 | + |
| 293 | + try: |
| 294 | + path.write_text(updated, encoding="utf-8") |
| 295 | + except OSError: |
| 296 | + # Read-only checkout, permissions, container FS - not worth failing over. |
| 297 | + return None, False |
| 298 | + |
| 299 | + return project_id, True |
| 300 | + |
| 301 | + |
| 302 | +def _insert_project_id(content: str, project_id: str) -> str | None: |
| 303 | + """Add ``project_id`` to the ``[tool.crewai]`` table in TOML source text. |
| 304 | +
|
| 305 | + Edits the raw text rather than round-tripping through a TOML writer so |
| 306 | + formatting, ordering, and comments in the rest of the file are preserved. |
| 307 | +
|
| 308 | + Args: |
| 309 | + content: Full contents of a ``pyproject.toml``. |
| 310 | + project_id: The id to insert. |
| 311 | +
|
| 312 | + Returns: |
| 313 | + Updated file contents, or None if the edit could not be made safely. |
| 314 | + """ |
| 315 | + lines = content.splitlines(keepends=True) |
| 316 | + entry = f'{_PROJECT_ID_KEY} = "{project_id}"\n' |
| 317 | + |
| 318 | + for index, line in enumerate(lines): |
| 319 | + if line.strip() != "[tool.crewai]": |
| 320 | + continue |
| 321 | + |
| 322 | + # Insert at the end of the table, before the next table header, so the |
| 323 | + # key cannot land inside a different section. |
| 324 | + insert_at = len(lines) |
| 325 | + for offset in range(index + 1, len(lines)): |
| 326 | + if lines[offset].lstrip().startswith("["): |
| 327 | + insert_at = offset |
| 328 | + break |
| 329 | + |
| 330 | + # Step back over trailing blank lines so the key stays in the table. |
| 331 | + while insert_at > index + 1 and not lines[insert_at - 1].strip(): |
| 332 | + insert_at -= 1 |
| 333 | + |
| 334 | + if insert_at > 0 and not lines[insert_at - 1].endswith("\n"): |
| 335 | + lines[insert_at - 1] += "\n" |
| 336 | + |
| 337 | + lines.insert(insert_at, entry) |
| 338 | + return "".join(lines) |
| 339 | + |
| 340 | + # No [tool.crewai] table: append one rather than guessing where it belongs. |
| 341 | + suffix = "" if content.endswith("\n") or not content else "\n" |
| 342 | + return f"{content}{suffix}\n[tool.crewai]\n{entry}" |
0 commit comments