4141
4242import errno
4343import json as json_module
44+ import os
4445import sys
4546
4647from adafruit_connection_manager import get_connection_manager
4748
49+ SEEK_END = 2
50+
4851if not sys .implementation .name == "circuitpython" :
4952 from types import TracebackType
50- from typing import Any , Dict , Optional , Type
53+ from typing import IO , Any , Dict , Optional , Type
5154
5255 from circuitpython_typing .socket import (
5356 SocketpoolModuleType ,
@@ -83,6 +86,21 @@ class Response:
8386 """The response from a request, contains all the headers/content"""
8487
8588 encoding = None
89+ socket : SocketType
90+ """The underlying socket object (CircuitPython extension, not in standard requests)
91+
92+ Under the following circumstances, calling code may directly access the underlying
93+ socket object:
94+
95+ * The request was made with ``stream=True``
96+ * The request headers included ``{'connection': 'close'}``
97+ * No methods or properties on the Response object that access the response content
98+ may be used
99+
100+ Methods and properties that access response headers may be accessed.
101+
102+ It is still necessary to ``close`` the response object for correct management of
103+ sockets, including doing so implicitly via ``with requests.get(...) as response``."""
86104
87105 def __init__ (self , sock : SocketType , session : "Session" ) -> None :
88106 self .socket = sock
@@ -245,7 +263,8 @@ def _parse_headers(self) -> None:
245263 header = self ._readto (b"\r \n " )
246264 if not header :
247265 break
248- title , content = bytes (header ).split (b": " , 1 )
266+ title , content = bytes (header ).split (b":" , 1 )
267+ content = content .strip ()
249268 if title and content :
250269 # enforce that all headers are lowercase
251270 title = str (title , "utf-8" ).lower ()
@@ -318,7 +337,7 @@ def json(self) -> Any:
318337 obj = json_module .load (self ._raw )
319338 if not self ._cached :
320339 self ._cached = obj
321- self . close ()
340+
322341 return obj
323342
324343 def iter_content (self , chunk_size : int = 1 , decode_unicode : bool = False ) -> bytes :
@@ -354,18 +373,81 @@ def __init__(
354373 self ._session_id = session_id
355374 self ._last_response = None
356375
376+ def _build_boundary_data (self , files : dict ): # pylint: disable=too-many-locals
377+ boundary_string = self ._build_boundary_string ()
378+ content_length = 0
379+ boundary_objects = []
380+
381+ for field_name , field_values in files .items ():
382+ file_name = field_values [0 ]
383+ file_handle = field_values [1 ]
384+
385+ boundary_objects .append (
386+ f'--{ boundary_string } \r \n Content-Disposition: form-data; name="{ field_name } "'
387+ )
388+ if file_name is not None :
389+ boundary_objects .append (f'; filename="{ file_name } "' )
390+ boundary_objects .append ("\r \n " )
391+ if len (field_values ) >= 3 :
392+ file_content_type = field_values [2 ]
393+ boundary_objects .append (f"Content-Type: { file_content_type } \r \n " )
394+ if len (field_values ) >= 4 :
395+ file_headers = field_values [3 ]
396+ for file_header_key , file_header_value in file_headers .items ():
397+ boundary_objects .append (
398+ f"{ file_header_key } : { file_header_value } \r \n "
399+ )
400+ boundary_objects .append ("\r \n " )
401+
402+ if hasattr (file_handle , "read" ):
403+ content_length += self ._get_file_length (file_handle )
404+
405+ boundary_objects .append (file_handle )
406+ boundary_objects .append ("\r \n " )
407+
408+ boundary_objects .append (f"--{ boundary_string } --\r \n " )
409+
410+ for boundary_object in boundary_objects :
411+ if isinstance (boundary_object , str ):
412+ content_length += len (boundary_object )
413+
414+ return boundary_string , content_length , boundary_objects
415+
416+ @staticmethod
417+ def _build_boundary_string ():
418+ return os .urandom (16 ).hex ()
419+
357420 @staticmethod
358421 def _check_headers (headers : Dict [str , str ]):
359422 if not isinstance (headers , dict ):
360- raise AttributeError ( "headers must be in dict format" )
423+ raise TypeError ( "Headers must be in dict format" )
361424
362425 for key , value in headers .items ():
363426 if isinstance (value , (str , bytes )) or value is None :
364427 continue
365- raise AttributeError (
428+ raise TypeError (
366429 f"Header part ({ value } ) from { key } must be of type str or bytes, not { type (value )} "
367430 )
368431
432+ @staticmethod
433+ def _get_file_length (file_handle : IO ):
434+ is_binary = False
435+ try :
436+ file_handle .seek (0 )
437+ # read at least 4 bytes incase we are reading a b64 stream
438+ content = file_handle .read (4 )
439+ is_binary = isinstance (content , bytes )
440+ except UnicodeError :
441+ is_binary = False
442+
443+ if not is_binary :
444+ raise ValueError ("Files must be opened in binary mode" )
445+
446+ file_handle .seek (0 , SEEK_END )
447+ content_length = file_handle .tell ()
448+ file_handle .seek (0 )
449+ return content_length
450+
369451 @staticmethod
370452 def _send (socket : SocketType , data : bytes ):
371453 total_sent = 0
@@ -391,6 +473,22 @@ def _send(socket: SocketType, data: bytes):
391473 def _send_as_bytes (self , socket : SocketType , data : str ):
392474 return self ._send (socket , bytes (data , "utf-8" ))
393475
476+ def _send_boundary_objects (self , socket : SocketType , boundary_objects : Any ):
477+ for boundary_object in boundary_objects :
478+ if isinstance (boundary_object , str ):
479+ self ._send_as_bytes (socket , boundary_object )
480+ else :
481+ self ._send_file (socket , boundary_object )
482+
483+ def _send_file (self , socket : SocketType , file_handle : IO ):
484+ chunk_size = 36
485+ b = bytearray (chunk_size )
486+ while True :
487+ size = file_handle .readinto (b )
488+ if size == 0 :
489+ break
490+ self ._send (socket , b [:size ])
491+
394492 def _send_header (self , socket , header , value ):
395493 if value is None :
396494 return
@@ -411,6 +509,7 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
411509 headers : Dict [str , str ],
412510 data : Any ,
413511 json : Any ,
512+ files : Optional [Dict [str , tuple ]],
414513 ):
415514 # Check headers
416515 self ._check_headers (headers )
@@ -421,11 +520,13 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
421520 # If json is sent, set content type header and convert to string
422521 if json is not None :
423522 assert data is None
523+ assert files is None
424524 content_type_header = "application/json"
425525 data = json_module .dumps (json )
426526
427527 # If data is sent and it's a dict, set content type header and convert to string
428528 if data and isinstance (data , dict ):
529+ assert files is None
429530 content_type_header = "application/x-www-form-urlencoded"
430531 _post_data = ""
431532 for k in data :
@@ -437,6 +538,23 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
437538 if data and isinstance (data , str ):
438539 data = bytes (data , "utf-8" )
439540
541+ # If files are send, build data to send and calculate length
542+ content_length = 0
543+ data_is_file = False
544+ boundary_objects = None
545+ if files and isinstance (files , dict ):
546+ boundary_string , content_length , boundary_objects = (
547+ self ._build_boundary_data (files )
548+ )
549+ content_type_header = f"multipart/form-data; boundary={ boundary_string } "
550+ elif data and hasattr (data , "read" ):
551+ data_is_file = True
552+ content_length = self ._get_file_length (data )
553+ else :
554+ if data is None :
555+ data = b""
556+ content_length = len (data )
557+
440558 self ._send_as_bytes (socket , method )
441559 self ._send (socket , b" /" )
442560 self ._send_as_bytes (socket , path )
@@ -452,16 +570,20 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
452570 self ._send_header (socket , "User-Agent" , "Adafruit CircuitPython" )
453571 if content_type_header and not "content-type" in supplied_headers :
454572 self ._send_header (socket , "Content-Type" , content_type_header )
455- if data and not "content-length" in supplied_headers :
456- self ._send_header (socket , "Content-Length" , str (len ( data ) ))
573+ if ( data or files ) and not "content-length" in supplied_headers :
574+ self ._send_header (socket , "Content-Length" , str (content_length ))
457575 # Iterate over keys to avoid tuple alloc
458576 for header in headers :
459577 self ._send_header (socket , header , headers [header ])
460578 self ._send (socket , b"\r \n " )
461579
462580 # Send data
463- if data :
581+ if data_is_file :
582+ self ._send_file (socket , data )
583+ elif data :
464584 self ._send (socket , bytes (data ))
585+ elif boundary_objects :
586+ self ._send_boundary_objects (socket , boundary_objects )
465587
466588 def request ( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many arguments in function definition,Too many statements
467589 self ,
@@ -473,6 +595,7 @@ def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many argumen
473595 stream : bool = False ,
474596 timeout : float = 60 ,
475597 allow_redirects : bool = True ,
598+ files : Optional [Dict [str , tuple ]] = None ,
476599 ) -> Response :
477600 """Perform an HTTP request to the given url which we will parse to determine
478601 whether to use SSL ('https://') or not. We can also send some provided 'data'
@@ -521,7 +644,9 @@ def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many argumen
521644 )
522645 ok = True
523646 try :
524- self ._send_request (socket , host , method , path , headers , data , json )
647+ self ._send_request (
648+ socket , host , method , path , headers , data , json , files
649+ )
525650 except OSError as exc :
526651 last_exc = exc
527652 ok = False
0 commit comments