You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

450 satır
13KB

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