base.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """Abstract base class for Git hosting provider backends."""
  2. import hashlib
  3. from abc import ABC, abstractmethod
  4. import httpx
  5. class GitProviderBackend(ABC):
  6. """Abstract base for Git hosting provider API backends."""
  7. @staticmethod
  8. def _blob_sha(content_bytes: bytes) -> str:
  9. """Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
  10. return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
  11. @staticmethod
  12. def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
  13. """Return a bounded response body for errors surfaced to logs/UI."""
  14. text = response.text
  15. if len(text) <= max_length:
  16. return text
  17. return f"{text[: max_length - 3]}..."
  18. @staticmethod
  19. def _read_sha(response: httpx.Response, *path: str) -> tuple[str | None, str | None]:
  20. """Walk a JSON path to a string SHA value.
  21. Returns ``(sha, None)`` on success, ``(None, reason)`` if the body is
  22. not JSON, the path is missing, or the leaf is not a string. Callers
  23. use the reason to build a clear failure message instead of letting
  24. ``KeyError``/``JSONDecodeError`` bubble to the outer catch-all (which
  25. surfaces cryptic one-word strings like ``"'object'"`` to operators).
  26. """
  27. try:
  28. data = response.json()
  29. except ValueError:
  30. return None, "non-JSON response body"
  31. for key in path:
  32. if not isinstance(data, dict):
  33. return None, f"unexpected shape at key {key!r}"
  34. if key not in data:
  35. return None, f"missing key {key!r}"
  36. data = data[key]
  37. if not isinstance(data, str):
  38. return None, f"value at {'.'.join(path)} is not a string"
  39. return data, None
  40. def get_headers(self, token: str) -> dict:
  41. """Return HTTP headers for authenticated API requests."""
  42. return {
  43. "Authorization": f"token {token}",
  44. "Accept": "application/vnd.github.v3+json",
  45. "User-Agent": "Bambuddy-Backup",
  46. }
  47. @abstractmethod
  48. def parse_repo_url(self, url: str) -> tuple[str, str]:
  49. """Return (owner, repo) extracted from the repository URL."""
  50. @abstractmethod
  51. def get_api_base(self, repo_url: str) -> str:
  52. """Return the API base URL for this provider instance."""
  53. @abstractmethod
  54. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  55. """Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
  56. @abstractmethod
  57. async def push_files(
  58. self,
  59. repo_url: str,
  60. token: str,
  61. branch: str,
  62. files: dict,
  63. client: httpx.AsyncClient,
  64. ) -> dict:
  65. """Push files to the repository. Returns status/message/commit_sha/files_changed."""
  66. # --- Read side (restore, issue #2656) ---------------------------------
  67. # The backup path only ever writes. Restore needs to walk history, list a
  68. # snapshot and read individual blobs back, so these three mirror the
  69. # ``{"success": bool, "message": str, ...}`` convention ``test_connection``
  70. # already uses rather than raising.
  71. @abstractmethod
  72. async def list_commits(
  73. self,
  74. repo_url: str,
  75. token: str,
  76. branch: str,
  77. client: httpx.AsyncClient,
  78. limit: int = 20,
  79. ) -> dict:
  80. """List recent commits on ``branch``, newest first.
  81. Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
  82. """
  83. @abstractmethod
  84. async def list_tree(
  85. self,
  86. repo_url: str,
  87. token: str,
  88. ref: str,
  89. client: httpx.AsyncClient,
  90. ) -> dict:
  91. """List every blob path present at ``ref``.
  92. ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
  93. via :meth:`list_commits` first, so the snapshot being previewed and the
  94. one being restored are provably the same commit even if a scheduled
  95. backup lands in between.
  96. Returns ``{"success", "message", "paths": [str]}``.
  97. """
  98. @abstractmethod
  99. async def fetch_files(
  100. self,
  101. repo_url: str,
  102. token: str,
  103. ref: str,
  104. paths: list[str],
  105. client: httpx.AsyncClient,
  106. ) -> dict:
  107. """Read several files' decoded UTF-8 text at ``ref``.
  108. Batched rather than one-file-at-a-time so providers that need a tree
  109. listing to map path -> blob SHA can do that lookup once for the whole
  110. restore instead of per file.
  111. Returns ``{"success", "message", "files": {path: text}}``. Paths absent
  112. from the commit are simply missing from ``files`` — that is not an error,
  113. since which categories a given backup contains varies by config.
  114. """