1313import inspect
1414import io
1515import json
16+ import re
1617import socket
1718import threading
1819from pathlib import Path
3839 _EXECUTE_SEND_ONLY ,
3940 _STATUS_SEND_ONLY ,
4041 APIDeploymentsClient ,
42+ APIDeploymentsClientException ,
4143)
4244
4345BASELINE_VERSION = "1.5.3"
@@ -623,15 +625,15 @@ def test_execute_url_matches_the_deployment_url(api_url):
623625 assert args [1 ] == api_url == mock_requests .post .call_args [0 ][0 ]
624626
625627
626- def _wire_heads (* calls ):
627- """Run each call against a loopback server and return its request headers .
628+ def _wire_requests (* calls , reply = b'{"status":"COMPLETED","message":{}}' ):
629+ """Run each call against a loopback server and return the raw requests .
628630
629631 Below the client, the transport adds headers of its own -- and drops none
630632 of them into any object the client can be asked for. A socket is the only
631633 place both clients can be compared on what they actually send. One server
632634 serves every call, so the ``Host`` header is the same for all of them.
633635 """
634- heads = []
636+ raw = []
635637 server = socket .socket ()
636638 server .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
637639 server .bind (("127.0.0.1" , 0 ))
@@ -646,8 +648,17 @@ def serve():
646648 if not chunk :
647649 break
648650 data += chunk
649- heads .append (data .split (b"\r \n \r \n " )[0 ])
650- body = b'{"status":"COMPLETED","message":[]}'
651+ # The body has to be drained too: a client whose upload is never
652+ # read can block on the socket instead of returning.
653+ head , _ , rest = data .partition (b"\r \n \r \n " )
654+ declared = _header_value (head , "content-length" )
655+ while declared and len (rest ) < int (declared ):
656+ chunk = conn .recv (65536 )
657+ if not chunk :
658+ break
659+ rest += chunk
660+ raw .append (head + b"\r \n \r \n " + rest )
661+ body = reply
651662 conn .sendall (
652663 b"HTTP/1.1 200 OK\r \n Content-Type: application/json\r \n "
653664 b"Content-Length: %d\r \n \r \n %s" % (len (body ), body )
@@ -664,15 +675,51 @@ def serve():
664675 thread .join (timeout = 10 )
665676 server .close ()
666677
667- return [
668- {
669- name .lower (): value .strip ()
670- for name , _ , value in (
671- line .partition (":" ) for line in head .decode ().split ("\r \n " )[1 :]
678+ return raw
679+
680+
681+ def _headers (head : bytes ) -> dict [str , str ]:
682+ return {
683+ name .lower (): value .strip ()
684+ for name , _ , value in (
685+ line .partition (":" ) for line in head .decode ().split ("\r \n " )[1 :]
686+ )
687+ }
688+
689+
690+ def _header_value (head : bytes , name : str ) -> str :
691+ return _headers (head ).get (name , "" )
692+
693+
694+ def _wire_heads (* calls ):
695+ """The request headers each call put on the wire."""
696+ return [_headers (raw .split (b"\r \n \r \n " )[0 ]) for raw in _wire_requests (* calls )]
697+
698+
699+ def _multipart_parts (raw : bytes ) -> list [tuple [str , str , bytes ]]:
700+ """``(field, filename, content)`` for every part of a multipart request.
701+
702+ The boundary itself is deliberately not compared: it is random per request
703+ in both clients, so only what it delimits can be.
704+ """
705+ head , _ , body = raw .partition (b"\r \n \r \n " )
706+ boundary = _header_value (head , "content-type" ).partition ("boundary=" )[2 ]
707+ parts = []
708+ for chunk in body .split (b"--" + boundary .encode ()):
709+ headers , _ , content = chunk .partition (b"\r \n \r \n " )
710+ disposition = headers .decode ("utf-8" , errors = "replace" )
711+ if "content-disposition" not in disposition .lower ():
712+ continue
713+ field = re .search (r'name="([^"]*)"' , disposition )
714+ filename = re .search (r'filename="([^"]*)"' , disposition )
715+ parts .append (
716+ (
717+ field .group (1 ) if field else "" ,
718+ filename .group (1 ) if filename else "" ,
719+ content .removesuffix (b"\r \n " ),
672720 )
673- }
674- for head in heads
675- ]
721+ )
722+ return parts
676723
677724
678725def test_wire_headers_match_the_released_client ():
@@ -697,6 +744,50 @@ def test_wire_headers_match_the_released_client():
697744 assert ours ["user-agent" ].startswith ("python-httpx/" )
698745
699746
747+ def test_a_multi_file_upload_matches_the_released_client (tmp_path ):
748+ """The method takes a list, and the second file is where a transport swap
749+ diverges: one part written, one dropped, or two parts sharing a name the
750+ server then reads as one."""
751+ paths = []
752+ for name , content in (("first.txt" , b"one" ), ("second.txt" , b"two" )):
753+ path = tmp_path / name
754+ path .write_bytes (content )
755+ paths .append (str (path ))
756+
757+ ours , theirs = _wire_requests (
758+ lambda url : _client (api_url = url , api_timeout = 300 ).structure_file (paths ),
759+ lambda url : _baseline_client (api_url = url , api_timeout = 300 ).structure_file (
760+ paths
761+ ),
762+ )
763+
764+ # Sorted: the two clients order the fields differently, which no multipart
765+ # parser reads as meaning. The order of the files among themselves is the
766+ # part that carries meaning, and it is pinned below.
767+ assert sorted (_multipart_parts (ours )) == sorted (_multipart_parts (theirs ))
768+ uploaded = [part for part in _multipart_parts (ours ) if part [0 ] == "files" ]
769+ assert [(filename , content ) for _ , filename , content in uploaded ] == [
770+ ("first.txt" , b"one" ),
771+ ("second.txt" , b"two" ),
772+ ]
773+
774+
775+ def test_redirects_are_followed ():
776+ """The released client followed them on both verbs.
777+
778+ Not following one turns a load balancer's 307 into a body the poll loop
779+ reads as a finished execution with no status.
780+ """
781+ assert _client ()._transport .get_httpx_client ().follow_redirects is True
782+
783+
784+ def test_a_status_endpoint_without_an_execution_id_is_refused ():
785+ """Polling with a blank id asks the service about an execution nobody has;
786+ what it answers is not this execution's state."""
787+ with pytest .raises (APIDeploymentsClientException ):
788+ _client ().check_execution_status ("/deployment/api/testorg/testapi/" )
789+
790+
700791def test_the_transport_is_untimed_by_default ():
701792 """A connection that stalls forever is what the released client did.
702793
0 commit comments