41
41
42
42
import errno
43
43
import json as json_module
44
+ import os
44
45
import sys
45
46
46
47
from adafruit_connection_manager import get_connection_manager
47
48
49
+ SEEK_END = 2
50
+
48
51
if not sys .implementation .name == "circuitpython" :
49
52
from types import TracebackType
50
- from typing import Any , Dict , Optional , Type
53
+ from typing import IO , Any , Dict , Optional , Type
51
54
52
55
from circuitpython_typing .socket import (
53
56
SocketpoolModuleType ,
@@ -83,6 +86,21 @@ class Response:
83
86
"""The response from a request, contains all the headers/content"""
84
87
85
88
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``."""
86
104
87
105
def __init__ (self , sock : SocketType , session : "Session" ) -> None :
88
106
self .socket = sock
@@ -245,7 +263,8 @@ def _parse_headers(self) -> None:
245
263
header = self ._readto (b"\r \n " )
246
264
if not header :
247
265
break
248
- title , content = bytes (header ).split (b": " , 1 )
266
+ title , content = bytes (header ).split (b":" , 1 )
267
+ content = content .strip ()
249
268
if title and content :
250
269
# enforce that all headers are lowercase
251
270
title = str (title , "utf-8" ).lower ()
@@ -318,7 +337,7 @@ def json(self) -> Any:
318
337
obj = json_module .load (self ._raw )
319
338
if not self ._cached :
320
339
self ._cached = obj
321
- self . close ()
340
+
322
341
return obj
323
342
324
343
def iter_content (self , chunk_size : int = 1 , decode_unicode : bool = False ) -> bytes :
@@ -354,18 +373,81 @@ def __init__(
354
373
self ._session_id = session_id
355
374
self ._last_response = None
356
375
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
+
357
420
@staticmethod
358
421
def _check_headers (headers : Dict [str , str ]):
359
422
if not isinstance (headers , dict ):
360
- raise AttributeError ( "headers must be in dict format" )
423
+ raise TypeError ( "Headers must be in dict format" )
361
424
362
425
for key , value in headers .items ():
363
426
if isinstance (value , (str , bytes )) or value is None :
364
427
continue
365
- raise AttributeError (
428
+ raise TypeError (
366
429
f"Header part ({ value } ) from { key } must be of type str or bytes, not { type (value )} "
367
430
)
368
431
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
+
369
451
@staticmethod
370
452
def _send (socket : SocketType , data : bytes ):
371
453
total_sent = 0
@@ -391,6 +473,22 @@ def _send(socket: SocketType, data: bytes):
391
473
def _send_as_bytes (self , socket : SocketType , data : str ):
392
474
return self ._send (socket , bytes (data , "utf-8" ))
393
475
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
+
394
492
def _send_header (self , socket , header , value ):
395
493
if value is None :
396
494
return
@@ -411,6 +509,7 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
411
509
headers : Dict [str , str ],
412
510
data : Any ,
413
511
json : Any ,
512
+ files : Optional [Dict [str , tuple ]],
414
513
):
415
514
# Check headers
416
515
self ._check_headers (headers )
@@ -421,11 +520,13 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
421
520
# If json is sent, set content type header and convert to string
422
521
if json is not None :
423
522
assert data is None
523
+ assert files is None
424
524
content_type_header = "application/json"
425
525
data = json_module .dumps (json )
426
526
427
527
# If data is sent and it's a dict, set content type header and convert to string
428
528
if data and isinstance (data , dict ):
529
+ assert files is None
429
530
content_type_header = "application/x-www-form-urlencoded"
430
531
_post_data = ""
431
532
for k in data :
@@ -437,6 +538,23 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
437
538
if data and isinstance (data , str ):
438
539
data = bytes (data , "utf-8" )
439
540
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
+
440
558
self ._send_as_bytes (socket , method )
441
559
self ._send (socket , b" /" )
442
560
self ._send_as_bytes (socket , path )
@@ -452,16 +570,20 @@ def _send_request( # noqa: PLR0913 Too many arguments in function definition
452
570
self ._send_header (socket , "User-Agent" , "Adafruit CircuitPython" )
453
571
if content_type_header and not "content-type" in supplied_headers :
454
572
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 ))
457
575
# Iterate over keys to avoid tuple alloc
458
576
for header in headers :
459
577
self ._send_header (socket , header , headers [header ])
460
578
self ._send (socket , b"\r \n " )
461
579
462
580
# Send data
463
- if data :
581
+ if data_is_file :
582
+ self ._send_file (socket , data )
583
+ elif data :
464
584
self ._send (socket , bytes (data ))
585
+ elif boundary_objects :
586
+ self ._send_boundary_objects (socket , boundary_objects )
465
587
466
588
def request ( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many arguments in function definition,Too many statements
467
589
self ,
@@ -473,6 +595,7 @@ def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many argumen
473
595
stream : bool = False ,
474
596
timeout : float = 60 ,
475
597
allow_redirects : bool = True ,
598
+ files : Optional [Dict [str , tuple ]] = None ,
476
599
) -> Response :
477
600
"""Perform an HTTP request to the given url which we will parse to determine
478
601
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
521
644
)
522
645
ok = True
523
646
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
+ )
525
650
except OSError as exc :
526
651
last_exc = exc
527
652
ok = False
0 commit comments