base.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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 get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
  85. """Read one commit's display metadata by SHA.
  86. ``list_commits`` only reaches back as far as its limit, so a ref outside
  87. that window has no entry to describe it. This is the direct lookup for
  88. that case.
  89. Returns ``{"success", "message", "commit": {"sha", "message", "author",
  90. "date"} | None}``.
  91. """
  92. @abstractmethod
  93. async def list_tree(
  94. self,
  95. repo_url: str,
  96. token: str,
  97. ref: str,
  98. client: httpx.AsyncClient,
  99. ) -> dict:
  100. """List every blob path present at ``ref``.
  101. ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
  102. via :meth:`list_commits` first, so the snapshot being previewed and the
  103. one being restored are provably the same commit even if a scheduled
  104. backup lands in between.
  105. Returns ``{"success", "message", "paths": [str], "blob_shas":
  106. {path: sha}}``. ``blob_shas`` is the path -> blob SHA map the listing
  107. already had to build, offered so :meth:`fetch_files` need not fetch the
  108. same tree again; providers that read files by path return ``{}``.
  109. """
  110. @abstractmethod
  111. async def fetch_files(
  112. self,
  113. repo_url: str,
  114. token: str,
  115. ref: str,
  116. paths: list[str],
  117. client: httpx.AsyncClient,
  118. blob_shas: dict[str, str] | None = None,
  119. ) -> dict:
  120. """Read several files' decoded UTF-8 text at ``ref``.
  121. Batched rather than one-file-at-a-time so providers that need a tree
  122. listing to map path -> blob SHA can do that lookup once for the whole
  123. restore instead of per file.
  124. ``blob_shas`` is the map :meth:`list_tree` returned for the same ref, if
  125. the caller has one. Passing it saves a second recursive tree GET; a
  126. provider that reads by path ignores it, and one that needs it fetches
  127. the tree itself when it is absent.
  128. Returns ``{"success", "message", "files": {path: text}}``. Paths absent
  129. from the commit are simply missing from ``files`` — that is not an error,
  130. since which categories a given backup contains varies by config.
  131. """