1313import pathlib
1414import re
1515import textwrap
16- import typing
1716
1817import tomli
1918
2019
2120GENERATED_FILE = pathlib .Path ("requirements.txt" )
21+ DOC_REQUIREMENTS = pathlib .Path ("docs/.requirements.txt" )
2222
2323VERSION_RESTRICTER_REGEX = re .compile (r"(?P<sign>[<>=!]{1,2})(?P<version>\d+\.\d+?)(?P<patch>\.\d+?|\.\*)?" )
2424PLATFORM_MARKERS_REGEX = re .compile (r'sys_platform\s?==\s?"(?P<platform>\w+)"' )
2525
26+ PACKAGE_REGEX = re .compile (r"^[^=<>~]+" )
2627# fmt: off
2728MESSAGE = textwrap .indent (textwrap .dedent (
2829 f"""
@@ -55,7 +56,95 @@ def get_hash(content: dict) -> str:
5556 return hash == get_hash (content )
5657
5758
58- def main (req_path : os .PathLike , should_validate_hash : bool = True ) -> typing .Optional [int ]:
59+ def _extract_packages_from_requirements (requirements : str ) -> "tuple[set[str],list[str]]" :
60+ """Extract a list of packages from the provided requirements str."""
61+ req = requirements .split ("\n " )
62+ packages = set ()
63+ for i , line in enumerate (req .copy ()):
64+ if line .startswith ("#" ):
65+ continue
66+ if not len (line .strip ()):
67+ continue
68+
69+ # requirement files we will be parsing can have `;` or =<>~
70+ match = PACKAGE_REGEX .match (line )
71+ if match is None :
72+ continue
73+ # replace the line with the match
74+ req [i ] = match [0 ].strip ()
75+
76+ # replacing `_` with `-` because pypi treats them as the same character
77+ # poetry is supposed to do this, but does not always
78+ package = req [i ].lower ().replace ("_" , "-" )
79+
80+ packages .add (package )
81+
82+ return packages , req
83+
84+
85+ def _update_versions_in_requirements (requirements : "list[str]" , packages : dict ) -> str :
86+ """Update the versions in requirements with the provided package to version mapping."""
87+ for i , package in enumerate (requirements .copy ()):
88+ if package .startswith ("#" ):
89+ continue
90+ if not len (package .strip ()):
91+ continue
92+ try :
93+ requirements [i ] = package + "==" + packages [package .lower ().replace ("_" , "-" )]
94+ except KeyError :
95+ raise AttributeError (f"{ package } could not be found in poetry.lock" ) from None
96+ return "\n " .join (requirements )
97+
98+
99+ def _export_doc_requirements (toml : dict , file : pathlib .Path , * packages ) -> int :
100+ """
101+ Export the provided packages versions.
102+
103+ Return values:
104+ 0 no changes
105+ 1 exported new requirements
106+ 2 file does not exist
107+ 3 invalid packages
108+ """
109+ file = pathlib .Path (file )
110+ if not file .exists ():
111+ # file does not exist
112+ return 2
113+
114+ with open (file ) as f :
115+ contents = f .read ()
116+
117+ # parse the packages out of the requirements txt
118+ packages , req = _extract_packages_from_requirements (contents )
119+
120+ # get the version of each package
121+ packages_metadata : dict = toml ["package" ]
122+ new_versions = {
123+ package ["name" ]: package ["version" ]
124+ for package in packages_metadata
125+ if package ["name" ].lower ().replace ("_" , "-" ) in packages
126+ }
127+
128+ try :
129+ new_contents = _update_versions_in_requirements (req , new_versions )
130+ except AttributeError as e :
131+ print (e )
132+ return 3
133+ if new_contents == contents :
134+ # don't write anything, just return 0
135+ return 0
136+
137+ with open (file , "w" ) as f :
138+ f .write (new_contents )
139+
140+ return 1
141+
142+
143+ def main (
144+ req_path : os .PathLike ,
145+ should_validate_hash : bool = True ,
146+ export_doc_requirements : bool = True ,
147+ ) -> int :
59148 """Read and export all required packages to their pinned version in requirements.txt format."""
60149 req_path = pathlib .Path (req_path )
61150
@@ -149,16 +238,24 @@ def main(req_path: os.PathLike, should_validate_hash: bool = True) -> typing.Opt
149238 dependency_lines [k ] = line
150239
151240 req_txt += "\n " .join (sorted (k + v .rstrip () for k , v in dependency_lines .items ())) + "\n "
241+
242+ if export_doc_requirements :
243+ exit_code = _export_doc_requirements (lockfile , DOC_REQUIREMENTS )
244+ else :
245+ exit_code = 0
246+
152247 if req_path .exists ():
153248 with open (req_path , "r" ) as f :
154249 if req_txt == f .read ():
155250 # nothing to edit
156- return 0
251+ # if exit_code is ever removed from here, this should return zero
252+ return exit_code
157253
158254 with open (req_path , "w" ) as f :
159255 f .write (req_txt )
160256 print (f"Updated { req_path } with new requirements." )
161- return 1
257+
258+ return 1
162259
163260
164261if __name__ == "__main__" :
@@ -181,6 +278,19 @@ def main(req_path: os.PathLike, should_validate_hash: bool = True) -> typing.Opt
181278 default = GENERATED_FILE ,
182279 help = "File to export to." ,
183280 )
281+ parser .add_argument (
282+ "--docs" ,
283+ action = "store_true" ,
284+ dest = "export_doc_requirements" ,
285+ default = False ,
286+ help = "Also export the documentation requirements. Defaults to false." ,
287+ )
184288
185289 args = parser .parse_args ()
186- sys .exit (main (args .output_file , should_validate_hash = not args .skip_hash_check ))
290+ sys .exit (
291+ main (
292+ args .output_file ,
293+ should_validate_hash = not args .skip_hash_check ,
294+ export_doc_requirements = args .export_doc_requirements ,
295+ )
296+ )
0 commit comments