project_locator.py 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  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 _candidate_roots(cwd: Path) -> Iterable[Path]:
  16. yield cwd
  17. for name in DEFAULT_PROJECT_DIR_NAMES:
  18. yield cwd / name
  19. for parent in cwd.parents:
  20. yield parent
  21. for name in DEFAULT_PROJECT_DIR_NAMES:
  22. yield parent / name
  23. def _is_project_root(path: Path) -> bool:
  24. return (path / ".webnovel" / "state.json").is_file()
  25. def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
  26. """
  27. Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
  28. Resolution order:
  29. 1) explicit_project_root (if provided)
  30. 2) env var WEBNOVEL_PROJECT_ROOT (if set)
  31. 3) Search from cwd and parents, including common subdir `webnovel-project/`
  32. Raises:
  33. FileNotFoundError: if no valid project root can be found.
  34. """
  35. if explicit_project_root:
  36. root = Path(explicit_project_root).expanduser().resolve()
  37. if _is_project_root(root):
  38. return root
  39. raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
  40. env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
  41. if env_root:
  42. root = Path(env_root).expanduser().resolve()
  43. if _is_project_root(root):
  44. return root
  45. raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
  46. base = (cwd or Path.cwd()).resolve()
  47. for candidate in _candidate_roots(base):
  48. if _is_project_root(candidate):
  49. return candidate.resolve()
  50. raise FileNotFoundError(
  51. "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
  52. "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
  53. "WEBNOVEL_PROJECT_ROOT."
  54. )
  55. def resolve_state_file(
  56. explicit_state_file: Optional[str] = None,
  57. *,
  58. explicit_project_root: Optional[str] = None,
  59. cwd: Optional[Path] = None,
  60. ) -> Path:
  61. """
  62. Resolve `.webnovel/state.json` path.
  63. If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
  64. Otherwise derives it from resolve_project_root().
  65. """
  66. base = (cwd or Path.cwd()).resolve()
  67. if explicit_state_file:
  68. p = Path(explicit_state_file).expanduser()
  69. return (base / p).resolve() if not p.is_absolute() else p.resolve()
  70. root = resolve_project_root(explicit_project_root, cwd=base)
  71. return root / ".webnovel" / "state.json"