Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

mkrepo.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. """
  2. Script for populating a repository with packages declaratively.
  3. """
  4. import argparse
  5. import itertools
  6. import json
  7. import tarfile
  8. import re
  9. import shutil
  10. import sys
  11. import tempfile
  12. from concurrent.futures import ThreadPoolExecutor
  13. from contextlib import contextmanager
  14. from pathlib import Path
  15. from subprocess import check_call
  16. from threading import Lock
  17. from urllib import request
  18. from typing import (Any, Dict, Iterable, Iterator, NamedTuple, NoReturn, Optional, Sequence, Tuple, TypeVar, Type,
  19. Union)
  20. from semver import VersionInfo
  21. from typing_extensions import Protocol
  22. T = TypeVar('T')
  23. I32_MAX = 0xffff_ffff - 1
  24. MAX_VERSION = VersionInfo(I32_MAX, I32_MAX, I32_MAX)
  25. class Dependency(NamedTuple):
  26. name: str
  27. low: VersionInfo
  28. high: VersionInfo
  29. @classmethod
  30. def parse(cls: Type[T], depstr: str) -> T:
  31. mat = re.match(r'(.+?)([\^~\+@])(.+?)$', depstr)
  32. if not mat:
  33. raise ValueError(f'Invalid dependency string "{depstr}"')
  34. name, kind, version_str = mat.groups()
  35. version = VersionInfo.parse(version_str)
  36. high = {
  37. '^': version.bump_major,
  38. '~': version.bump_minor,
  39. '@': version.bump_patch,
  40. '+': lambda: MAX_VERSION,
  41. }[kind]()
  42. return cls(name, version, high)
  43. def glob_if_exists(path: Path, pat: str) -> Iterable[Path]:
  44. try:
  45. yield from path.glob(pat)
  46. except FileNotFoundError:
  47. yield from ()
  48. class MoveTransform(NamedTuple):
  49. frm: str
  50. to: str
  51. strip_components: int = 0
  52. include: Sequence[str] = []
  53. exclude: Sequence[str] = []
  54. @classmethod
  55. def parse_data(cls: Type[T], data: Any) -> T:
  56. return cls(
  57. frm=data.pop('from'),
  58. to=data.pop('to'),
  59. include=data.pop('include', []),
  60. strip_components=data.pop('strip-components', 0),
  61. exclude=data.pop('exclude', []))
  62. def apply_to(self, p: Path) -> None:
  63. src = p / self.frm
  64. dest = p / self.to
  65. if src.is_file():
  66. self.do_reloc_file(src, dest)
  67. return
  68. inc_pats = self.include or ['**/*']
  69. include = set(itertools.chain.from_iterable(glob_if_exists(src, pat) for pat in inc_pats))
  70. exclude = set(itertools.chain.from_iterable(glob_if_exists(src, pat) for pat in self.exclude))
  71. to_reloc = sorted(include - exclude)
  72. for source_file in to_reloc:
  73. relpath = source_file.relative_to(src)
  74. strip_relpath = Path('/'.join(relpath.parts[self.strip_components:]))
  75. dest_file = dest / strip_relpath
  76. self.do_reloc_file(source_file, dest_file)
  77. def do_reloc_file(self, src: Path, dest: Path) -> None:
  78. if src.is_dir():
  79. dest.mkdir(exist_ok=True, parents=True)
  80. else:
  81. dest.parent.mkdir(exist_ok=True, parents=True)
  82. src.rename(dest)
  83. class CopyTransform(MoveTransform):
  84. def do_reloc_file(self, src: Path, dest: Path) -> None:
  85. if src.is_dir():
  86. dest.mkdir(exist_ok=True, parents=True)
  87. else:
  88. shutil.copy2(src, dest)
  89. class OneEdit(NamedTuple):
  90. kind: str
  91. line: int
  92. content: Optional[str] = None
  93. @classmethod
  94. def parse_data(cls, data: Dict) -> 'OneEdit':
  95. return OneEdit(data.pop('kind'), data.pop('line'), data.pop('content', None))
  96. def apply_to(self, fpath: Path) -> None:
  97. fn = {
  98. 'insert': self._insert,
  99. # 'delete': self._delete,
  100. }[self.kind]
  101. fn(fpath)
  102. def _insert(self, fpath: Path) -> None:
  103. content = fpath.read_bytes()
  104. lines = content.split(b'\n')
  105. assert self.content
  106. lines.insert(self.line, self.content.encode())
  107. fpath.write_bytes(b'\n'.join(lines))
  108. class EditTransform(NamedTuple):
  109. path: str
  110. edits: Sequence[OneEdit] = []
  111. @classmethod
  112. def parse_data(cls, data: Dict) -> 'EditTransform':
  113. return EditTransform(data.pop('path'), [OneEdit.parse_data(ed) for ed in data.pop('edits')])
  114. def apply_to(self, p: Path) -> None:
  115. fpath = p / self.path
  116. for ed in self.edits:
  117. ed.apply_to(fpath)
  118. class WriteTransform(NamedTuple):
  119. path: str
  120. content: str
  121. @classmethod
  122. def parse_data(self, data: Dict) -> 'WriteTransform':
  123. return WriteTransform(data.pop('path'), data.pop('content'))
  124. def apply_to(self, p: Path) -> None:
  125. fpath = p / self.path
  126. print('Writing to file', p, self.content)
  127. fpath.write_text(self.content)
  128. class RemoveTransform(NamedTuple):
  129. path: Path
  130. only_matching: Sequence[str] = ()
  131. @classmethod
  132. def parse_data(self, d: Any) -> 'RemoveTransform':
  133. p = d.pop('path')
  134. pat = d.pop('only-matching')
  135. return RemoveTransform(Path(p), pat)
  136. def apply_to(self, p: Path) -> None:
  137. if p.is_dir():
  138. self._apply_dir(p)
  139. else:
  140. p.unlink()
  141. def _apply_dir(self, p: Path) -> None:
  142. abspath = p / self.path
  143. if not self.only_matching:
  144. # Remove everything
  145. if abspath.is_dir():
  146. shutil.rmtree(abspath)
  147. else:
  148. abspath.unlink()
  149. return
  150. for pat in self.only_matching:
  151. items = glob_if_exists(abspath, pat)
  152. for f in items:
  153. if f.is_dir():
  154. shutil.rmtree(f)
  155. else:
  156. f.unlink()
  157. class FSTransform(NamedTuple):
  158. copy: Optional[CopyTransform] = None
  159. move: Optional[MoveTransform] = None
  160. remove: Optional[RemoveTransform] = None
  161. write: Optional[WriteTransform] = None
  162. edit: Optional[EditTransform] = None
  163. def apply_to(self, p: Path) -> None:
  164. for tr in (self.copy, self.move, self.remove, self.write, self.edit):
  165. if tr:
  166. tr.apply_to(p)
  167. @classmethod
  168. def parse_data(self, data: Any) -> 'FSTransform':
  169. move = data.pop('move', None)
  170. copy = data.pop('copy', None)
  171. remove = data.pop('remove', None)
  172. write = data.pop('write', None)
  173. edit = data.pop('edit', None)
  174. return FSTransform(
  175. copy=None if copy is None else CopyTransform.parse_data(copy),
  176. move=None if move is None else MoveTransform.parse_data(move),
  177. remove=None if remove is None else RemoveTransform.parse_data(remove),
  178. write=None if write is None else WriteTransform.parse_data(write),
  179. edit=None if edit is None else EditTransform.parse_data(edit),
  180. )
  181. class HTTPRemoteSpec(NamedTuple):
  182. url: str
  183. transform: Sequence[FSTransform]
  184. @classmethod
  185. def parse_data(cls, data: Dict[str, Any]) -> 'HTTPRemoteSpec':
  186. url = data.pop('url')
  187. trs = [FSTransform.parse_data(tr) for tr in data.pop('transforms', [])]
  188. return HTTPRemoteSpec(url, trs)
  189. def make_local_dir(self):
  190. return http_dl_unpack(self.url)
  191. class GitSpec(NamedTuple):
  192. url: str
  193. ref: str
  194. transform: Sequence[FSTransform]
  195. @classmethod
  196. def parse_data(cls, data: Dict[str, Any]) -> 'GitSpec':
  197. ref = data.pop('ref')
  198. url = data.pop('url')
  199. trs = [FSTransform.parse_data(tr) for tr in data.pop('transform', [])]
  200. return GitSpec(url=url, ref=ref, transform=trs)
  201. @contextmanager
  202. def make_local_dir(self) -> Iterator[Path]:
  203. tdir = Path(tempfile.mkdtemp())
  204. try:
  205. check_call(['git', 'clone', '--quiet', self.url, f'--depth=1', f'--branch={self.ref}', str(tdir)])
  206. yield tdir
  207. finally:
  208. shutil.rmtree(tdir)
  209. class ForeignPackage(NamedTuple):
  210. remote: Union[HTTPRemoteSpec, GitSpec]
  211. transform: Sequence[FSTransform]
  212. auto_lib: Optional[Tuple]
  213. @classmethod
  214. def parse_data(cls, data: Dict[str, Any]) -> 'ForeignPackage':
  215. git = data.pop('git', None)
  216. http = data.pop('http', None)
  217. chosen = git or http
  218. assert chosen, data
  219. trs = data.pop('transform', [])
  220. al = data.pop('auto-lib', None)
  221. return ForeignPackage(
  222. remote=GitSpec.parse_data(git) if git else HTTPRemoteSpec.parse_data(http),
  223. transform=[FSTransform.parse_data(tr) for tr in trs],
  224. auto_lib=al.split('/') if al else None,
  225. )
  226. @contextmanager
  227. def make_local_dir(self, name: str, ver: VersionInfo) -> Iterator[Path]:
  228. with self.remote.make_local_dir() as tdir:
  229. for tr in self.transform:
  230. tr.apply_to(tdir)
  231. if self.auto_lib:
  232. pkg_json = {
  233. 'name': name,
  234. 'version': str(ver),
  235. 'namespace': self.auto_lib[0],
  236. }
  237. lib_json = {'name': self.auto_lib[1]}
  238. tdir.joinpath('package.jsonc').write_text(json.dumps(pkg_json))
  239. tdir.joinpath('library.jsonc').write_text(json.dumps(lib_json))
  240. yield tdir
  241. class SpecPackage(NamedTuple):
  242. name: str
  243. version: VersionInfo
  244. depends: Sequence[Dependency]
  245. description: str
  246. remote: ForeignPackage
  247. @classmethod
  248. def parse_data(cls, name: str, version: str, data: Any) -> 'SpecPackage':
  249. deps = data.pop('depends', [])
  250. desc = data.pop('description', '[No description]')
  251. remote = ForeignPackage.parse_data(data.pop('remote'))
  252. return SpecPackage(
  253. name,
  254. VersionInfo.parse(version),
  255. description=desc,
  256. depends=[Dependency.parse(d) for d in deps],
  257. remote=remote)
  258. def iter_spec(path: Path) -> Iterable[SpecPackage]:
  259. data = json.loads(path.read_text())
  260. pkgs = data['packages']
  261. return iter_spec_packages(pkgs)
  262. def iter_spec_packages(data: Dict[str, Any]) -> Iterable[SpecPackage]:
  263. for name, versions in data.items():
  264. for version, defin in versions.items():
  265. yield SpecPackage.parse_data(name, version, defin)
  266. @contextmanager
  267. def http_dl_unpack(url: str) -> Iterator[Path]:
  268. req = request.urlopen(url)
  269. tdir = Path(tempfile.mkdtemp())
  270. ofile = tdir / '.dl-archive'
  271. try:
  272. with ofile.open('wb') as fd:
  273. fd.write(req.read())
  274. tf = tarfile.open(ofile)
  275. tf.extractall(tdir)
  276. tf.close()
  277. ofile.unlink()
  278. subdir = next(iter(Path(tdir).iterdir()))
  279. yield subdir
  280. finally:
  281. shutil.rmtree(tdir)
  282. @contextmanager
  283. def spec_as_local_tgz(spec: SpecPackage) -> Iterator[Path]:
  284. with spec.remote.make_local_dir(spec.name, spec.version) as clone_dir:
  285. out_tgz = clone_dir / 'sdist.tgz'
  286. check_call(['dds', 'sdist', 'create', f'--project-dir={clone_dir}', f'--out={out_tgz}'])
  287. yield out_tgz
  288. class Repository:
  289. def __init__(self, path: Path) -> None:
  290. self._path = path
  291. self._import_lock = Lock()
  292. @property
  293. def pkg_dir(self) -> Path:
  294. return self._path / 'pkg'
  295. @classmethod
  296. def create(cls, dirpath: Path, name: str) -> 'Repository':
  297. check_call(['dds', 'repoman', 'init', str(dirpath), f'--name={name}'])
  298. return Repository(dirpath)
  299. @classmethod
  300. def open(cls, dirpath: Path) -> 'Repository':
  301. return Repository(dirpath)
  302. def import_tgz(self, path: Path) -> None:
  303. check_call(['dds', 'repoman', 'import', str(self._path), str(path)])
  304. def remove(self, name: str) -> None:
  305. check_call(['dds', 'repoman', 'remove', str(self._path), name])
  306. def spec_import(self, spec: Path) -> None:
  307. all_specs = iter_spec(spec)
  308. want_import = (s for s in all_specs if self._shoule_import(s))
  309. pool = ThreadPoolExecutor(10)
  310. futs = pool.map(self._get_and_import, want_import)
  311. for res in futs:
  312. pass
  313. def _shoule_import(self, spec: SpecPackage) -> bool:
  314. expect_file = self.pkg_dir / spec.name / str(spec.version) / 'sdist.tar.gz'
  315. return not expect_file.is_file()
  316. def _get_and_import(self, spec: SpecPackage) -> None:
  317. print(f'Import: {spec.name}@{spec.version}')
  318. with spec_as_local_tgz(spec) as tgz:
  319. with self._import_lock:
  320. self.import_tgz(tgz)
  321. class Arguments(Protocol):
  322. dir: Path
  323. spec: Path
  324. def main(argv: Sequence[str]) -> int:
  325. parser = argparse.ArgumentParser()
  326. parser.add_argument('--dir', '-d', help='Path to a repository to manage', required=True, type=Path)
  327. parser.add_argument(
  328. '--spec',
  329. metavar='<spec-path>',
  330. type=Path,
  331. required=True,
  332. help='Provide a JSON document specifying how to obtain an import some packages')
  333. args: Arguments = parser.parse_args(argv)
  334. repo = Repository.open(args.dir)
  335. repo.spec_import(args.spec)
  336. return 0
  337. def start() -> NoReturn:
  338. sys.exit(main(sys.argv[1:]))
  339. if __name__ == "__main__":
  340. start()