2 # -*- coding: utf-8 -*-
5 bootstrap.py - bootstrapping utility for cabal-install.
7 See bootstrap/README.md for usage instructions.
11 This utility is only intended for use in building cabal-install
12 on a new platform. If you already have a functional (if dated) cabal-install
13 please rather run `cabal install .`.
20 from pathlib
import Path
27 from textwrap
import dedent
28 from typing
import Optional
, Dict
, List
, Tuple
, \
29 NewType
, BinaryIO
, NamedTuple
31 #logging.basicConfig(level=logging.INFO)
33 BUILDDIR
= Path('_build')
35 BINDIR
= BUILDDIR
/ 'bin' # binaries go there (--bindir)
36 DISTDIR
= BUILDDIR
/ 'dists' # --builddir
37 UNPACKED
= BUILDDIR
/ 'unpacked' # where we unpack tarballs
38 TARBALLS
= BUILDDIR
/ 'tarballs' # where we download tarballks
39 PSEUDOSTORE
= BUILDDIR
/ 'pseudostore' # where we install packages
40 ARTIFACTS
= BUILDDIR
/ 'artifacts' # Where we put the archive
41 TMPDIR
= BUILDDIR
/ 'tmp' #
42 PKG_DB
= BUILDDIR
/ 'packages.conf' # package db
44 PackageName
= NewType('PackageName', str)
45 Version
= NewType('Version', str)
46 SHA256Hash
= NewType('SHA256Hash', str)
48 class PackageSource(Enum
):
52 BuiltinDep
= NamedTuple('BuiltinDep', [
53 ('package', PackageName
),
57 BootstrapDep
= NamedTuple('BootstrapDep', [
58 ('package', PackageName
),
60 ('source', PackageSource
),
61 # source tarball SHA256
62 ('src_sha256', Optional
[SHA256Hash
]),
63 # `revision` is only valid when source == HACKAGE.
64 ('revision', Optional
[int]),
65 ('cabal_sha256', Optional
[SHA256Hash
]),
67 ('component', Optional
[str])
70 BootstrapInfo
= NamedTuple('BootstrapInfo', [
71 ('builtin', List
[BuiltinDep
]),
72 ('dependencies', List
[BootstrapDep
]),
75 FetchInfo
= NamedTuple('FetchInfo', [
77 ('sha256', SHA256Hash
)
80 FetchPlan
= Dict
[Path
, FetchInfo
]
82 local_packages
: List
[PackageName
] = ["Cabal-syntax", "Cabal", "cabal-install-solver", "cabal-install"]
85 def __init__(self
, ghc_path
: Path
):
86 if not ghc_path
.is_file():
87 raise TypeError(f
'GHC {ghc_path} is not a file')
89 self
.ghc_path
= ghc_path
.resolve()
92 if platform
.system() == 'Windows': exe
= '.exe'
94 info
= self
._get
_ghc
_info
()
95 self
.version
= info
['Project version']
96 #self.lib_dir = Path(info['LibDir'])
97 #self.ghc_pkg_path = (self.lib_dir / 'bin' / 'ghc-pkg').resolve()
98 self
.ghc_pkg_path
= (self
.ghc_path
.parent
/ ('ghc-pkg' + exe
)).resolve()
99 if not self
.ghc_pkg_path
.is_file():
100 raise TypeError(f
'ghc-pkg {self.ghc_pkg_path} is not a file')
101 self
.hsc2hs_path
= (self
.ghc_path
.parent
/ ('hsc2hs' + exe
)).resolve()
102 if not self
.hsc2hs_path
.is_file():
103 raise TypeError(f
'hsc2hs {self.hsc2hs_path} is not a file')
105 def _get_ghc_info(self
) -> Dict
[str,str]:
106 from ast
import literal_eval
107 p
= subprocess_run([self
.ghc_path
, '--info'], stdout
=subprocess
.PIPE
, check
=True, encoding
='UTF-8')
108 out
= p
.stdout
.replace('\n', '').strip()
109 return dict(literal_eval(out
))
111 PackageSpec
= Tuple
[PackageName
, Version
]
113 class BadTarball(Exception):
114 def __init__(self
, path
: Path
, expected_sha256
: SHA256Hash
, found_sha256
: SHA256Hash
):
116 self
.expected_sha256
= expected_sha256
117 self
.found_sha256
= found_sha256
121 f
'Bad tarball hash: {str(self.path)}',
122 f
' expected: {self.expected_sha256}',
123 f
' found: {self.found_sha256}',
126 def package_url(package
: PackageName
, version
: Version
) -> str:
127 return f
'http://hackage.haskell.org/package/{package}-{version}/{package}-{version}.tar.gz'
129 def package_cabal_url(package
: PackageName
, version
: Version
, revision
: int) -> str:
130 return f
'http://hackage.haskell.org/package/{package}-{version}/revision/{revision}.cabal'
132 def verify_sha256(expected_hash
: SHA256Hash
, f
: Path
):
133 h
= hash_file(hashlib
.sha256(), f
.open('rb'))
134 if h
!= expected_hash
:
135 raise BadTarball(f
, expected_hash
, h
)
137 def read_bootstrap_info(path
: Path
) -> BootstrapInfo
:
138 obj
= json
.load(path
.open())
140 def bi_from_json(o
: dict) -> BuiltinDep
:
141 return BuiltinDep(**o
)
143 def dep_from_json(o
: dict) -> BootstrapDep
:
144 o
['source'] = PackageSource(o
['source'])
145 return BootstrapDep(**o
)
147 builtin
= [bi_from_json(dep
) for dep
in obj
['builtin'] ]
148 deps
= [dep_from_json(dep
) for dep
in obj
['dependencies'] ]
150 return BootstrapInfo(dependencies
=deps
, builtin
=builtin
)
152 def check_builtin(dep
: BuiltinDep
, ghc
: Compiler
) -> None:
153 subprocess_run([str(ghc
.ghc_pkg_path
), 'describe', f
'{dep.package}-{dep.version}'],
154 check
=True, stdout
=subprocess
.DEVNULL
)
155 print(f
'Using {dep.package}-{dep.version} from GHC...')
158 def resolve_dep(dep
: BootstrapDep
) -> Path
:
159 if dep
.source
== PackageSource
.HACKAGE
:
161 tarball
= TARBALLS
/ f
'{dep.package}-{dep.version}.tar.gz'
162 verify_sha256(dep
.src_sha256
, tarball
)
164 cabal_file
= TARBALLS
/ f
'{dep.package}.cabal'
165 verify_sha256(dep
.cabal_sha256
, cabal_file
)
167 UNPACKED
.mkdir(parents
=True, exist_ok
=True)
168 shutil
.unpack_archive(tarball
.resolve(), UNPACKED
, 'gztar')
169 sdist_dir
= UNPACKED
/ f
'{dep.package}-{dep.version}'
171 # Update cabal file with revision
172 if dep
.revision
is not None:
173 shutil
.copyfile(cabal_file
, sdist_dir
/ f
'{dep.package}.cabal')
175 # We rely on the presence of Setup.hs
176 if len(list(sdist_dir
.glob('Setup.*hs'))) == 0:
177 with
open(sdist_dir
/ 'Setup.hs', 'w') as f
:
178 f
.write('import Distribution.Simple\n')
179 f
.write('main = defaultMain\n')
181 elif dep
.source
== PackageSource
.LOCAL
:
182 if dep
.package
in local_packages
:
183 sdist_dir
= Path(dep
.package
).resolve()
185 raise ValueError(f
'Unknown local package {dep.package}')
188 def install_dep(dep
: BootstrapDep
, ghc
: Compiler
) -> None:
189 dist_dir
= (DISTDIR
/ f
'{dep.package}-{dep.version}').resolve()
191 sdist_dir
= resolve_dep(dep
)
193 install_sdist(dist_dir
, sdist_dir
, ghc
, dep
.flags
, dep
.component
)
195 def install_sdist(dist_dir
: Path
, sdist_dir
: Path
, ghc
: Compiler
, flags
: List
[str], component
):
196 prefix
= PSEUDOSTORE
.resolve()
197 flags_option
= ' '.join(flags
)
198 setup_dist_dir
= dist_dir
/ 'setup'
199 setup
= setup_dist_dir
/ 'Setup'
202 f
'--builddir={dist_dir}',
205 configure_args
= build_args
+ [
206 f
'--package-db={PKG_DB.resolve()}',
207 f
'--prefix={prefix}',
208 f
'--bindir={BINDIR.resolve()}',
209 f
'--extra-prog-path={BINDIR.resolve()}',
210 f
'--with-compiler={ghc.ghc_path}',
211 f
'--with-hc-pkg={ghc.ghc_pkg_path}',
212 f
'--with-hsc2hs={ghc.hsc2hs_path}',
213 f
'--flags={flags_option}',
217 def check_call(args
: List
[str]) -> None:
218 subprocess_run(args
, cwd
=sdist_dir
, check
=True)
220 setup_dist_dir
.mkdir(parents
=True, exist_ok
=True)
222 # Note: we pass -i so GHC doesn't look for anything else
223 # This should be fine for cabal-install dependencies.
224 check_call([str(ghc
.ghc_path
), '--make', '-package-env=-', '-i', f
'-odir={setup_dist_dir}', f
'-hidir={setup_dist_dir}', '-o', setup
, 'Setup'])
225 check_call([setup
, 'configure'] + configure_args
)
226 check_call([setup
, 'build'] + build_args
)
227 check_call([setup
, 'install'] + build_args
)
229 def hash_file(h
, f
: BinaryIO
) -> SHA256Hash
:
233 return SHA256Hash(h
.hexdigest())
238 # Cabal plan.json representation
239 UnitId
= NewType('UnitId', str)
240 PlanUnit
= NewType('PlanUnit', dict)
242 def bootstrap(info
: BootstrapInfo
, ghc
: Compiler
) -> None:
243 if not PKG_DB
.exists():
244 print(f
'Creating package database {PKG_DB}')
245 PKG_DB
.parent
.mkdir(parents
=True, exist_ok
=True)
246 subprocess_run([ghc
.ghc_pkg_path
, 'init', PKG_DB
])
248 for dep
in info
.builtin
:
249 check_builtin(dep
, ghc
)
251 for dep
in info
.dependencies
:
252 install_dep(dep
, ghc
)
255 #######################################################################
259 r
= r
.strip() # release
260 if i
== '': return 'linux'
261 else: return f
"{i}-{r}".lower()
264 # https://en.wikipedia.org/wiki/MacOS_version_history#Releases
265 if macver
.startswith('10.12.'): return 'sierra'
266 if macver
.startswith('10.13.'): return 'high-sierra'
267 if macver
.startswith('10.14.'): return 'mojave'
268 if macver
.startswith('10.15.'): return 'catalina'
269 if macver
.startswith('11.0.'): return 'big-sur'
272 def archive_name(cabalversion
):
273 # Ask platform information
274 machine
= platform
.machine()
275 if machine
== '': machine
= "unknown"
277 system
= platform
.system().lower()
278 if system
== '': system
= "unknown"
281 if system
== 'linux':
283 i
= subprocess_run(['lsb_release', '-si'], stdout
=subprocess
.PIPE
, encoding
='UTF-8')
284 r
= subprocess_run(['lsb_release', '-sr'], stdout
=subprocess
.PIPE
, encoding
='UTF-8')
285 version
= linuxname(i
.stdout
, r
.stdout
)
288 with
open('/etc/alpine-release') as f
:
289 alpinever
= f
.read().strip()
290 return f
'alpine-{alpinever}'
293 elif system
== 'darwin':
294 version
= 'darwin-' + macname(platform
.mac_ver()[0])
295 elif system
== 'freebsd':
296 version
= 'freebsd-' + platform
.release().lower()
298 return f
'cabal-install-{cabalversion}-{machine}-{version}'
300 def make_distribution_archive(cabal_path
):
303 print(f
'Creating distribution tarball')
305 # Get bootstrapped cabal version
306 # This also acts as smoke test
307 p
= subprocess_run([cabal_path
, '--numeric-version'], stdout
=subprocess
.PIPE
, check
=True, encoding
='UTF-8')
308 cabalversion
= p
.stdout
.replace('\n', '').strip()
311 basename
= ARTIFACTS
.resolve() / (archive_name(cabalversion
) + '-bootstrapped')
313 # In temporary directory, create a directory which we will archive
314 tmpdir
= TMPDIR
.resolve()
315 tmpdir
.mkdir(parents
=True, exist_ok
=True)
317 rootdir
= Path(tempfile
.mkdtemp(dir=tmpdir
))
318 shutil
.copy(cabal_path
, rootdir
/ 'cabal')
322 if platform
.system() == 'Windows': fmt
= 'zip'
323 archivename
= shutil
.make_archive(basename
, fmt
, rootdir
)
327 def fetch_from_plan(plan
: FetchPlan
, output_dir
: Path
):
329 output_dir
.mkdir(parents
=True, exist_ok
=True)
332 output_path
= output_dir
/ path
334 sha
= plan
[path
].sha256
335 if not output_path
.exists():
336 print(f
'Fetching {url}...')
337 with urllib
.request
.urlopen(url
, timeout
= 10) as resp
:
338 shutil
.copyfileobj(resp
, output_path
.open('wb'))
339 verify_sha256(sha
, output_path
)
341 def gen_fetch_plan(info
: BootstrapInfo
) -> FetchPlan
:
343 for dep
in info
.dependencies
:
344 if not(dep
.package
in local_packages
):
345 sources_dict
[f
"{dep.package}-{dep.version}.tar.gz"] = FetchInfo(package_url(dep
.package
, dep
.version
), dep
.src_sha256
)
346 if dep
.revision
is not None:
347 sources_dict
[f
"{dep.package}.cabal"] = FetchInfo(package_cabal_url(dep
.package
, dep
.version
, dep
.revision
), dep
.cabal_sha256
)
350 def find_ghc(compiler
) -> Compiler
:
352 path
= shutil
.which('ghc')
354 raise ValueError("Couldn't find ghc in PATH")
355 ghc
= Compiler(Path(path
))
357 ghc
= Compiler(compiler
)
361 parser
= argparse
.ArgumentParser(
362 description
="bootstrapping utility for cabal-install.",
364 formatter_class
= argparse
.RawDescriptionHelpFormatter
)
365 parser
.add_argument('-d', '--deps', type=Path
,
366 help='bootstrap dependency file')
367 parser
.add_argument('-w', '--with-compiler', type=Path
,
369 parser
.add_argument('-s', '--bootstrap-sources', type=Path
,
370 help='path to prefetched bootstrap sources archive')
371 parser
.add_argument('--archive', dest
='want_archive', action
='store_true')
372 parser
.add_argument('--no-archive', dest
='want_archive', action
='store_false')
373 parser
.set_defaults(want_archive
=True)
375 subparsers
= parser
.add_subparsers(dest
="command")
377 parser_fetch
= subparsers
.add_parser('build', help='build cabal-install (default)')
379 parser_fetch
= subparsers
.add_parser('fetch', help='fetch all required sources from Hackage (for offline builds)')
380 parser_fetch
.add_argument('-o','--output', type=Path
, default
='bootstrap-sources')
382 args
= parser
.parse_args()
385 DO NOT use this script if you have another recent cabal-install available.
386 This script is intended only for bootstrapping cabal-install on new
390 ghc
= find_ghc(args
.with_compiler
)
392 sources_fmt
= 'gztar'
393 if platform
.system() == 'Windows': sources_fmt
= 'zip'
395 if args
.deps
is None:
396 # We have a tarball with all the required information, unpack it
397 if args
.bootstrap_sources
is not None:
398 print(f
'Unpacking {args.bootstrap_sources} to {TARBALLS}')
399 shutil
.unpack_archive(args
.bootstrap_sources
.resolve(), TARBALLS
, sources_fmt
)
400 args
.deps
= TARBALLS
/ 'plan-bootstrap.json'
401 print(f
"using plan-bootstrap.json ({args.deps}) from {args.bootstrap_sources}")
403 print("The bootstrap script requires a bootstrap plan JSON file.")
404 print("See bootstrap/README.md for more information.")
407 info
= read_bootstrap_info(args
.deps
)
409 if args
.command
== 'fetch':
410 plan
= gen_fetch_plan(info
)
412 print(f
'Fetching sources to bootstrap cabal-install with GHC {ghc.version} at {ghc.ghc_path}...')
414 # In temporary directory, create a directory which we will archive
415 tmpdir
= TMPDIR
.resolve()
416 tmpdir
.mkdir(parents
=True, exist_ok
=True)
418 rootdir
= Path(tempfile
.mkdtemp(dir=tmpdir
))
420 fetch_from_plan(plan
, rootdir
)
422 shutil
.copyfile(args
.deps
, rootdir
/ 'plan-bootstrap.json')
424 archivename
= shutil
.make_archive(args
.output
, sources_fmt
, root_dir
=rootdir
)
427 Bootstrap sources saved to {archivename}
429 Use these with the command:
431 bootstrap.py -w {ghc.ghc_path} -s {archivename}
434 else: # 'build' command (default behaviour)
436 print(f
'Bootstrapping cabal-install with GHC {ghc.version} at {ghc.ghc_path}...')
438 if args
.bootstrap_sources
is None:
439 plan
= gen_fetch_plan(info
)
440 fetch_from_plan(plan
, TARBALLS
)
443 cabal_path
= (BINDIR
/ 'cabal').resolve()
446 Bootstrapping finished!
448 The resulting cabal-install executable can be found at
453 if args
.want_archive
:
454 dist_archive
= make_distribution_archive(cabal_path
)
457 The cabal-install executable has been archived for distribution in
463 You now should use this to build a full cabal-install distribution
467 def subprocess_run(args
, **kwargs
):
468 "Like subprocess.run, but also print what we run"
470 args_str
= ' '.join(map(str, args
))
473 extras
+= f
' cwd={kwargs["cwd"]}'
474 print(f
'bootstrap: running{extras} {args_str}')
476 return subprocess
.run(args
, **kwargs
)
478 if __name__
== '__main__':