1
0

project_locator.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. #!/usr/bin/env python3
  2. """
  3. Project location helpers for webnovel-writer scripts.
  4. Problem this solves:
  5. - Many scripts assumed CWD is the project root and used relative paths like `.webnovel/state.json`.
  6. - In this repo, commands/scripts are often invoked from the repo root, while the actual project lives
  7. in a subdirectory (default: `webnovel-project/`).
  8. These helpers provide a single, consistent way to locate the active project root.
  9. """
  10. from __future__ import annotations
  11. import os
  12. from pathlib import Path
  13. from typing import Iterable, Optional
  14. DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
  15. def _find_git_root(cwd: Path) -> Optional[Path]:
  16. """Return nearest git root for cwd, if any."""
  17. for candidate in (cwd, *cwd.parents):
  18. if (candidate / ".git").exists():
  19. return candidate
  20. return None
  21. def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
  22. yield cwd
  23. for name in DEFAULT_PROJECT_DIR_NAMES:
  24. yield cwd / name
  25. for parent in cwd.parents:
  26. yield parent
  27. for name in DEFAULT_PROJECT_DIR_NAMES:
  28. yield parent / name
  29. if stop_at is not None and parent == stop_at:
  30. break
  31. def _is_project_root(path: Path) -> bool:
  32. return (path / ".webnovel" / "state.json").is_file()
  33. def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
  34. """
  35. Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
  36. Resolution order:
  37. 1) explicit_project_root (if provided)
  38. 2) env var WEBNOVEL_PROJECT_ROOT (if set)
  39. 3) Search from cwd and parents, including common subdir `webnovel-project/`
  40. Search safety:
  41. - If current location is inside a Git repo, parent search stops at the repo root.
  42. This avoids accidentally binding to unrelated parent directories.
  43. Raises:
  44. FileNotFoundError: if no valid project root can be found.
  45. """
  46. if explicit_project_root:
  47. root = Path(explicit_project_root).expanduser().resolve()
  48. if _is_project_root(root):
  49. return root
  50. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  51. env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
  52. if env_root:
  53. root = Path(env_root).expanduser().resolve()
  54. if _is_project_root(root):
  55. return root
  56. raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
  57. base = (cwd or Path.cwd()).resolve()
  58. git_root = _find_git_root(base)
  59. for candidate in _candidate_roots(base, stop_at=git_root):
  60. if _is_project_root(candidate):
  61. return candidate.resolve()
  62. raise FileNotFoundError(
  63. "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
  64. "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
  65. "WEBNOVEL_PROJECT_ROOT."
  66. )
  67. def resolve_state_file(
  68. explicit_state_file: Optional[str] = None,
  69. *,
  70. explicit_project_root: Optional[str] = None,
  71. cwd: Optional[Path] = None,
  72. ) -> Path:
  73. """
  74. Resolve `.webnovel/state.json` path.
  75. If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
  76. Otherwise derives it from resolve_project_root().
  77. """
  78. base = (cwd or Path.cwd()).resolve()
  79. if explicit_state_file:
  80. p = Path(explicit_state_file).expanduser()
  81. return (base / p).resolve() if not p.is_absolute() else p.resolve()
  82. root = resolve_project_root(explicit_project_root, cwd=base)
  83. return root / ".webnovel" / "state.json"