11from __future__ import annotations
22
3+ import contextlib
4+ import itertools
35import urllib .parse
46
7+ from datetime import datetime
58from functools import partialmethod
9+ from importlib import metadata
610from typing import TYPE_CHECKING
11+ from typing import Any
712
813from cleo .io .io import IO
14+ from poetry .core .constraints .version .version import Version
915from poetry .core .packages .dependency_group import MAIN_GROUP
16+ from poetry .core .packages .directory_dependency import DirectoryDependency
17+ from poetry .core .packages .file_dependency import FileDependency
18+ from poetry .core .packages .url_dependency import URLDependency
1019from poetry .core .packages .utils .utils import create_nested_marker
20+ from poetry .core .packages .vcs_dependency import VCSDependency
1121from poetry .core .version .markers import parse_marker
1222from poetry .repositories .http_repository import HTTPRepository
1323
2232 from typing import ClassVar
2333
2434 from packaging .utils import NormalizedName
35+ from poetry .core .packages .package import PackageFile
2536 from poetry .poetry import Poetry
2637
2738
@@ -32,11 +43,13 @@ class Exporter:
3243
3344 FORMAT_CONSTRAINTS_TXT = "constraints.txt"
3445 FORMAT_REQUIREMENTS_TXT = "requirements.txt"
46+ FORMAT_PYLOCK_TOML = "pylock.toml"
3547 ALLOWED_HASH_ALGORITHMS = ("sha256" , "sha384" , "sha512" )
3648
3749 EXPORT_METHODS : ClassVar [dict [str , str ]] = {
3850 FORMAT_CONSTRAINTS_TXT : "_export_constraints_txt" ,
3951 FORMAT_REQUIREMENTS_TXT : "_export_requirements_txt" ,
52+ FORMAT_PYLOCK_TOML : "_export_pylock_toml" ,
4053 }
4154
4255 def __init__ (self , poetry : Poetry , io : IO ) -> None :
@@ -81,11 +94,20 @@ def export(self, fmt: str, cwd: Path, output: IO | str) -> None:
8194 if not self .is_format_supported (fmt ):
8295 raise ValueError (f"Invalid export format: { fmt } " )
8396
84- getattr (self , self .EXPORT_METHODS [fmt ])(cwd , output )
97+ out_dir = cwd
98+ if isinstance (output , str ):
99+ out_dir = (cwd / output ).parent
100+ content = getattr (self , self .EXPORT_METHODS [fmt ])(out_dir )
101+
102+ if isinstance (output , IO ):
103+ output .write (content )
104+ else :
105+ with (cwd / output ).open ("w" , encoding = "utf-8" ) as txt :
106+ txt .write (content )
85107
86108 def _export_generic_txt (
87- self , cwd : Path , output : IO | str , with_extras : bool , allow_editable : bool
88- ) -> None :
109+ self , out_dir : Path , with_extras : bool , allow_editable : bool
110+ ) -> str :
89111 from poetry .core .packages .utils .utils import path_to_url
90112
91113 indexes = set ()
@@ -219,11 +241,7 @@ def _export_generic_txt(
219241
220242 content = indexes_header + "\n " + content
221243
222- if isinstance (output , IO ):
223- output .write (content )
224- else :
225- with (cwd / output ).open ("w" , encoding = "utf-8" ) as txt :
226- txt .write (content )
244+ return content
227245
228246 _export_constraints_txt = partialmethod (
229247 _export_generic_txt , with_extras = False , allow_editable = False
@@ -232,3 +250,185 @@ def _export_generic_txt(
232250 _export_requirements_txt = partialmethod (
233251 _export_generic_txt , with_extras = True , allow_editable = True
234252 )
253+
254+ def _get_poetry_version (self ) -> str :
255+ return metadata .version ("poetry" )
256+
257+ def _export_pylock_toml (self , out_dir : Path ) -> str :
258+ from tomlkit import aot
259+ from tomlkit import array
260+ from tomlkit import document
261+ from tomlkit import inline_table
262+ from tomlkit import table
263+
264+ min_poetry_version = "2.3.0"
265+ if Version .parse (self ._get_poetry_version ()) < Version .parse (
266+ min_poetry_version
267+ ):
268+ raise RuntimeError (
269+ "Exporting pylock.toml requires Poetry version"
270+ f" { min_poetry_version } or higher."
271+ )
272+
273+ if not self ._poetry .locker .is_locked_groups_and_markers ():
274+ raise RuntimeError (
275+ "Cannot export pylock.toml because the lock file is not at least version 2.1"
276+ )
277+
278+ def add_file_info (
279+ archive : dict [str , Any ],
280+ locked_file_info : PackageFile ,
281+ additional_file_info : PackageFile | None = None ,
282+ ) -> None :
283+ # We only use additional_file_info for url, upload_time and size
284+ # because they are not in locked_file_info.
285+ if additional_file_info :
286+ archive ["name" ] = locked_file_info ["file" ]
287+ url = additional_file_info .get ("url" )
288+ assert url , "url must be present in additional_file_info"
289+ archive ["url" ] = url
290+ if upload_time := additional_file_info .get ("upload_time" ):
291+ with contextlib .suppress (ValueError ):
292+ # Python < 3.11 does not support 'Z' suffix for UTC, replace it with '+00:00'
293+ archive ["upload-time" ] = datetime .fromisoformat (
294+ upload_time .replace ("Z" , "+00:00" )
295+ )
296+ if size := additional_file_info .get ("size" ):
297+ archive ["size" ] = size
298+ archive ["hashes" ] = dict ([locked_file_info ["hash" ].split (":" , 1 )])
299+
300+ python_constraint = self ._poetry .package .python_constraint
301+ python_marker = parse_marker (
302+ create_nested_marker ("python_version" , python_constraint )
303+ )
304+
305+ lock = document ()
306+ lock ["lock-version" ] = "1.0"
307+ if self ._poetry .package .python_versions != "*" :
308+ lock ["environments" ] = [str (python_marker )]
309+ lock ["requires-python" ] = str (python_constraint )
310+ lock ["created-by" ] = "poetry-plugin-export"
311+
312+ packages = aot ()
313+ for dependency_package in get_project_dependency_packages2 (
314+ self ._poetry .locker ,
315+ groups = set (self ._groups ),
316+ extras = self ._extras ,
317+ ):
318+ dependency = dependency_package .dependency
319+ package = dependency_package .package
320+ data = table ()
321+ data ["name" ] = package .name
322+ data ["version" ] = str (package .version )
323+ if not package .marker .is_any ():
324+ data ["marker" ] = str (package .marker )
325+ if not package .python_constraint .is_any ():
326+ data ["requires-python" ] = str (package .python_constraint )
327+ packages .append (data )
328+ match dependency :
329+ case VCSDependency ():
330+ vcs = {}
331+ vcs ["type" ] = "git"
332+ vcs ["url" ] = dependency .source
333+ vcs ["requested-revision" ] = dependency .reference
334+ assert dependency .source_resolved_reference , (
335+ "VCSDependency must have a resolved reference"
336+ )
337+ vcs ["commit-id" ] = dependency .source_resolved_reference
338+ if dependency .directory :
339+ vcs ["subdirectory" ] = dependency .directory
340+ data ["vcs" ] = vcs
341+ case DirectoryDependency ():
342+ # The version MUST NOT be included when it cannot be guaranteed
343+ # to be consistent with the code used
344+ del data ["version" ]
345+ dir_ : dict [str , Any ] = {}
346+ try :
347+ dir_ ["path" ] = dependency .full_path .relative_to (
348+ out_dir
349+ ).as_posix ()
350+ except ValueError :
351+ dir_ ["path" ] = dependency .full_path .as_posix ()
352+ if package .develop :
353+ dir_ ["editable" ] = package .develop
354+ data ["directory" ] = dir_
355+ case FileDependency ():
356+ archive = inline_table ()
357+ try :
358+ archive ["path" ] = dependency .full_path .relative_to (
359+ out_dir
360+ ).as_posix ()
361+ except ValueError :
362+ archive ["path" ] = dependency .full_path .as_posix ()
363+ assert len (package .files ) == 1 , (
364+ "FileDependency must have exactly one file"
365+ )
366+ add_file_info (archive , package .files [0 ])
367+ if dependency .directory :
368+ archive ["subdirectory" ] = dependency .directory
369+ data ["archive" ] = archive
370+ case URLDependency ():
371+ archive = inline_table ()
372+ archive ["url" ] = dependency .url
373+ assert len (package .files ) == 1 , (
374+ "URLDependency must have exactly one file"
375+ )
376+ add_file_info (archive , package .files [0 ])
377+ if dependency .directory :
378+ archive ["subdirectory" ] = dependency .directory
379+ data ["archive" ] = archive
380+ case _:
381+ data ["index" ] = package .source_url or "https://pypi.org/simple"
382+ pool_info = {
383+ p ["file" ]: p
384+ for p in self ._poetry .pool .package (
385+ package .name ,
386+ package .version ,
387+ package .source_reference or "PyPI" ,
388+ ).files
389+ }
390+ artifacts = {
391+ k : list (v )
392+ for k , v in itertools .groupby (
393+ package .files ,
394+ key = (
395+ lambda x : "wheel"
396+ if x ["file" ].endswith (".whl" )
397+ else "sdist"
398+ ),
399+ )
400+ }
401+
402+ sdist_files = list (artifacts .get ("sdist" , []))
403+ for sdist in sdist_files :
404+ sdist_table = inline_table ()
405+ data ["sdist" ] = sdist_table
406+ add_file_info (sdist_table , sdist , pool_info [sdist ["file" ]])
407+ if wheels := list (artifacts .get ("wheel" , [])):
408+ wheel_array = array ()
409+ data ["wheels" ] = wheel_array
410+ wheel_array .multiline (True )
411+ for wheel in wheels :
412+ wheel_table = inline_table ()
413+ add_file_info (wheel_table , wheel , pool_info [wheel ["file" ]])
414+ wheel_array .append (wheel_table )
415+
416+ lock ["packages" ] = packages if packages else []
417+
418+ lock ["tool" ] = {}
419+ lock ["tool" ]["poetry-plugin-export" ] = {} # type: ignore[index]
420+ lock ["tool" ]["poetry-plugin-export" ]["groups" ] = sorted ( # type: ignore[index]
421+ self ._groups , key = lambda x : (x != "main" , x )
422+ )
423+ lock ["tool" ]["poetry-plugin-export" ]["extras" ] = sorted (self ._extras ) # type: ignore[index]
424+
425+ # Poetry writes invalid requires-python for "or" relations.
426+ # Though Poetry could parse it, other tools would fail.
427+ # Since requires-python is redundant with markers, we just comment it out.
428+ lock_lines = [
429+ f"# { line } "
430+ if line .startswith ("requires-python = " ) and "||" in line
431+ else line
432+ for line in lock .as_string ().splitlines ()
433+ ]
434+ return "\n " .join (lock_lines ) + "\n "
0 commit comments