33from __future__ import annotations
44
55import argparse
6+ import dataclasses
67import re
78import sys
89import time
910from contextlib import contextmanager
1011from pathlib import Path
11- from typing import TYPE_CHECKING
12+ from typing import TYPE_CHECKING , Literal
1213
1314if TYPE_CHECKING :
1415 from collections .abc import Iterator , Sequence
15- from typing import TypeAlias
1616
1717script_dir = Path (__file__ ).parent
1818package_dir = script_dir .parent
1919
20- RELEASE_TYPE = {'a' : 'alpha' , 'b' : 'beta' }
2120
22- VersionInfo : TypeAlias = tuple [int , int , int , str , int ]
21+ @dataclasses .dataclass (frozen = True , slots = True )
22+ class VersionInfo :
23+ major : int
24+ minor : int
25+ micro : int
26+ level : Literal ['a' , 'b' , 'rc' , 'final' ]
27+ serial : int
28+
29+ @property
30+ def releaselevel (self ) -> Literal ['alpha' , 'beta' , 'candidate' , 'final' ]:
31+ if self .level == 'final' :
32+ return 'final'
33+ if self .level == 'a' :
34+ return 'alpha'
35+ if self .level == 'b' :
36+ return 'beta'
37+ if self .level == 'rc' :
38+ return 'candidate'
39+ msg = f'Unknown release level: { self .level } '
40+ raise RuntimeError (msg )
41+
42+ @property
43+ def is_final (self ) -> bool :
44+ return self .level == 'final'
45+
46+ @property
47+ def version (self ) -> str :
48+ return f'{ self .major } .{ self .minor } .{ self .micro } '
49+
50+ @property
51+ def release (self ) -> str :
52+ return f'{ self .major } .{ self .minor } .{ self .micro } { self .level } { self .serial } '
53+
54+ @property
55+ def version_tuple (self ) -> tuple [int , int , int ]:
56+ return self .major , self .minor , self .micro
57+
58+ @property
59+ def release_tuple (self ) -> tuple [int , int , int , str , int ]:
60+ return self .major , self .minor , self .micro , self .releaselevel , self .serial
2361
2462
25- def stringify_version (version_info : VersionInfo , in_develop : bool = True ) -> str :
26- version = '.' .join (str (v ) for v in version_info [:3 ])
27- if not in_develop and version_info [3 ] != 'final' :
28- version += version_info [3 ][0 ] + str (version_info [4 ])
63+ def parse_version (version : str ) -> VersionInfo :
64+ # Final version:
65+ # - "X.Y.Z" -> (X, Y, Z, 'final', 0)
66+ # - "X.Y" -> (X, Y, 0, 'final', 0) [shortcut]
67+ if matched := re .fullmatch (r'(\d+)\.(\d+)(?:\.(\d+))?' , version ):
68+ major , minor , micro = matched .groups (default = '0' )
69+ return VersionInfo (int (major ), int (minor ), int (micro ), 'final' , 0 )
70+
71+ # Pre-release versions:
72+ # - "X.Y.ZaN" -> (X, Y, Z, 'alpha', N)
73+ # - "X.Y.ZbN" -> (X, Y, Z, 'beta', N)
74+ # - "X.Y.ZrcN" -> (X, Y, Z, 'candidate', N)
75+ if matched := re .fullmatch (r'(\d+)\.(\d+)\.(\d+)(a|b|rc)(\d+)' , version ):
76+ major , minor , micro , level , serial = matched .groups ()
77+ return VersionInfo (int (major ), int (minor ), int (micro ), level , int (serial )) # type: ignore[arg-type]
2978
30- return version
79+ msg = f'Unknown version: { version } '
80+ raise RuntimeError (msg )
3181
3282
3383def bump_version (path : Path , version_info : VersionInfo , in_develop : bool = True ) -> None :
34- version = stringify_version (version_info , in_develop )
84+ if in_develop or version_info .is_final :
85+ version = version_info .version
86+ else :
87+ version = version_info .release
3588
3689 with open (path , encoding = 'utf-8' ) as f :
37- lines = f .read ().splitlines ()
90+ lines = f .read ().splitlines (keepends = True )
3891
3992 for i , line in enumerate (lines ):
4093 if line .startswith ('__version__ = ' ):
41- lines [i ] = f"__version__ = '{ version } '"
94+ lines [i ] = f"__version__ = '{ version } '\n "
4295 continue
4396 if line .startswith ('version_info = ' ):
44- lines [i ] = f'version_info = { version_info } '
97+ lines [i ] = f'version_info = { version_info . release_tuple } \n '
4598 continue
4699 if line .startswith ('_in_development = ' ):
47- lines [i ] = f'_in_development = { in_develop } '
100+ lines [i ] = f'_in_development = { in_develop } \n '
48101 continue
49102
50103 with open (path , 'w' , encoding = 'utf-8' ) as f :
51- f .write ('\n ' .join (lines ) + '\n ' )
52-
53-
54- def parse_version (version : str ) -> VersionInfo :
55- matched = re .search (r'^(\d+)\.(\d+)$' , version )
56- if matched :
57- major , minor = matched .groups ()
58- return (int (major ), int (minor ), 0 , 'final' , 0 )
59-
60- matched = re .search (r'^(\d+)\.(\d+)\.(\d+)$' , version )
61- if matched :
62- major , minor , rev = matched .groups ()
63- return (int (major ), int (minor ), int (rev ), 'final' , 0 )
64-
65- matched = re .search (r'^(\d+)\.(\d+)\s*(a|b|alpha|beta)(\d+)$' , version )
66- if matched :
67- major , minor , typ , relver = matched .groups ()
68- release = RELEASE_TYPE .get (typ , typ )
69- return (int (major ), int (minor ), 0 , release , int (relver ))
70-
71- matched = re .search (r'^(\d+)\.(\d+)\.(\d+)\s*(a|b|alpha|beta)(\d+)$' , version )
72- if matched :
73- major , minor , rev , typ , relver = matched .groups ()
74- release = RELEASE_TYPE .get (typ , typ )
75- return (int (major ), int (minor ), int (rev ), release , int (relver ))
76-
77- raise RuntimeError ('Unknown version: %s' % version )
78-
79-
80- class Skip (Exception ):
81- pass
82-
83-
84- @contextmanager
85- def processing (message : str ) -> Iterator [None ]:
86- try :
87- print (message + ' ... ' , end = '' )
88- yield
89- except Skip as exc :
90- print ('skip: %s' % exc )
91- except Exception :
92- print ('error' )
93- raise
94- else :
95- print ('done' )
104+ f .writelines (lines )
96105
97106
98107class Changes :
99108 def __init__ (self , path : Path ) -> None :
100109 self .path = path
101- self .fetch_version ()
102110
103- def fetch_version (self ) -> None :
104111 with open (self .path , encoding = 'utf-8' ) as f :
105112 version = f .readline ().strip ()
106- matched = re .search (r'^ Release (.*) \((.*)\)$ ' , version )
113+ matched = re .fullmatch (r'Release (.*) \((.*)\)' , version )
107114 if matched is None :
108- raise RuntimeError ('Unknown CHANGES format: %s' % version )
115+ msg = f'Unknown CHANGES format: { version } '
116+ raise RuntimeError (msg )
109117
110- self .version , self .release_date = matched .groups ()
111- self .version_info = parse_version (self .version )
112- if self .release_date == 'in development' :
113- self .in_development = True
114- else :
115- self .in_development = False
118+ self .version , release_date = matched .groups ()
119+ self .in_development = release_date == 'in development'
120+ self .version_tuple = parse_version (self .version ).version_tuple
116121
117- def finalize_release_date (self ) -> None :
122+ def finalise_release_date (self ) -> None :
118123 release_date = time .strftime ('%b %d, %Y' )
119124 heading = f'Release { self .version } (released { release_date } )'
120-
121125 with open (self .path , 'r+' , encoding = 'utf-8' ) as f :
122126 f .readline () # skip first two lines
123127 f .readline ()
@@ -130,21 +134,8 @@ def finalize_release_date(self) -> None:
130134 f .write (self .filter_empty_sections (body ))
131135
132136 def add_release (self , version_info : VersionInfo ) -> None :
133- if version_info [- 2 :] in (('beta' , 0 ), ('final' , 0 )):
134- version = stringify_version (version_info )
135- else :
136- reltype = version_info [3 ]
137- version = (
138- f'{ stringify_version (version_info )} '
139- f'{ RELEASE_TYPE .get (reltype , reltype )} { version_info [4 ] or "" } '
140- )
141- heading = 'Release %s (in development)' % version
142-
143- with open (script_dir / 'CHANGES_template.rst' , encoding = 'utf-8' ) as f :
144- f .readline () # skip first two lines
145- f .readline ()
146- tmpl = f .read ()
147-
137+ heading = f'Release { version_info .version } (in development)'
138+ tmpl = (script_dir / 'CHANGES_template.rst' ).read_text (encoding = 'utf-8' )
148139 with open (self .path , 'r+' , encoding = 'utf-8' ) as f :
149140 body = f .read ()
150141
@@ -156,39 +147,55 @@ def add_release(self, version_info: VersionInfo) -> None:
156147 f .write ('\n ' )
157148 f .write (body )
158149
159- def filter_empty_sections (self , body : str ) -> str :
150+ @staticmethod
151+ def filter_empty_sections (body : str ) -> str :
160152 return re .sub ('^\n .+\n -{3,}\n +(?=\n .+\n [-=]{3,}\n )' , '' , body , flags = re .MULTILINE )
161153
162154
163- def parse_options (argv : Sequence [str ]) -> argparse .Namespace :
155+ class Skip (Exception ):
156+ pass
157+
158+
159+ @contextmanager
160+ def processing (message : str ) -> Iterator [None ]:
161+ try :
162+ print (message + ' ... ' , end = '' )
163+ yield
164+ except Skip as exc :
165+ print (f'skip: { exc } ' )
166+ except Exception :
167+ print ('error' )
168+ raise
169+ else :
170+ print ('done' )
171+
172+
173+ def parse_options (argv : Sequence [str ]) -> tuple [VersionInfo , bool ]:
164174 parser = argparse .ArgumentParser ()
165- parser .add_argument ('version' , help = 'A version number (cf. 1.6b0 )' )
175+ parser .add_argument ('version' , help = 'A version number (cf. 1.6.0b0 )' )
166176 parser .add_argument ('--in-develop' , action = 'store_true' )
167177 options = parser .parse_args (argv )
168- options .version = parse_version (options .version )
169- return options
178+ return parse_version (options .version ), options .in_develop
170179
171180
172181def main () -> None :
173- options = parse_options (sys .argv [1 :])
182+ version , in_develop = parse_options (sys .argv [1 :])
174183
175184 with processing ('Rewriting sphinx/__init__.py' ):
176- bump_version (
177- package_dir / 'sphinx' / '__init__.py' , options .version , options .in_develop
178- )
185+ bump_version (package_dir / 'sphinx' / '__init__.py' , version , in_develop )
179186
180187 with processing ('Rewriting CHANGES' ):
181188 changes = Changes (package_dir / 'CHANGES.rst' )
182- if changes .version_info == options . version :
183- if changes .in_development :
184- changes .finalize_release_date ()
189+ if changes .version_tuple == version . version_tuple :
190+ if changes .in_development and version . is_final and not in_develop :
191+ changes .finalise_release_date ()
185192 else :
186193 reason = 'version not changed'
187194 raise Skip (reason )
188195 else :
189196 if changes .in_development :
190- print ('WARNING: last version is not released yet: %s' % changes .version )
191- changes .add_release (options . version )
197+ print (f 'WARNING: last version is not released yet: { changes .version } ' )
198+ changes .add_release (version )
192199
193200
194201if __name__ == '__main__' :
0 commit comments