forked from cameron-simpson/css
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdistinfo.py
More file actions
executable file
·2083 lines (1929 loc) · 66.1 KB
/
Copy pathdistinfo.py
File metadata and controls
executable file
·2083 lines (1929 loc) · 66.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
#
# pylint: disable=too-many-lines
''' My Python package release script.
'''
from collections import defaultdict, namedtuple
from configparser import ConfigParser
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from fnmatch import fnmatch
from functools import cache, cached_property
from getopt import GetoptError
from glob import glob
import importlib
import os
import os.path
from os.path import (
basename,
dirname,
exists as existspath,
isdir as isdirpath,
isfile as isfilepath,
join as joinpath,
normpath,
relpath,
splitext,
)
from pprint import pprint
import re
from shutil import rmtree
import sys
from types import SimpleNamespace
from icontract import ensure, require
import tomli_w
from typeguard import typechecked
from cs.ansi_colour import colourise
from cs.cmdutils import BaseCommand, popopts
from cs.context import stackattrs
from cs.dateutils import isodate
from cs.fs import atomic_directory, scandirpaths
from cs.fstags import FSTags
from cs.lex import (
cutsuffix,
get_identifier,
get_dotted_identifier,
is_dotted_identifier,
is_identifier,
)
from cs.logutils import error, warning, info, status
from cs.numeric import intif
from cs.pfx import Pfx, pfx_call, pfx_method
from cs.progress import progressbar
from cs.psutils import pipefrom as ps_pipefrom, pipeto as ps_pipeto, run
from cs.py.doc import module_doc
from cs.py.modules import direct_imports
from cs.resources import RunState, uses_runstate
from cs.tagset import TagFile, tag_or_tag_value
from cs.upd import Upd, print, uses_upd
from cs.vcs import VCS
from cs.vcs.hg import VCS_Hg
# Add flake8 fixes -
# easy fixes, plus no bare except:
# I don't know the intention so I made it except BaseException: -
# Jonathan Hyry <jonathan.hyry@outlook.com> - 2026-03-29
def main(argv=None):
''' Main command line.
'''
return CSReleaseCommand(argv).run()
URL_PYPI_PROD = 'https://pypi.python.org/pypi'
URL_PYPI_TEST = 'https://test.pypi.org/legacy/'
# published URL
URL_BASE = 'https://bitbucket.org/cameron_simpson/css/src/tip/'
MIRROR_SRCBASE = 'https://github.com/cameron-simpson/css/blob/main'
DISTINFO_CLASSIFICATION = {
"Programming Language": "Python",
"Development Status": "4 - Beta",
"Intended Audience": "Developers",
"Operating System": "OS Independent",
"Topic": "Software Development :: Libraries :: Python Modules",
"License":
"OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
}
# the top level TagFile containing package state information
PKG_TAGS = 'pkg_tags'
# the path from the top level to the package files
PYLIBTOP = 'lib/python'
SVGLIBTOP = 'lib/svg'
# the prefix of interesting packages
MODULE_PREFIX = 'cs.'
TAG_PYPI_RELEASE = 'pypi.release'
# defaults for packages without their own specifics
DISTINFO_DEFAULTS = {
'urls': {
'Monorepo Hg/Mercurial Mirror':
'https://hg.sr.ht/~cameron-simpson/css',
'Monorepo Git Mirror':
'https://github.com/cameron-simpson/css',
'MonoRepo Commits':
'https://bitbucket.org/cameron_simpson/css/commits/branch/main',
},
}
re_RELEASE_TAG = re.compile(
# name - YYYYMMDD [.n]
r'([a-z][^-]*)-(2[0-9][0-9][0-9][01][0-9][0-3][0-9](\.[1-9]\d*)?)$'
)
class ReleaseTag(namedtuple('ReleaseTag', 'name version')):
''' A parsed version of one of my release tags,
which have the form *package_name*`-`*version*.
'''
@classmethod
def from_vcstag(cls, vcstag):
''' Create a new `ReleaseTag` from a VCS tag.
'''
name, version = vcstag.split('-', 1)
return cls(name, version)
@classmethod
def today(cls, name):
''' Basic release tag for today, without any `.`*n* suffix.
'''
return cls(name, isodate(dashed=False))
@property
def vcstag(self):
''' The VCS tag for this `(name,version)` pair.
'''
return self.name + '-' + self.version
@ensure(lambda result: isinstance(result, ReleaseTag))
@ensure(
lambda result: result.version == isodate(dashed=False) or result.version.
startswith(isodate(dashed=False) + '.')
)
@ensure(lambda self, result: self.version < result.version)
def next(self):
''' Compute the next `ReleaseTag` after the current one.
'''
today = type(self).today(self.name)
if self.version < today.version:
return today
current_version = self.version
if '.' in current_version:
_, seqpart = current_version.split('.', 1)
next_seq = int(seqpart) + 1
else:
next_seq = 1
version = today.version + '.' + str(next_seq)
return type(self)(self.name, version)
class ModuleRequirement(namedtuple('ModuleRequirement',
'module_name op requirements modules')):
''' A parsed version of a module requirement string
such as `'cs.upd>=multiline'` or `'cs.obj>=20200716'`.
Attributes:
* `module_name`: the name of the module or package
* `op`: the relationship to the requirements,
supporting `'='` and `'>='`;
`None` if only the name is present
* `requirements`: a list of the requirement terms,
broken out on commas in the requirements part
* `modules`: a references to the `Modules` instance used to track module information
'''
@classmethod
@pfx_method
def from_requirement(cls, requirement_spec, modules):
''' Parse a requirement string, return a `ModuleRequirement`.
'''
with Pfx(requirement_spec):
module_name, offset = get_dotted_identifier(
requirement_spec, extras='_-'
)
if not module_name:
raise ValueError('module_name is not a dotted identifier')
if requirement_spec.startswith('[', offset):
close_pos = requirement_spec.find(']', offset + 1)
if close_pos > offset:
offset = close_pos + 1
if offset == len(requirement_spec):
op = None
else:
for op in '>=', '=', None:
if op is None:
raise ValueError(
"no valid op after module_name at %r" %
(requirement_spec[offset:],)
)
if requirement_spec.startswith(op, offset):
offset += len(op)
break
if op is not None and offset == len(requirement_spec):
raise ValueError("no requirements after op %r" % (op,))
requirements = [
req for req in map(str.strip, requirement_spec[offset:].split(','))
if req
]
return cls(
module_name=module_name,
op=op,
requirements=requirements,
modules=modules
)
@pfx_method
def resolve(self):
''' Return a requirement string,
either `self.module_name` if `self.op` is `None`
or *module_name*{`=`,`>=`}*version*
satisfying the versions and features in `self.requirements`.
'''
try:
pkg = self.modules[self.module_name]
except KeyError as e:
warning("cannot resolve self.module_name=%r: %s", self.module_name, e)
print("sys.path")
for p in sys.path:
print(" ", p)
raise
if self.op is None:
pkg_pypi_version = pkg.latest_pypi_version
return (
f'{self.module_name}>={pkg_pypi_version}'
if pkg_pypi_version else self.module_name
)
release_versions = set()
feature_set = set()
for requirement in self.requirements:
with Pfx("%r", requirement):
feature_name, _ = get_identifier(requirement)
if feature_name == requirement:
feature_set.add(feature_name)
else:
# not a bare identifier, presume release version
release_versions.add(requirement)
if feature_set:
release_version = pkg.release_with_features(feature_set)
if release_version is None:
raise ValueError(
f'no release version satifying feature set {feature_set!r}'
)
release_versions.add(release_version)
if not release_versions:
raise ValueError("no satisfactory release versions")
if self.op == '=':
if len(release_versions) > 1:
raise ValueError(
f'conflicting release versions for {self.op!r}: {release_versions!r}'
)
release_version = release_versions.pop()
elif self.op == '>=':
# the release versions are minima: pick their maximum
release_version = max(release_versions)
else:
raise RuntimeError(f'unimplemented op {self.op!r}')
return ''.join((self.module_name, self.op, release_version))
def release_tags(vcs):
''' Generator yielding the current release tags.
'''
for tag in vcs.tags():
m = re_RELEASE_TAG.match(tag)
if m:
yield tag
def clean_release_entry(entry):
'''Turn a VCS release log entry into some MarkDown.
'''
lines = list(
filter(
lambda line: (
line.strip() and line != 'Summary:' and not line.
startswith('Release information for ')
),
entry.strip().split('\n')
)
)
if len(lines) > 1:
# Multiple lines become a MarkDown bullet list.
lines = ['* ' + line for line in lines]
return '\n'.join(lines)
@uses_upd
def prompt(message, *, fin=None, fout=None, upd):
''' Prompt for a one line answer.
Return the answer with trailing newlines or carriage returns stripped.
'''
if fin is None:
fin = sys.stdin
if fout is None:
fout = sys.stderr
with upd.above():
print(message, end='? ', file=fout)
fout.flush()
return fin.readline().rstrip('\r\n')
def ask(message, fin=None, fout=None):
''' Prompt with yes/no question, return true if response is "y" or "yes".
'''
response = prompt(message, fin=fin, fout=fout)
response = response.rstrip().lower()
return response in ('y', 'yes')
@contextmanager
def pipefrom(*argv, **kw):
''' Context manager returning the standard output file object of a command.
'''
with ps_pipefrom(argv, **kw) as P:
yield P.stdout
class Modules(defaultdict):
''' An autopopulating dict of mod_name->Module.
'''
def __init__(self, *, vcs):
super().__init__()
self.vcs = vcs
def __missing__(self, mod_name):
assert isinstance(mod_name, str), "mod_name=%s:%r" % (
type(mod_name),
mod_name,
)
assert is_dotted_identifier(
mod_name, extras='_-'
), ("not a dotted identifier: %r" % (mod_name,))
try:
M = Module(mod_name, self)
except ValueError as e:
raise KeyError(mod_name) from e
self[mod_name] = M
return M
@pfx_method
def resolve_requirement(self, requirement_spec):
''' Resolve the `install_requires` specification `requirement_spec`
into a requirement string.
'''
with Pfx(requirement_spec):
mrq = ModuleRequirement.from_requirement(requirement_spec, modules=self)
requirement = mrq.resolve()
return requirement
# pylint: disable=too-many-public-methods
class Module:
''' Metadata about a Python module/package.
'''
# our names not forbidden by THIRD_PARTY_EXCLUSIONS
THIRD_PARTY_WHITELIST = 'cs.resources',
# create a mapping of third party names to their source
THIRD_PARTY_EXCLUSIONS = {}
for third_party_listpath in glob('3rd-party-conflicts/*'):
with Pfx(third_party_listpath):
with open(third_party_listpath) as f:
for lineno, line in enumerate(f, 1):
with Pfx(lineno):
pkgname = line.rstrip().replace('-', '_')
if not pkgname or pkgname.startswith('#'):
continue
THIRD_PARTY_EXCLUSIONS[pkgname] = (third_party_listpath, lineno)
def __init__(self, name, modules):
self.name = name
self._module = None
self.modules = modules
self._distinfo = None
self._checking = False
self._module_problems = None
if self.ismine() and not self.paths():
warning("no file paths for Module(%r)", name)
def __str__(self):
return "%s(%r)" % (type(self).__name__, self.name)
__repr__ = __str__
@property
def vcs(self):
''' The VCS from `self.modules.vcs`.
'''
return self.modules.vcs
@cached_property
@pfx_method(use_str=True)
def module(self):
''' The module for this package name.
'''
try:
M = pfx_call(importlib.import_module, self.name)
except (ImportError, ModuleNotFoundError) as e:
warning("import fails: %s", e._)
M = None
except (NameError, SyntaxError) as e:
warning("import fails: %s", e)
M = None
return M
@pfx_method(use_str=True)
def ismine(self):
''' Test whether this is one of my modules.
'''
return self.name.startswith(MODULE_PREFIX)
@pfx_method(use_str=True)
def isthirdparty(self):
''' Test whether this is a third party module.
'''
if self.ismine():
return False
if hasattr(sys, 'stdlib_module_names'):
# what about removed batteries? just not use them?
return not self.isstdlib()
M = self.module
if M is None:
return False
return '/site-packages/' in getattr(M, '__file__', '')
@pfx_method(use_str=True)
def isstdlib(self):
''' Test if this module exists in the stdlib.
'''
try:
stdlib_module_names = sys.stdlib_module_names
except AttributeError:
if self.ismine():
return False
if self.isthirdparty():
return False
return True
else:
return self.name.split('.')[0] in stdlib_module_names
@cached_property
@pfx_method(use_str=True)
def package_name(self):
''' The name of the package containing this module,
or `None` if this is not inside a package.
'''
M = self.module
if M is None:
return None
tested_name = cutsuffix(self.name, '_tests')
if tested_name is not self.name:
# foo_tests is considered part of foo
return self.modules[tested_name].package_name
try:
pkg_name = M.__package__
except AttributeError:
warning("self.module has no __package__: %r", sorted(dir(M)))
return None
if pkg_name != self.name and hasattr(M, 'DISTINFO'):
# standalone module like cs.py.modules (within cs.py)
return None
if self.ismine() and not pkg_name.startswith(MODULE_PREFIX):
# top level modules tend to be in the notional "cs" package,
# but they're just modules
return None
return pkg_name
@property
@pfx_method(use_str=True)
def package(self):
''' The python package module for this Module
(which may be the package module or some submodule).
'''
name = self.package_name
if name is None:
raise AttributeError("self.package_name is None")
return self.modules[name]
@property
def in_package(self):
''' Is this module part of a package?
'''
return self.package_name != self.name
@property
def is_package(self):
''' Is this module a package?
'''
pkg_name = self.package_name
return pkg_name is not None and pkg_name == self.name
@property
def needs_setuptools(self):
''' Do we require `setuptools` (to compile C extensions)?
'''
return bool(self.compute_distinfo().get('ext-modules'))
@property
def pkg_tags(self):
''' The `TagSet` for this package.
'''
return self.vcs.pkg_tagsets[self.name]
def feature_map(self):
''' Return a `dict` mapping package names to features.
'''
return dict(self.pkg_tags.get('features', {}))
@pfx_method
def named_features(self):
''' Return a set containing all the feature names in use by this `Module`.
'''
feature_map = self.feature_map()
all_feature_names = set()
for feature_names in feature_map.values():
for feature_name in feature_names:
if not is_identifier(feature_name):
warning("ignoring non-dentifier feature name: %r", feature_name)
else:
all_feature_names.add(feature_name)
return all_feature_names
@typechecked
def set_feature(self, feature_name: str, release_version: str):
''' Include `feature_name` in the features for release `release_version`.
'''
feature_map = self.feature_map()
release_features = set(feature_map.get(release_version, []))
release_features.add(feature_name)
feature_map[release_version] = sorted(release_features)
self.set_tag(
'features',
feature_map,
msg="features[%s]+%s" % (release_version, feature_name)
)
@pfx_method(use_str=True)
def release_features(self):
''' Yield `(release_version,feature_names)`
for all releases mentioned in the `features` tag.
'''
feature_map = self.feature_map()
yield from feature_map.items()
@pfx_method(use_str=True)
def release_feature_set(self):
''' Yield `(release_version,feature_sets)`
for all releases mentioned in the `features` tag
in release order.
This is an accumulation of features up to and including `release_version`.
Use of this method implies an assumption that features are
only added and never removed.
'''
feature_set = set()
for release_version, release_features in sorted(self.release_features()):
feature_set.update(release_features)
yield release_version, set(feature_set)
@pfx_method(use_str=True)
def features(self, release_version=None):
''' Return a set of the feature names for `release_version`,
default from the `pypi.release`.
This is an accumulation of the features from prior releases.
'''
tags = self.pkg_tags
if release_version is None:
release_version = tags.get('pypi.release')
if release_version is None:
raise ValueError("no pypi.release")
release_version = intif(float(release_version))
release_set = set()
for version, feature_set in sorted(self.release_feature_set()):
if intif(float(version)) > release_version:
break
release_set = feature_set
return release_set
@pfx_method(use_str=True)
def release_with_features(self, features):
''' Return the earliest release version containing all the named features.
Return `None` if no release has all the features.
'''
for version, feature_set in sorted(self.release_feature_set()):
if all(map(lambda feature: feature in feature_set, features)):
return version
return None
def save_pkg_tags(self):
''' Sync the package `Tag`s `TagFile`, return the pathname of the tag file.
'''
self.vcs.pkg_tagsets.save()
return self.vcs.pkg_tagsets.fspath
@tag_or_tag_value
def set_tag(self, tag_name, value, *, msg):
''' Set a tag value and commit the modified tag file.
'''
print("%s: set %s=%s" % (self.name, tag_name, value))
self.pkg_tags.set(tag_name, value)
self.save_pkg_tags()
self.vcs.commit(
f'{PKG_TAGS}: {self.name}: {msg + ": " if msg else ""}set {tag_name}={value!r} [IGNORE]',
PKG_TAGS
)
@cache
def release_tags(self):
''' Return the `ReleaseTag`s for this package.
'''
myname = self.name
return list(
filter(
lambda tag: tag.name == myname, (
ReleaseTag.from_vcstag(vcstag)
for vcstag in release_tags(self.vcs)
)
)
)
def release_log(self):
''' Generator yielding `(ReleaseTag,log_entry)`
for our release tags in reverse tag order (most recent first).
'''
return (
(ReleaseTag.from_vcstag(vcstag), entry)
for vcstag, entry in self.vcs.release_log(self.name + '-')
)
@property
@ensure(lambda result: result is None or isinstance(result, ReleaseTag))
@ensure(lambda self, result: result is None or result.name == self.name)
def latest(self):
''' The `ReleaseTag` of the latest release of this `Module`.
'''
tags = self.release_tags()
if not tags:
return None
return max(tags)
def next(self):
''' The next `ReleaseTag` after `self.latest`.
'''
latest = self.latest
return ReleaseTag.today() if latest is None else latest.next()
@property
def latest_pypi_version(self):
''' The last PyPI version.
'''
return self.pkg_tags.get(TAG_PYPI_RELEASE)
@latest_pypi_version.setter
def latest_pypi_version(self, new_version):
''' Update the last PyPI version.
'''
self.set_tag(TAG_PYPI_RELEASE, new_version, msg='update PyPI release')
def compute_doc(self, all_class_names=True):
''' Compute the components of the documentation.
Parameters:
* `all_class_names`: optional flag, default `False`;
if true list all methods, otherwise constrain the listing
to `__new__` and `__init__`.
'''
# break out the release log and format it
releases = list(self.release_log())
preamble_md = None
postamble_parts = []
if releases:
release_tag, release_entry = releases[0]
release_entry = clean_release_entry(release_entry)
preamble_md = f'*Latest release {release_tag.version}*:\n{release_entry}'
for release_tag, release_entry in releases:
postamble_parts.append(
f'*Release {release_tag.version}*:\n{clean_release_entry(release_entry)}'
)
full_doc = module_doc(
self.module,
method_names=None if all_class_names else ('__new__', '__init__')
)
# split the module documentation after the opening paragraph
try:
doc_head, doc_tail = full_doc.split('\n\n', 1)
except ValueError:
doc_head = full_doc
doc_tail = ''
# compute some distinfo stuff
description = doc_head.replace('\n', ' ')
if preamble_md:
long_description = '\n\n'.join(
[
doc_head,
preamble_md.rstrip(), doc_tail, '# Release Log\n\n',
*postamble_parts
]
)
else:
long_description = full_doc
return SimpleNamespace(
module_doc=full_doc,
description=description,
long_description=long_description,
release_paragraphs=postamble_parts,
)
@property
def latest_changeset_hash(self):
''' The most recent changeset hash of the files in the module.
'''
path_revs = self.vcs.file_revisions(self.paths())
rev_latest = None
for rev, node in sorted(path_revs.values()):
if rev is not None and rev_latest is None or rev_latest < rev:
changeset_hash = node
rev_latest = rev
return changeset_hash
# pylint: disable=too-many-branches,too-many-locals
@pfx_method
def compute_distinfo(
self,
*,
pypi_package_name=None,
pypi_package_version=None,
):
''' Compute the distutils info mapping for this package.
Return a new `dict` containing the mapping.
'''
if '>' in self.name or '=' in self.name:
raise RuntimeError("bad module name %r" % (self.name))
if pypi_package_name is None:
pypi_package_name = self.name
if pypi_package_version is None:
pypi_package_version = self.latest.version if self.latest else None
# prepare core distinfo
dinfo = dict(DISTINFO_DEFAULTS)
docs = self.compute_doc(all_class_names=True)
dinfo.update(description=docs.description)
dinfo.update(self.module.DISTINFO)
# resolve install_requires
dinfo.update(
install_requires=self
.resolve_requirements(dinfo.pop('install_requires', ()))
)
# fill in default fields
di_defaults = {
'author': os.environ['NAME'],
'author_email': os.environ['EMAIL'],
'include_package_data': True,
'package_dir': PYLIBTOP,
}
for di_field in ('author', 'author_email', 'package_dir'):
with Pfx("%r", di_field):
if di_field not in dinfo:
dinfo[di_field] = di_defaults[di_field]
# fill in default classifications
classifiers = dinfo['classifiers']
for classifier_topic, classifier_subsection in DISTINFO_CLASSIFICATION.items(
):
classifier_prefix = classifier_topic + " ::"
classifier_value = classifier_topic + " :: " + classifier_subsection
if not any(classifier.startswith(classifier_prefix)
for classifier in classifiers):
dinfo['classifiers'].append(classifier_value)
# derive some stuff from the classifiers
license_type = None
for classifier in dinfo['classifiers']:
parts = classifier.split(' :: ')
topic = parts[0]
if topic == 'License':
license_type = parts[-1]
# source URLs
urls = dinfo['urls']
basepath = self.basepath
if isdirpath(basepath):
urls['Source'] = joinpath(MIRROR_SRCBASE, basepath)
elif isfilepath(basepath + '.py'):
urls['Source'] = joinpath(MIRROR_SRCBASE, basepath + '.py')
else:
warning("cannot compute Source URL: basepath=%r", basepath)
if self.is_package:
# stash the package in a top level directory of that name
# dinfo['package_dir'] = {package_name: package_name}
dinfo['packages'] = [self.name]
else:
dinfo['py_modules'] = [self.name]
# fill in missing but expected fields
for kw, value in (
('license', license_type),
('name', pypi_package_name),
('version', pypi_package_version),
):
with Pfx(kw):
if value is None:
warning("no value")
elif kw in dinfo:
if dinfo[kw] != value:
info("publishing %s instead of %s", value, dinfo[kw])
else:
dinfo[kw] = value
# check for required fields
for kw in (
'name',
'description',
'author',
'author_email',
'version',
'license',
'urls',
):
with Pfx(kw):
if kw not in dinfo:
warning('not in distinfo', kw)
return dinfo
@require(lambda self: self.is_package)
def setuptools_ext_modules(self, specs):
''' A copy of `specs` with the sources prepended with `package_dir`.
'''
full_specs = []
for ext_mod in specs:
full_spec = dict(ext_mod)
full_spec['sources'] = [
joinpath(self.basepath, srcrpath)
for srcrpath in full_spec['sources']
]
full_specs.append(full_spec)
return full_specs
@pfx_method
def compute_pyproject(
self,
dinfo=None,
*,
pypi_package_name=None,
pypi_package_version=None,
):
''' Compute the contents for the `pyproject.toml` file,
return a `dict` for transcription as TOML.
'''
if dinfo is None:
dinfo = self.compute_distinfo(
pypi_package_name=pypi_package_name,
pypi_package_version=pypi_package_version
)
elif pypi_package_name or pypi_package_version:
raise ValueError(
"cannot supply both dinfo and either pypi_package_name or pypi_package_version"
)
# we will be consuming the dict so make a copy of the presupplied mapping
dinfo = dict(dinfo)
projspec = dict(
name=dinfo.pop('name').replace('.', '-'),
description=dinfo.pop('description'),
authors=[
dict(name=dinfo.pop('author'), email=dinfo.pop('author_email'))
],
license={"text": dinfo.pop('license')},
keywords=dinfo.pop('keywords'),
dependencies=dinfo.pop('install_requires'),
urls=dinfo.pop('urls'),
classifiers=dinfo.pop('classifiers'),
)
python_version = dinfo.pop('requires_python', None)
if python_version is not None:
projspec['requires_python'] = python_version
version = dinfo.pop('version', None)
if version:
projspec['version'] = version
if 'extra_requires' in dinfo:
projspec['optional-dependencies'] = dinfo.pop('extra_requires')
if 'python_requires' in dinfo:
projspec['requires-python'] = dinfo.pop('python_requires')
package_dir = dinfo.pop('package_dir')
dinfo_entry_points = dinfo.pop('entry_points', {})
if dinfo_entry_points:
console_scripts = dinfo_entry_points.pop('console_scripts', [])
if console_scripts:
projspec['scripts'] = console_scripts
gui_scripts = dinfo_entry_points.pop('gui_scripts', [])
if gui_scripts:
projspec['gui-scripts'] = gui_scripts
pyproject = {
"project": projspec,
}
if self.needs_setuptools:
pyproject["build-system"] = {
"build-backend": "setuptools.build_meta",
"requires": [
"setuptools >= 61.2",
"trove-classifiers",
"wheel",
],
}
setuptools_cfg = {
"package-dir": {
"": package_dir,
},
"ext-modules":
self.setuptools_ext_modules(dinfo.pop("ext-modules", [])),
}
if self.is_package:
setuptools_cfg["packages"] = [self.name]
else:
setuptools_cfg["py-modules"] = [self.name]
pyproject["tool"] = {
"setuptools": setuptools_cfg,
}
else:
pyproject["build-system"] = {
"build-backend": "flit_core.buildapi",
"requires": ["flit_core >=3.2,<4"],
}
pyproject["tool"] = {"flit": {"module": {"name": self.name}}}
docs = self.compute_doc()
projspec["readme"] = {
"text": docs.long_description,
"content-type": "text/markdown",
}
# check that everything was covered off
if dinfo and dinfo != {'py_modules': [self.name]}:
warning("dinfo not emptied: %r", dinfo)
return pyproject
# pylint: disable=too-many-locals
@pfx_method
def compute_setup_cfg(
self,
dinfo=None,
*,
pypi_package_name=None,
pypi_package_version=None,
) -> ConfigParser:
''' Compute the contents for `setup.cfg`, used by `setuptools`.
Return a filled in `ConfigParser` instance.
'''
if dinfo is None:
dinfo = self.compute_distinfo(
pypi_package_name=pypi_package_name,
pypi_package_version=pypi_package_version
)
else:
if pypi_package_name or pypi_package_version:
raise ValueError(
"cannot supply both dinfo and either pypi_package_name or pypi_package_version"
)
# we will be consuming the dict so make a copy of the presupplied mapping
dinfo = dict(dinfo)
sections = {}
# metadata section
md = {}
for k in ('name', 'version', 'author', 'author_email', 'license',
'description', 'keywords', 'url', 'classifiers'):
v = dinfo.pop(k, None)
if v is None:
continue
if k in ('keywords',):
v = ', '.join(v)
elif k in ('classifiers', 'install_requires', 'extra_requires'):
v = '\n' + '\n'.join(v)
md[k] = v
md['long_description'] = 'file: README.md'
md['long_description_content_type'] = 'text/markdown'