選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

436 行
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 sys
  11. import tarfile
  12. import tempfile
  13. from concurrent.futures import ThreadPoolExecutor
  14. from contextlib import contextmanager
  15. from pathlib import Path
  16. from subprocess import check_call
  17. from threading import Lock
  18. from typing import (Any, Dict, Iterable, Iterator, NamedTuple, NoReturn, Optional, Sequence, Tuple, Type, TypeVar,
  19. Union)
  20. from urllib import request
  21. from semver import VersionInfo
  22. from typing_extensions import Protocol
  23. T = TypeVar('T')
  24. I32_MAX = 0xffff_ffff - 1
  25. MAX_VERSION = VersionInfo(I32_MAX, I32_MAX, I32_MAX)
  26. REPO_ROOT = Path(__file__).resolve().absolute().parent.parent
  27. def dds_exe() -> Path:
  28. suffix = '.exe' if os.name == 'nt' else ''
  29. dirs = [REPO_ROOT / '_build', REPO_ROOT / '_prebuilt']
  30. for d in dirs:
  31. exe = d / ('dds' + suffix)
  32. if exe.is_file():
  33. return exe
  34. raise RuntimeError('Unable to find a dds.exe to use')
  35. class Dependency(NamedTuple):
  36. name: str
  37. low: VersionInfo
  38. high: VersionInfo
  39. @classmethod
  40. def parse(cls: Type[T], depstr: str) -> T:
  41. mat = re.match(r'(.+?)([\^~\+@])(.+?)$', depstr)
  42. if not mat:
  43. raise ValueError(f'Invalid dependency string "{depstr}"')
  44. name, kind, version_str = mat.groups()
  45. version = VersionInfo.parse(version_str)
  46. high = {
  47. '^': version.bump_major,
  48. '~': version.bump_minor,
  49. '@': version.bump_patch,
  50. '+': lambda: MAX_VERSION,
  51. }[kind]()
  52. return cls(name, version, high)
  53. def glob_if_exists(path: Path, pat: str) -> Iterable[Path]:
  54. try:
  55. yield from path.glob(pat)
  56. except FileNotFoundError:
  57. yield from ()
  58. class MoveTransform(NamedTuple):
  59. frm: str
  60. to: str
  61. strip_components: int = 0
  62. include: Sequence[str] = []
  63. exclude: Sequence[str] = []
  64. @classmethod
  65. def parse_data(cls: Type[T], data: Any) -> T:
  66. return cls(
  67. 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. shutil.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. shutil.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. shutil.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(
  263. name,
  264. VersionInfo.parse(version),
  265. description=desc,
  266. depends=[Dependency.parse(d) for d in deps],
  267. remote=remote)
  268. def iter_spec(path: Path) -> Iterable[SpecPackage]:
  269. data = json.loads(path.read_text())
  270. pkgs = data['packages']
  271. return iter_spec_packages(pkgs)
  272. def iter_spec_packages(data: Dict[str, Any]) -> Iterable[SpecPackage]:
  273. for name, versions in data.items():
  274. for version, defin in versions.items():
  275. yield SpecPackage.parse_data(name, version, defin)
  276. @contextmanager
  277. def http_dl_unpack(url: str) -> Iterator[Path]:
  278. req = request.urlopen(url)
  279. tdir = Path(tempfile.mkdtemp())
  280. ofile = tdir / '.dl-archive'
  281. try:
  282. with ofile.open('wb') as fd:
  283. fd.write(req.read())
  284. tf = tarfile.open(ofile)
  285. tf.extractall(tdir)
  286. tf.close()
  287. ofile.unlink()
  288. subdir = next(iter(Path(tdir).iterdir()))
  289. yield subdir
  290. finally:
  291. shutil.rmtree(tdir)
  292. @contextmanager
  293. def spec_as_local_tgz(spec: SpecPackage) -> Iterator[Path]:
  294. with spec.remote.make_local_dir(spec.name, spec.version) as clone_dir:
  295. out_tgz = clone_dir / 'sdist.tgz'
  296. check_call([str(dds_exe()), 'sdist', 'create', f'--project-dir={clone_dir}', f'--out={out_tgz}'])
  297. yield out_tgz
  298. class Repository:
  299. def __init__(self, path: Path) -> None:
  300. self._path = path
  301. self._import_lock = Lock()
  302. @property
  303. def pkg_dir(self) -> Path:
  304. return self._path / 'pkg'
  305. @classmethod
  306. def create(cls, dirpath: Path, name: str) -> 'Repository':
  307. check_call([str(dds_exe()), 'repoman', 'init', str(dirpath), f'--name={name}'])
  308. return Repository(dirpath)
  309. @classmethod
  310. def open(cls, dirpath: Path) -> 'Repository':
  311. return Repository(dirpath)
  312. def import_tgz(self, path: Path) -> None:
  313. check_call([str(dds_exe()), 'repoman', 'import', str(self._path), str(path)])
  314. def remove(self, name: str) -> None:
  315. check_call([str(dds_exe()), 'repoman', 'remove', str(self._path), name])
  316. def spec_import(self, spec: Path) -> None:
  317. all_specs = iter_spec(spec)
  318. want_import = (s for s in all_specs if self._shoule_import(s))
  319. pool = ThreadPoolExecutor(10)
  320. futs = pool.map(self._get_and_import, want_import)
  321. for res in futs:
  322. pass
  323. def _shoule_import(self, spec: SpecPackage) -> bool:
  324. expect_file = self.pkg_dir / spec.name / str(spec.version) / 'sdist.tar.gz'
  325. return not expect_file.is_file()
  326. def _get_and_import(self, spec: SpecPackage) -> None:
  327. print(f'Import: {spec.name}@{spec.version}')
  328. with spec_as_local_tgz(spec) as tgz:
  329. with self._import_lock:
  330. self.import_tgz(tgz)
  331. class Arguments(Protocol):
  332. dir: Path
  333. spec: Path
  334. def main(argv: Sequence[str]) -> int:
  335. parser = argparse.ArgumentParser()
  336. parser.add_argument('--dir', '-d', help='Path to a repository to manage', required=True, type=Path)
  337. parser.add_argument(
  338. '--spec',
  339. metavar='<spec-path>',
  340. type=Path,
  341. required=True,
  342. help='Provide a JSON document specifying how to obtain an import some packages')
  343. args: Arguments = parser.parse_args(argv)
  344. repo = Repository.open(args.dir)
  345. repo.spec_import(args.spec)
  346. return 0
  347. def start() -> NoReturn:
  348. sys.exit(main(sys.argv[1:]))
  349. if __name__ == "__main__":
  350. start()