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.

450 lines
14KB

  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 _get_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(frm=data.pop('from'),
  68. to=data.pop('to'),
  69. include=data.pop('include', []),
  70. strip_components=data.pop('strip-components', 0),
  71. exclude=data.pop('exclude', []))
  72. def apply_to(self, p: Path) -> None:
  73. src = p / self.frm
  74. dest = p / self.to
  75. if src.is_file():
  76. self.do_reloc_file(src, dest)
  77. return
  78. inc_pats = self.include or ['**/*']
  79. include = set(itertools.chain.from_iterable(glob_if_exists(src, pat) for pat in inc_pats))
  80. exclude = set(itertools.chain.from_iterable(glob_if_exists(src, pat) for pat in self.exclude))
  81. to_reloc = sorted(include - exclude)
  82. for source_file in to_reloc:
  83. relpath = source_file.relative_to(src)
  84. strip_relpath = Path('/'.join(relpath.parts[self.strip_components:]))
  85. dest_file = dest / strip_relpath
  86. self.do_reloc_file(source_file, dest_file)
  87. def do_reloc_file(self, src: Path, dest: Path) -> None:
  88. if src.is_dir():
  89. dest.mkdir(exist_ok=True, parents=True)
  90. else:
  91. dest.parent.mkdir(exist_ok=True, parents=True)
  92. src.rename(dest)
  93. class CopyTransform(MoveTransform):
  94. def do_reloc_file(self, src: Path, dest: Path) -> None:
  95. if src.is_dir():
  96. dest.mkdir(exist_ok=True, parents=True)
  97. else:
  98. shutil.copy2(src, dest)
  99. class OneEdit(NamedTuple):
  100. kind: str
  101. line: int
  102. content: Optional[str] = None
  103. @classmethod
  104. def parse_data(cls, data: Dict) -> 'OneEdit':
  105. return OneEdit(data.pop('kind'), data.pop('line'), data.pop('content', None))
  106. def apply_to(self, fpath: Path) -> None:
  107. fn = {
  108. 'insert': self._insert,
  109. # 'delete': self._delete,
  110. }[self.kind]
  111. fn(fpath)
  112. def _insert(self, fpath: Path) -> None:
  113. content = fpath.read_bytes()
  114. lines = content.split(b'\n')
  115. assert self.content
  116. lines.insert(self.line, self.content.encode())
  117. fpath.write_bytes(b'\n'.join(lines))
  118. class EditTransform(NamedTuple):
  119. path: str
  120. edits: Sequence[OneEdit] = []
  121. @classmethod
  122. def parse_data(cls, data: Dict) -> 'EditTransform':
  123. return EditTransform(data.pop('path'), [OneEdit.parse_data(ed) for ed in data.pop('edits')])
  124. def apply_to(self, p: Path) -> None:
  125. fpath = p / self.path
  126. for ed in self.edits:
  127. ed.apply_to(fpath)
  128. class WriteTransform(NamedTuple):
  129. path: str
  130. content: str
  131. @classmethod
  132. def parse_data(self, data: Dict) -> 'WriteTransform':
  133. return WriteTransform(data.pop('path'), data.pop('content'))
  134. def apply_to(self, p: Path) -> None:
  135. fpath = p / self.path
  136. print('Writing to file', p, self.content)
  137. fpath.write_text(self.content)
  138. class RemoveTransform(NamedTuple):
  139. path: Path
  140. only_matching: Sequence[str] = ()
  141. @classmethod
  142. def parse_data(self, d: Any) -> 'RemoveTransform':
  143. p = d.pop('path')
  144. pat = d.pop('only-matching')
  145. return RemoveTransform(Path(p), pat)
  146. def apply_to(self, p: Path) -> None:
  147. if p.is_dir():
  148. self._apply_dir(p)
  149. else:
  150. p.unlink()
  151. def _apply_dir(self, p: Path) -> None:
  152. abspath = p / self.path
  153. if not self.only_matching:
  154. # Remove everything
  155. if abspath.is_dir():
  156. better_rmtree(abspath)
  157. else:
  158. abspath.unlink()
  159. return
  160. for pat in self.only_matching:
  161. items = glob_if_exists(abspath, pat)
  162. for f in items:
  163. if f.is_dir():
  164. better_rmtree(f)
  165. else:
  166. f.unlink()
  167. class FSTransform(NamedTuple):
  168. copy: Optional[CopyTransform] = None
  169. move: Optional[MoveTransform] = None
  170. remove: Optional[RemoveTransform] = None
  171. write: Optional[WriteTransform] = None
  172. edit: Optional[EditTransform] = None
  173. def apply_to(self, p: Path) -> None:
  174. for tr in (self.copy, self.move, self.remove, self.write, self.edit):
  175. if tr:
  176. tr.apply_to(p)
  177. @classmethod
  178. def parse_data(self, data: Any) -> 'FSTransform':
  179. move = data.pop('move', None)
  180. copy = data.pop('copy', None)
  181. remove = data.pop('remove', None)
  182. write = data.pop('write', None)
  183. edit = data.pop('edit', None)
  184. return FSTransform(
  185. copy=None if copy is None else CopyTransform.parse_data(copy),
  186. move=None if move is None else MoveTransform.parse_data(move),
  187. remove=None if remove is None else RemoveTransform.parse_data(remove),
  188. write=None if write is None else WriteTransform.parse_data(write),
  189. edit=None if edit is None else EditTransform.parse_data(edit),
  190. )
  191. class HTTPRemoteSpec(NamedTuple):
  192. url: str
  193. transform: Sequence[FSTransform]
  194. @classmethod
  195. def parse_data(cls, data: Dict[str, Any]) -> 'HTTPRemoteSpec':
  196. url = data.pop('url')
  197. trs = [FSTransform.parse_data(tr) for tr in data.pop('transforms', [])]
  198. return HTTPRemoteSpec(url, trs)
  199. def make_local_dir(self):
  200. return http_dl_unpack(self.url)
  201. class GitSpec(NamedTuple):
  202. url: str
  203. ref: str
  204. transform: Sequence[FSTransform]
  205. @classmethod
  206. def parse_data(cls, data: Dict[str, Any]) -> 'GitSpec':
  207. ref = data.pop('ref')
  208. url = data.pop('url')
  209. trs = [FSTransform.parse_data(tr) for tr in data.pop('transform', [])]
  210. return GitSpec(url=url, ref=ref, transform=trs)
  211. @contextmanager
  212. def make_local_dir(self) -> Iterator[Path]:
  213. tdir = Path(tempfile.mkdtemp())
  214. try:
  215. check_call(['git', 'clone', '--quiet', self.url, f'--depth=1', f'--branch={self.ref}', str(tdir)])
  216. yield tdir
  217. finally:
  218. better_rmtree(tdir)
  219. class ForeignPackage(NamedTuple):
  220. remote: Union[HTTPRemoteSpec, GitSpec]
  221. transform: Sequence[FSTransform]
  222. auto_lib: Optional[Tuple]
  223. @classmethod
  224. def parse_data(cls, data: Dict[str, Any]) -> 'ForeignPackage':
  225. git = data.pop('git', None)
  226. http = data.pop('http', None)
  227. chosen = git or http
  228. assert chosen, data
  229. trs = data.pop('transform', [])
  230. al = data.pop('auto-lib', None)
  231. return ForeignPackage(
  232. remote=GitSpec.parse_data(git) if git else HTTPRemoteSpec.parse_data(http),
  233. transform=[FSTransform.parse_data(tr) for tr in trs],
  234. auto_lib=al.split('/') if al else None,
  235. )
  236. @contextmanager
  237. def make_local_dir(self, name: str, ver: VersionInfo) -> Iterator[Path]:
  238. with self.remote.make_local_dir() as tdir:
  239. for tr in self.transform:
  240. tr.apply_to(tdir)
  241. if self.auto_lib:
  242. pkg_json = {
  243. 'name': name,
  244. 'version': str(ver),
  245. 'namespace': self.auto_lib[0],
  246. }
  247. lib_json = {'name': self.auto_lib[1]}
  248. tdir.joinpath('package.jsonc').write_text(json.dumps(pkg_json))
  249. tdir.joinpath('library.jsonc').write_text(json.dumps(lib_json))
  250. yield tdir
  251. class SpecPackage(NamedTuple):
  252. name: str
  253. version: VersionInfo
  254. depends: Sequence[Dependency]
  255. description: str
  256. remote: ForeignPackage
  257. @classmethod
  258. def parse_data(cls, name: str, version: str, data: Any) -> 'SpecPackage':
  259. deps = data.pop('depends', [])
  260. desc = data.pop('description', '[No description]')
  261. remote = ForeignPackage.parse_data(data.pop('remote'))
  262. return SpecPackage(name,
  263. VersionInfo.parse(version),
  264. description=desc,
  265. depends=[Dependency.parse(d) for d in deps],
  266. remote=remote)
  267. def iter_spec(path: Path) -> Iterable[SpecPackage]:
  268. data = json.loads(path.read_text())
  269. pkgs = data['packages']
  270. return iter_spec_packages(pkgs)
  271. def iter_spec_packages(data: Dict[str, Any]) -> Iterable[SpecPackage]:
  272. for name, versions in data.items():
  273. for version, defin in versions.items():
  274. yield SpecPackage.parse_data(name, version, defin)
  275. def _on_rm_error_win32(fn, filepath, _exc_info):
  276. p = Path(filepath)
  277. p.chmod(stat.S_IWRITE)
  278. p.unlink()
  279. def better_rmtree(dir: Path) -> None:
  280. if os.name == 'nt':
  281. shutil.rmtree(dir, onerror=_on_rm_error_win32)
  282. else:
  283. shutil.rmtree(dir)
  284. @contextmanager
  285. def http_dl_unpack(url: str) -> Iterator[Path]:
  286. req = request.urlopen(url)
  287. tdir = Path(tempfile.mkdtemp())
  288. ofile = tdir / '.dl-archive'
  289. try:
  290. with ofile.open('wb') as fd:
  291. fd.write(req.read())
  292. tf = tarfile.open(ofile)
  293. tf.extractall(tdir)
  294. tf.close()
  295. ofile.unlink()
  296. subdir = next(iter(Path(tdir).iterdir()))
  297. yield subdir
  298. finally:
  299. better_rmtree(tdir)
  300. @contextmanager
  301. def spec_as_local_tgz(dds_exe: Path, spec: SpecPackage) -> Iterator[Path]:
  302. with spec.remote.make_local_dir(spec.name, spec.version) as clone_dir:
  303. out_tgz = clone_dir / 'sdist.tgz'
  304. check_call([str(dds_exe), 'pkg', 'create', f'--project={clone_dir}', f'--out={out_tgz}'])
  305. yield out_tgz
  306. class Repository:
  307. def __init__(self, dds_exe: Path, path: Path) -> None:
  308. self._path = path
  309. self._dds_exe = dds_exe
  310. self._import_lock = Lock()
  311. @property
  312. def pkg_dir(self) -> Path:
  313. return self._path / 'pkg'
  314. @classmethod
  315. def create(cls, dds_exe: Path, dirpath: Path, name: str) -> 'Repository':
  316. check_call([str(dds_exe), 'repoman', 'init', str(dirpath), f'--name={name}'])
  317. return Repository(dds_exe, dirpath)
  318. @classmethod
  319. def open(cls, dds_exe: Path, dirpath: Path) -> 'Repository':
  320. return Repository(dds_exe, dirpath)
  321. def import_tgz(self, path: Path) -> None:
  322. check_call([str(self._dds_exe), 'repoman', 'import', str(self._path), str(path)])
  323. def remove(self, name: str) -> None:
  324. check_call([str(self._dds_exe), 'repoman', 'remove', str(self._path), name])
  325. def spec_import(self, spec: Path) -> None:
  326. all_specs = iter_spec(spec)
  327. want_import = (s for s in all_specs if self._shoule_import(s))
  328. pool = ThreadPoolExecutor(10)
  329. futs = pool.map(self._get_and_import, want_import)
  330. for res in futs:
  331. pass
  332. def _shoule_import(self, spec: SpecPackage) -> bool:
  333. expect_file = self.pkg_dir / spec.name / str(spec.version) / 'sdist.tar.gz'
  334. return not expect_file.is_file()
  335. def _get_and_import(self, spec: SpecPackage) -> None:
  336. print(f'Import: {spec.name}@{spec.version}')
  337. with spec_as_local_tgz(self._dds_exe, spec) as tgz:
  338. with self._import_lock:
  339. self.import_tgz(tgz)
  340. class Arguments(Protocol):
  341. dir: Path
  342. spec: Path
  343. dds_exe: Path
  344. def main(argv: Sequence[str]) -> int:
  345. parser = argparse.ArgumentParser()
  346. parser.add_argument('--dds-exe', type=Path, help='Path to the dds executable to use', default=_get_dds_exe())
  347. parser.add_argument('--dir', '-d', help='Path to a repository to manage', required=True, type=Path)
  348. parser.add_argument('--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.dds_exe, 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()