11from __future__ import annotations
22
3+ import tarfile
4+ import zipfile
5+
36from pathlib import Path
47from typing import TYPE_CHECKING
58from typing import Any
9+ from typing import Literal
610
711import requests
812
9- from poetry .core .masonry .metadata import Metadata
13+ from packaging .metadata import RawMetadata
14+ from packaging .metadata import parse_email
1015from poetry .core .masonry .utils .helpers import distribution_name
1116from requests_toolbelt import user_agent
1217from requests_toolbelt .multipart import MultipartEncoder
@@ -101,10 +106,9 @@ def upload(
101106 with session :
102107 self ._upload (session , url , dry_run , skip_existing )
103108
104- def post_data (self , file : Path ) -> dict [str , Any ]:
105- meta = Metadata .from_package (self ._package )
106-
107- file_type = self ._get_type (file )
109+ @classmethod
110+ def post_data (cls , file : Path ) -> dict [str , Any ]:
111+ file_type = cls ._get_type (file )
108112
109113 hash_manager = HashManager ()
110114 hash_manager .hash (file )
@@ -119,51 +123,38 @@ def post_data(self, file: Path) -> dict[str, Any]:
119123 wheel_info = wheel_file_re .match (file .name )
120124 if wheel_info is not None :
121125 py_version = wheel_info .group ("pyver" )
126+ else :
127+ py_version = "source"
122128
123- data = {
124- # identify release
125- "name" : meta .name ,
126- "version" : meta .version ,
127- # file content
128- "filetype" : file_type ,
129- "pyversion" : py_version ,
130- # additional meta-data
131- "metadata_version" : meta .metadata_version ,
132- "summary" : meta .summary ,
133- "home_page" : meta .home_page ,
134- "author" : meta .author ,
135- "author_email" : meta .author_email ,
136- "maintainer" : meta .maintainer ,
137- "maintainer_email" : meta .maintainer_email ,
138- "license" : meta .license ,
139- "description" : meta .description ,
140- "keywords" : meta .keywords ,
141- "platform" : meta .platforms ,
142- "classifiers" : meta .classifiers ,
143- "download_url" : meta .download_url ,
144- "supported_platform" : meta .supported_platforms ,
145- "comment" : None ,
129+ data : dict [str , Any ] = {
130+ # Upload API (https://docs.pypi.org/api/upload/)
131+ # ":action", "protocol_version" and "content are added later
146132 "md5_digest" : md5_digest ,
147133 "sha256_digest" : sha2_digest ,
148134 "blake2_256_digest" : blake2_256_digest ,
149- # PEP 314
150- "provides" : meta .provides ,
151- "requires" : meta .requires ,
152- "obsoletes" : meta .obsoletes ,
153- # Metadata 1.2
154- "project_urls" : meta .project_urls ,
155- "provides_dist" : meta .provides_dist ,
156- "obsoletes_dist" : meta .obsoletes_dist ,
157- "requires_dist" : meta .requires_dist ,
158- "requires_external" : meta .requires_external ,
159- "requires_python" : meta .requires_python ,
135+ "filetype" : file_type ,
136+ "pyversion" : py_version ,
160137 }
161138
162- # Metadata 2.1
163- if meta .description_content_type :
164- data ["description_content_type" ] = meta .description_content_type
139+ for key , value in cls ._get_metadata (file ).items ():
140+ # strip trailing 's' to match API field names
141+ # see https://docs.pypi.org/api/upload/
142+ if key in {"platforms" , "supported_platforms" , "license_files" }:
143+ key = key [:- 1 ]
165144
166- # TODO: Provides extra
145+ # revert some special cases from packaging.metadata.parse_email()
146+
147+ # "keywords" is not "multiple use" but a comma-separated string
148+ if key == "keywords" :
149+ assert isinstance (value , list )
150+ value = ", " .join (value )
151+
152+ # "project_urls" is not a dict
153+ if key == "project_urls" :
154+ assert isinstance (value , dict )
155+ value = [f"{ k } , { v } " for k , v in value .items ()]
156+
157+ data [key ] = value
167158
168159 return data
169160
@@ -191,13 +182,7 @@ def _upload_file(
191182 raise UploadError (f"Archive ({ file } ) does not exist" )
192183
193184 data = self .post_data (file )
194- data .update (
195- {
196- # action
197- ":action" : "file_upload" ,
198- "protocol_version" : "1" ,
199- }
200- )
185+ data .update ({":action" : "file_upload" , "protocol_version" : "1" })
201186
202187 data_to_send : list [tuple [str , Any ]] = self ._prepare_data (data )
203188
@@ -308,15 +293,45 @@ def _prepare_data(self, data: dict[str, Any]) -> list[tuple[str, str]]:
308293
309294 return data_to_send
310295
311- def _get_type (self , file : Path ) -> str :
296+ @staticmethod
297+ def _get_type (file : Path ) -> Literal ["bdist_wheel" , "sdist" ]:
312298 exts = file .suffixes
313- if exts [- 1 ] == ".whl" :
299+ if exts and exts [- 1 ] == ".whl" :
314300 return "bdist_wheel"
315301 elif len (exts ) >= 2 and "" .join (exts [- 2 :]) == ".tar.gz" :
316302 return "sdist"
317303
318304 raise ValueError ("Unknown distribution format " + "" .join (exts ))
319305
306+ @staticmethod
307+ def _get_metadata (file : Path ) -> RawMetadata :
308+ if file .suffix == ".whl" :
309+ with zipfile .ZipFile (file ) as z :
310+ for name in z .namelist ():
311+ parts = Path (name ).parts
312+ if (
313+ len (parts ) == 2
314+ and parts [1 ] == "METADATA"
315+ and parts [0 ].endswith (".dist-info" )
316+ ):
317+ with z .open (name ) as mf :
318+ return parse_email (mf .read ().decode ("utf-8" ))[0 ]
319+ raise FileNotFoundError ("METADATA not found in wheel" )
320+
321+ elif file .suffixes [- 2 :] == [".tar" , ".gz" ]:
322+ with tarfile .open (file , "r:gz" ) as tar :
323+ for member in tar .getmembers ():
324+ parts = Path (member .name ).parts
325+ if (
326+ len (parts ) == 2
327+ and parts [1 ] == "PKG-INFO"
328+ and (pf := tar .extractfile (member ))
329+ ):
330+ return parse_email (pf .read ().decode ("utf-8" ))[0 ]
331+ raise FileNotFoundError ("PKG-INFO not found in sdist" )
332+
333+ raise ValueError (f"Unsupported file type: { file } " )
334+
320335 def _is_file_exists_error (self , response : requests .Response ) -> bool :
321336 # based on https://github.com/pypa/twine/blob/a6dd69c79f7b5abfb79022092a5d3776a499e31b/twine/commands/upload.py#L32
322337 status = response .status_code
0 commit comments