lfs.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """
  2. Implementation of a custom transfer agent for the transfer type "multipart" for git-lfs.
  3. Inspired by: github.com/cbartz/git-lfs-swift-transfer-agent/blob/master/git_lfs_swift_transfer.py
  4. Spec is: github.com/git-lfs/git-lfs/blob/master/docs/custom-transfers.md
  5. To launch debugger while developing:
  6. ``` [lfs "customtransfer.multipart"]
  7. path = /path/to/transformers/.env/bin/python args = -m debugpy --listen 5678 --wait-for-client
  8. /path/to/transformers/src/transformers/commands/transformers_cli.py lfs-multipart-upload ```"""
  9. import json
  10. import os
  11. import subprocess
  12. import sys
  13. import warnings
  14. from argparse import ArgumentParser
  15. from contextlib import AbstractContextManager
  16. from typing import Dict, List, Optional
  17. import requests
  18. from ..utils import logging
  19. from . import BaseTransformersCLICommand
  20. logger = logging.get_logger(__name__) # pylint: disable=invalid-name
  21. LFS_MULTIPART_UPLOAD_COMMAND = "lfs-multipart-upload"
  22. class LfsCommands(BaseTransformersCLICommand):
  23. """
  24. Implementation of a custom transfer agent for the transfer type "multipart" for git-lfs. This lets users upload
  25. large files >5GB 🔥. Spec for LFS custom transfer agent is:
  26. https://github.com/git-lfs/git-lfs/blob/master/docs/custom-transfers.md
  27. This introduces two commands to the CLI:
  28. 1. $ transformers-cli lfs-enable-largefiles
  29. This should be executed once for each model repo that contains a model file >5GB. It's documented in the error
  30. message you get if you just try to git push a 5GB file without having enabled it before.
  31. 2. $ transformers-cli lfs-multipart-upload
  32. This command is called by lfs directly and is not meant to be called by the user.
  33. """
  34. @staticmethod
  35. def register_subcommand(parser: ArgumentParser):
  36. enable_parser = parser.add_parser(
  37. "lfs-enable-largefiles",
  38. help=(
  39. "Deprecated: use `huggingface-cli` instead. Configure your repository to enable upload of files > 5GB."
  40. ),
  41. )
  42. enable_parser.add_argument("path", type=str, help="Local path to repository you want to configure.")
  43. enable_parser.set_defaults(func=lambda args: LfsEnableCommand(args))
  44. upload_parser = parser.add_parser(
  45. LFS_MULTIPART_UPLOAD_COMMAND,
  46. help=(
  47. "Deprecated: use `huggingface-cli` instead. "
  48. "Command will get called by git-lfs, do not call it directly."
  49. ),
  50. )
  51. upload_parser.set_defaults(func=lambda args: LfsUploadCommand(args))
  52. class LfsEnableCommand:
  53. def __init__(self, args):
  54. self.args = args
  55. def run(self):
  56. warnings.warn(
  57. "Managing repositories through transformers-cli is deprecated. Please use `huggingface-cli` instead."
  58. )
  59. local_path = os.path.abspath(self.args.path)
  60. if not os.path.isdir(local_path):
  61. print("This does not look like a valid git repo.")
  62. exit(1)
  63. subprocess.run(
  64. "git config lfs.customtransfer.multipart.path transformers-cli".split(), check=True, cwd=local_path
  65. )
  66. subprocess.run(
  67. f"git config lfs.customtransfer.multipart.args {LFS_MULTIPART_UPLOAD_COMMAND}".split(),
  68. check=True,
  69. cwd=local_path,
  70. )
  71. print("Local repo set up for largefiles")
  72. def write_msg(msg: Dict):
  73. """Write out the message in Line delimited JSON."""
  74. msg = json.dumps(msg) + "\n"
  75. sys.stdout.write(msg)
  76. sys.stdout.flush()
  77. def read_msg() -> Optional[Dict]:
  78. """Read Line delimited JSON from stdin."""
  79. msg = json.loads(sys.stdin.readline().strip())
  80. if "terminate" in (msg.get("type"), msg.get("event")):
  81. # terminate message received
  82. return None
  83. if msg.get("event") not in ("download", "upload"):
  84. logger.critical("Received unexpected message")
  85. sys.exit(1)
  86. return msg
  87. class FileSlice(AbstractContextManager):
  88. """
  89. File-like object that only reads a slice of a file
  90. Inspired by stackoverflow.com/a/29838711/593036
  91. """
  92. def __init__(self, filepath: str, seek_from: int, read_limit: int):
  93. self.filepath = filepath
  94. self.seek_from = seek_from
  95. self.read_limit = read_limit
  96. self.n_seen = 0
  97. def __enter__(self):
  98. self.f = open(self.filepath, "rb")
  99. self.f.seek(self.seek_from)
  100. return self
  101. def __len__(self):
  102. total_length = os.fstat(self.f.fileno()).st_size
  103. return min(self.read_limit, total_length - self.seek_from)
  104. def read(self, n=-1):
  105. if self.n_seen >= self.read_limit:
  106. return b""
  107. remaining_amount = self.read_limit - self.n_seen
  108. data = self.f.read(remaining_amount if n < 0 else min(n, remaining_amount))
  109. self.n_seen += len(data)
  110. return data
  111. def __iter__(self):
  112. yield self.read(n=4 * 1024 * 1024)
  113. def __exit__(self, *args):
  114. self.f.close()
  115. class LfsUploadCommand:
  116. def __init__(self, args):
  117. self.args = args
  118. def run(self):
  119. # Immediately after invoking a custom transfer process, git-lfs
  120. # sends initiation data to the process over stdin.
  121. # This tells the process useful information about the configuration.
  122. init_msg = json.loads(sys.stdin.readline().strip())
  123. if not (init_msg.get("event") == "init" and init_msg.get("operation") == "upload"):
  124. write_msg({"error": {"code": 32, "message": "Wrong lfs init operation"}})
  125. sys.exit(1)
  126. # The transfer process should use the information it needs from the
  127. # initiation structure, and also perform any one-off setup tasks it
  128. # needs to do. It should then respond on stdout with a simple empty
  129. # confirmation structure, as follows:
  130. write_msg({})
  131. # After the initiation exchange, git-lfs will send any number of
  132. # transfer requests to the stdin of the transfer process, in a serial sequence.
  133. while True:
  134. msg = read_msg()
  135. if msg is None:
  136. # When all transfers have been processed, git-lfs will send
  137. # a terminate event to the stdin of the transfer process.
  138. # On receiving this message the transfer process should
  139. # clean up and terminate. No response is expected.
  140. sys.exit(0)
  141. oid = msg["oid"]
  142. filepath = msg["path"]
  143. completion_url = msg["action"]["href"]
  144. header = msg["action"]["header"]
  145. chunk_size = int(header.pop("chunk_size"))
  146. presigned_urls: List[str] = list(header.values())
  147. parts = []
  148. for i, presigned_url in enumerate(presigned_urls):
  149. with FileSlice(filepath, seek_from=i * chunk_size, read_limit=chunk_size) as data:
  150. r = requests.put(presigned_url, data=data)
  151. r.raise_for_status()
  152. parts.append(
  153. {
  154. "etag": r.headers.get("etag"),
  155. "partNumber": i + 1,
  156. }
  157. )
  158. # In order to support progress reporting while data is uploading / downloading,
  159. # the transfer process should post messages to stdout
  160. write_msg(
  161. {
  162. "event": "progress",
  163. "oid": oid,
  164. "bytesSoFar": (i + 1) * chunk_size,
  165. "bytesSinceLast": chunk_size,
  166. }
  167. )
  168. # Not precise but that's ok.
  169. r = requests.post(
  170. completion_url,
  171. json={
  172. "oid": oid,
  173. "parts": parts,
  174. },
  175. )
  176. r.raise_for_status()
  177. write_msg({"event": "complete", "oid": oid})