Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sage
Path: blob/develop/build/sage_bootstrap/app.py
4052 views
1
# -*- coding: utf-8 -*-
2
"""
3
Controller for the commandline actions
4
5
AUTHORS:
6
7
- Volker Braun (2016): initial version
8
- Thierry Monteil (2022): clean option to remove outdated source tarballs
9
"""
10
11
12
# ****************************************************************************
13
# Copyright (C) 2016 Volker Braun <[email protected]>
14
# 2020-2024 Matthias Koeppe
15
# 2022 Thierry Monteil
16
# 2024 Marc Culler
17
#
18
# This program is free software: you can redistribute it and/or modify
19
# it under the terms of the GNU General Public License as published by
20
# the Free Software Foundation, either version 2 of the License, or
21
# (at your option) any later version.
22
# https://www.gnu.org/licenses/
23
# ****************************************************************************
24
25
26
import os
27
import re
28
import logging
29
log = logging.getLogger()
30
31
from collections import defaultdict
32
33
from sage_bootstrap.package import Package
34
from sage_bootstrap.tarball import Tarball, FileNotMirroredError
35
from sage_bootstrap.updater import ChecksumUpdater, PackageUpdater
36
from sage_bootstrap.creator import PackageCreator
37
from sage_bootstrap.pypi import PyPiVersion, PyPiNotFound, PyPiError
38
from sage_bootstrap.fileserver import FileServer
39
from sage_bootstrap.expand_class import PackageClass
40
from sage_bootstrap.env import SAGE_DISTFILES
41
42
43
# Approximation of https://peps.python.org/pep-0508/#names dependency specification
44
dep_re = re.compile('^ *([-A-Z0-9._]+)', re.IGNORECASE)
45
46
47
class Application(object):
48
49
def config(self):
50
"""
51
Print the configuration
52
53
$ sage --package config
54
Configuration:
55
* log = info
56
* interactive = True
57
"""
58
log.debug('Printing configuration')
59
from sage_bootstrap.config import Configuration
60
print(Configuration())
61
62
def list_cls(self, *package_classes, **filters):
63
"""
64
Print a list of all available packages
65
66
$ sage --package list
67
4ti2
68
_bootstrap
69
_develop
70
[...]
71
zipp
72
73
$ sage -package list --has-file=spkg-configure.m4 :experimental:
74
perl_term_readline_gnu
75
76
$ sage -package list --has-file=spkg-configure.m4 --has-file=distros/debian.txt
77
4ti2
78
_develop
79
_prereq
80
[...]
81
zeromq
82
"""
83
log.debug('Listing packages')
84
pc = PackageClass(*package_classes, **filters)
85
for pkg_name in pc.names:
86
print(pkg_name)
87
88
def properties(self, *package_classes, **kwds):
89
"""
90
Show the properties of given packages
91
92
$ sage --package properties --format shell maxima
93
path_maxima='........./build/pkgs/maxima'
94
version_with_patchlevel_maxima='5.46.0'
95
type_maxima='standard'
96
source_maxima='normal'
97
trees_maxima='SAGE_LOCAL'
98
"""
99
props = kwds.pop('props', ['path', 'version_with_patchlevel', 'type', 'source', 'trees', 'purl'])
100
format = kwds.pop('format', 'plain')
101
log.debug('Looking up properties')
102
pc = PackageClass(*package_classes)
103
for package_name in pc.names:
104
package = Package(package_name)
105
if len(pc.names) > 1:
106
if format == 'plain':
107
print("{0}:".format(package_name))
108
for p in props:
109
value = getattr(package, p)
110
if value is None:
111
if p.startswith('version'):
112
value = 'none'
113
else:
114
value = ''
115
if format == 'plain':
116
print(" {0:28} {1}".format(p + ":", value))
117
else:
118
print("{0}_{1}='{2}'".format(p, package_name, value))
119
120
def dependencies(self, *package_classes, **kwds):
121
"""
122
Find the dependencies given package names
123
124
$ sage --package dependencies maxima --runtime --order-only --format=shell
125
order_only_deps_maxima='info'
126
runtime_deps_maxima='ecl'
127
"""
128
types = kwds.pop('types', None)
129
format = kwds.pop('format', 'plain')
130
log.debug('Looking up dependencies')
131
pc = PackageClass(*package_classes)
132
if format in ['plain', 'rst']:
133
if types is None:
134
typesets = [['order_only', 'runtime']]
135
else:
136
typesets = [[t] for t in types]
137
elif format == 'shell':
138
if types is None:
139
types = ['order_only', 'optional', 'runtime', 'check']
140
typesets = [[t] for t in types]
141
else:
142
raise ValueError('format must be one of "plain", "rst", and "shell"')
143
144
for package_name in pc.names:
145
package = Package(package_name)
146
if len(pc.names) > 1:
147
if format == 'plain':
148
print("{0}:".format(package_name))
149
indent1 = " "
150
elif format == 'rst':
151
print("\n{0}\n{1}\n".format(package_name, "~" * len(package_name)))
152
indent1 = ""
153
else:
154
indent1 = ""
155
156
for typeset in typesets:
157
if len(typesets) > 1:
158
if format == 'plain':
159
print(indent1 + "{0}: ".format('/'.join(typeset)))
160
indent2 = indent1 + " "
161
elif format == 'rst':
162
print("\n" + indent1 + ".. tab:: {0}\n".format('/'.join(typeset)))
163
indent2 = indent1 + " "
164
else:
165
indent2 = indent1
166
167
deps = []
168
for t in typeset:
169
deps.extend(getattr(package, 'dependencies_' + t))
170
deps = sorted(set(deps))
171
172
if format in ['plain', 'rst']:
173
for dep in deps:
174
if '/' in dep:
175
# Suppress dependencies on source files, e.g. of the form $(SAGE_ROOT)/..., $(SAGE_SRC)/...
176
continue
177
if dep == 'FORCE':
178
# Suppress FORCE
179
continue
180
if dep.startswith('$('):
181
# Dependencies like $(BLAS)
182
print(indent2 + "- {0}".format(dep))
183
elif format == 'rst' and Package(dep).has_file('SPKG.rst'):
184
# This RST label is set in src/doc/bootstrap
185
print(indent2 + "- :ref:`spkg_{0}`".format(dep))
186
else:
187
print(indent2 + "- {0}".format(dep))
188
elif format == 'shell':
189
# We single-quote the values because dependencies
190
# may contain Makefile variable substitutions
191
print("{0}_deps_{1}='{2}'".format(t, package_name, ' '.join(deps)))
192
193
def name(self, tarball_filename):
194
"""
195
Find the package name given a tarball filename
196
197
$ sage --package name pari-2.8-1564-gdeac36e.tar.gz
198
pari
199
"""
200
log.debug('Looking up package name for %s', tarball_filename)
201
tarball = Tarball(os.path.basename(tarball_filename))
202
print(tarball.package.name)
203
204
def tarball(self, package_name):
205
"""
206
Find the tarball filename given a package name
207
208
$ sage --package tarball pari
209
pari-2.8-1564-gdeac36e.tar.gz
210
"""
211
log.debug('Looking up tarball name for %s', package_name)
212
package = Package(package_name)
213
print(package.tarball.filename)
214
215
def apropos(self, incorrect_name):
216
"""
217
Find up to 5 package names that are close to the given name
218
219
$ sage --package apropos python
220
Did you mean: cython, ipython, python2, python3, patch?
221
"""
222
log.debug('Apropos for %s', incorrect_name)
223
from sage_bootstrap.levenshtein import Levenshtein, DistanceExceeded
224
levenshtein = Levenshtein(5)
225
names = []
226
for pkg in Package.all():
227
try:
228
names.append([levenshtein(pkg.name, incorrect_name), pkg.name])
229
except DistanceExceeded:
230
pass
231
if names:
232
names = sorted(names)[:5]
233
print('Did you mean: {0}?'.format(', '.join(name[1] for name in names)))
234
else:
235
print('There is no package similar to {0}'.format(incorrect_name))
236
print('You can find further packages at http://files.sagemath.org/spkg/')
237
238
def commit(self, package_name, message=None):
239
"""
240
Commit the changes to the Sage source tree for the given package
241
"""
242
package = Package(package_name)
243
if message is None:
244
message = 'build/pkgs/{0}: Update to {1}'.format(package_name, package.version)
245
os.system('git commit -m "{0}" {1}'.format(message, package.path))
246
247
def update(self, package_name, new_version, url=None, commit=False):
248
"""
249
Update a package. This modifies the Sage sources.
250
251
$ sage --package update pari 2015 --url=http://localhost/pari/tarball.tgz
252
"""
253
log.debug('Updating %s to %s', package_name, new_version)
254
update = PackageUpdater(package_name, new_version)
255
if url is not None or update.package.tarball_upstream_url:
256
log.debug('Downloading %s', url)
257
update.download_upstream(url)
258
update.fix_checksum()
259
if commit:
260
self.commit(package_name)
261
262
def update_latest(self, package_name, commit=False):
263
"""
264
Update a package to the latest version. This modifies the Sage sources.
265
"""
266
pkg = Package(package_name)
267
if pkg.source not in ['normal', 'wheel']:
268
log.debug('update_latest can only update normal and wheel packages; %s is a %s package' % (pkg, pkg.source))
269
return
270
dist_name = pkg.distribution_name
271
if dist_name is None:
272
log.debug('%s does not have Python distribution info in version_requirements.txt' % pkg)
273
return
274
if pkg.tarball_pattern.endswith('.whl'):
275
source = 'wheel'
276
else:
277
source = 'pypi'
278
try:
279
pypi = PyPiVersion(dist_name, source=source)
280
except PyPiNotFound:
281
log.debug('%s is not a pypi package', dist_name)
282
return
283
else:
284
pypi.update(pkg)
285
if commit:
286
self.commit(package_name)
287
288
def update_latest_cls(self, package_name_or_class, commit=False):
289
exclude = [
290
'cypari' # Name conflict
291
]
292
# Restrict to normal Python packages
293
pc = PackageClass(package_name_or_class, has_files=['checksums.ini', 'version_requirements.txt'])
294
if not pc.names:
295
log.warn('nothing to do (does not name a normal Python package)')
296
for package_name in sorted(pc.names):
297
if package_name in exclude:
298
log.debug('skipping %s because of pypi name collision', package_name)
299
continue
300
try:
301
self.update_latest(package_name, commit=commit)
302
except PyPiError as e:
303
log.warn('updating %s failed: %s', package_name, e)
304
305
def download(self, package_name, allow_upstream=False):
306
"""
307
Download a package
308
309
$ sage --package download pari
310
Using cached file /home/vbraun/Code/sage.git/upstream/pari-2.8-2044-g89b0f1e.tar.gz
311
/home/vbraun/Code/sage.git/upstream/pari-2.8-2044-g89b0f1e.tar.gz
312
"""
313
log.debug('Downloading %s', package_name)
314
package = Package(package_name)
315
package.tarball.download(allow_upstream=allow_upstream)
316
print(package.tarball.upstream_fqn)
317
318
def download_cls(self, *package_classes, **kwds):
319
"""
320
Download a package or a class of packages
321
"""
322
allow_upstream = kwds.pop('allow_upstream', False)
323
on_error = kwds.pop('on_error', 'stop')
324
has_files = list(kwds.pop('has_files', []))
325
pc = PackageClass(*package_classes, has_files=has_files + ['checksums.ini'], **kwds)
326
327
def download_with_args(package):
328
try:
329
self.download(package, allow_upstream=allow_upstream)
330
except FileNotMirroredError:
331
if on_error == 'stop':
332
raise
333
elif on_error == 'warn':
334
log.warn('Unable to download tarball of %s', package)
335
else:
336
raise ValueError('on_error must be one of "stop" and "warn"')
337
pc.apply(download_with_args)
338
339
def upload(self, package_name):
340
"""
341
Upload a package to the Sage mirror network
342
343
$ sage --package upload pari
344
Uploading /home/vbraun/Code/sage.git/upstream/pari-2.8-2044-g89b0f1e.tar.gz
345
"""
346
package = Package(package_name)
347
if not os.path.exists(package.tarball.upstream_fqn):
348
log.debug('Skipping %s because there is no local tarball', package_name)
349
return
350
if not package.tarball.is_distributable():
351
log.info('Skipping %s because the tarball is marked as not distributable',
352
package_name)
353
return
354
log.info('Uploading %s', package.tarball.upstream_fqn)
355
fs = FileServer()
356
fs.upload(package)
357
358
def upload_cls(self, package_name_or_class):
359
pc = PackageClass(package_name_or_class)
360
pc.apply(self.upload)
361
fs = FileServer()
362
log.info('Publishing')
363
fs.publish()
364
365
def fix_checksum_cls(self, *package_classes):
366
"""
367
Fix the checksum of packages
368
369
$ sage --package fix-checksum
370
"""
371
pc = PackageClass(*package_classes, has_files=['checksums.ini'])
372
pc.apply(self.fix_checksum)
373
374
def fix_checksum(self, package_name):
375
"""
376
Fix the checksum of a package
377
378
$ sage --package fix-checksum pari
379
Updating checksum of pari-2.8-2044-g89b0f1e.tar.gz
380
"""
381
log.debug('Correcting the checksum of %s', package_name)
382
update = ChecksumUpdater(package_name)
383
pkg = update.package
384
if not pkg.tarball_filename:
385
log.info('Ignoring {0} because it is not a normal package'.format(package_name))
386
return
387
if not os.path.exists(pkg.tarball.upstream_fqn):
388
log.info('Ignoring {0} because tarball is not cached'.format(package_name))
389
return
390
if pkg.tarball.checksum_verifies(force_sha256=True):
391
log.info('Checksum of {0} (tarball {1}) unchanged'.format(package_name, pkg.tarball_filename))
392
else:
393
log.info('Updating checksum of {0} (tarball {1})'.format(package_name, pkg.tarball_filename))
394
update.fix_checksum()
395
396
def create(self, package_name, version=None, tarball=None, pkg_type=None, upstream_url=None,
397
description=None, license=None, upstream_contact=None, pypi=False, source=None,
398
dependencies=None):
399
"""
400
Create a package
401
402
$ sage --package create foo --version 1.3 --tarball FoO-VERSION.tar.gz --type experimental
403
404
$ sage --package create scikit_spatial --pypi --type optional
405
406
$ sage --package create torch --pypi --source pip --type optional
407
408
$ sage --package create jupyterlab_markup --pypi --source wheel --type optional
409
"""
410
if package_name.startswith('pypi/'):
411
package_name = 'pkg:' + package_name
412
if package_name.startswith('pkg:pypi/'):
413
pypi = True
414
package_name = package_name[len('pkg:pypi/'):].lower().replace('-', '_').replace('.', '_')
415
elif '-' in package_name:
416
raise ValueError('package names must not contain dashes, use underscore instead')
417
if pypi:
418
if source is None:
419
try:
420
if PyPiVersion(package_name, source='wheel').tarball.endswith('-none-any.whl'):
421
source = 'wheel'
422
else:
423
source = 'normal'
424
except PyPiError:
425
source = 'normal'
426
pypi_version = PyPiVersion(package_name, source=source)
427
if source == 'normal':
428
if not tarball:
429
# Guess the general format of the tarball name.
430
tarball = pypi_version.tarball.replace(pypi_version.version, 'VERSION')
431
if not version:
432
version = pypi_version.version
433
# Use a URL from files.pythonhosted.org instead of the specific URL received from the PyPI query
434
# because it follows a simple pattern.
435
upstream_url = 'https://files.pythonhosted.org/packages/source/{0:1.1}/{0}/{1}'.format(package_name, tarball)
436
elif source == 'wheel':
437
if not tarball:
438
tarball = pypi_version.tarball.replace(pypi_version.version, 'VERSION')
439
if not tarball.endswith('-none-any.whl'):
440
raise ValueError('Only platform-independent wheels can be used for wheel packages, got {0}'.format(tarball))
441
if not version:
442
version = pypi_version.version
443
if dependencies is None:
444
log.info('Requires-Python: {0}'.format(pypi_version.requires_python))
445
requires_dist = pypi_version.requires_dist
446
if requires_dist:
447
dependencies = []
448
for item in requires_dist:
449
if "extra ==" in item:
450
continue
451
try:
452
dep = dep_re.match(item).groups()[0].strip()
453
except Exception:
454
continue
455
dep = 'pkg:pypi/' + dep
456
try:
457
dep = Package(dep).name
458
except ValueError:
459
self.create(dep, pkg_type=pkg_type)
460
dep = Package(dep).name
461
dependencies.append(dep)
462
upstream_url = 'https://files.pythonhosted.org/packages/{2}/{0:1.1}/{0}/{1}'.format(package_name, tarball, pypi_version.python_version)
463
if not description:
464
description = pypi_version.summary
465
if not license:
466
license = pypi_version.license
467
if not upstream_contact:
468
upstream_contact = pypi_version.package_url
469
if upstream_url and not tarball:
470
tarball = upstream_url.rpartition('/')[2]
471
if tarball and source is None:
472
source = 'normal'
473
if tarball and not pkg_type:
474
# If we set a tarball, also make sure to create a "type" file,
475
# so that subsequent operations (downloading of tarballs) work.
476
pkg_type = 'optional'
477
log.debug('Creating %s: %s, %s, %s', package_name, version, tarball, pkg_type)
478
creator = PackageCreator(package_name)
479
if version:
480
creator.set_version(version)
481
if pkg_type:
482
creator.set_type(pkg_type)
483
if description or license or upstream_contact:
484
creator.set_description(description, license, upstream_contact)
485
if pypi or source == 'pip':
486
creator.set_python_data_and_scripts(pypi_package_name=pypi_version.name, source=source,
487
dependencies=dependencies)
488
if tarball:
489
creator.set_tarball(tarball, upstream_url)
490
if upstream_url and version:
491
update = PackageUpdater(package_name, version)
492
update.download_upstream()
493
else:
494
update = ChecksumUpdater(package_name)
495
update.fix_checksum()
496
497
def clean(self):
498
"""
499
Remove outdated source tarballs from the upstream/ directory
500
501
$ sage --package clean
502
42 files were removed from the .../upstream directory
503
"""
504
log.debug('Cleaning upstream/ directory')
505
package_names = PackageClass(':all:').names
506
keep = [Package(package_name).tarball.filename for package_name in package_names]
507
count = 0
508
for filename in os.listdir(SAGE_DISTFILES):
509
if filename not in keep:
510
filepath = os.path.join(SAGE_DISTFILES, filename)
511
if os.path.isfile(filepath):
512
log.debug('Removing file {}'.format(filepath))
513
os.remove(filepath)
514
count += 1
515
print('{} files were removed from the {} directory'.format(count, SAGE_DISTFILES))
516
517
def metrics_cls(self, *package_classes):
518
"""
519
Show the metrics of given packages
520
521
$ sage --package metrics :standard:
522
has_file_distros_arch_txt=131
523
has_file_distros_conda_txt=216
524
has_file_distros_debian_txt=125
525
has_file_distros_fedora_txt=138
526
has_file_distros_gentoo_txt=181
527
has_file_distros_homebrew_txt=61
528
has_file_distros_macports_txt=129
529
has_file_distros_nix_txt=51
530
has_file_distros_opensuse_txt=146
531
has_file_distros_slackware_txt=25
532
has_file_distros_void_txt=184
533
has_file_patches=35
534
has_file_spkg_check=59
535
has_file_spkg_configure_m4=222
536
has_file_spkg_install=198
537
has_tarball_upstream_url=231
538
line_count_file_patches=22561
539
line_count_file_spkg_check=402
540
line_count_file_spkg_configure_m4=2792
541
line_count_file_spkg_install=2960
542
packages=272
543
type_standard=272
544
"""
545
log.debug('Computing metrics')
546
metrics = defaultdict(int)
547
pc = PackageClass(*package_classes)
548
for package_name in pc.names:
549
package = Package(package_name)
550
metrics['packages'] += 1
551
metrics['type_' + package.type] += 1
552
for filenames in [['spkg-configure.m4'],
553
['spkg-install', 'spkg-install.in'],
554
['spkg-check', 'spkg-check.in'],
555
['distros/arch.txt'],
556
['distros/conda.txt'],
557
['distros/debian.txt'],
558
['distros/fedora.txt'],
559
['distros/gentoo.txt'],
560
['distros/homebrew.txt'],
561
['distros/macports.txt'],
562
['distros/nix.txt'],
563
['distros/opensuse.txt'],
564
['distros/slackware.txt'],
565
['distros/void.txt'],
566
['patches']]:
567
key = filenames[0].replace('.', '_').replace('-', '_').replace('/', '_')
568
metrics['has_file_' + key] += int(any(package.has_file(filename)
569
for filename in filenames))
570
if not key.startswith('distros_'):
571
metrics['line_count_file_' + key] += sum(package.line_count_file(filename)
572
for filename in filenames)
573
metrics['has_tarball_upstream_url'] += int(bool(package.tarball_upstream_url))
574
for key, value in sorted(metrics.items()):
575
print('{0}={1}'.format(key, value))
576
577