Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-10-20
Added
- The
pycurlconsole script andpython -m curlike: the drop-in curl (curlike.shim.main).main(argv=None, *, entry=None)takes the executable acting as this curl, whose directory is skipped when looking for the real curl and which hook wrappers receive as<P>_HOOK_CURL. curlike.main(),curlike.run_fallback()andcurlike.shim.main()takessl_context=/proxy_ssl_context=: a caller'sssl.SSLContextused as given for every https origin or anhttps://proxy (mutual TLS, ciphers, protocol versions). The TLS flags it replaces are refused beside it (exit 2).- With no proxy variable set, the system's proxy configuration routes each
URL on the command line (through syshttp: WinHTTP, CFNetwork, libproxy, PAC,
WPAD); its redirect hops keep that decision, as with curl's own
--proxy. The real curl is handed that proxy with--proxy(curl reads only the*_proxyvariables).SYSHTTP_SYSTEM_PROXY=0turns it off. Of a list (PROXY a; PROXY b; DIRECT) the first proxy that accepts a connection is used; with none and no DIRECT, exit 7 and real curl is not run.
Changed
-
curlike is a pure-Python curl and never runs the real one:
pycurlandpython -m curlikearecurlike.main. The launcher that prefers the real curl -- hook wrappers, the agent proxy handed to curl as--proxy, the<P>_CURL,<P>_CURL_SSL_REVOKEand<P>_PYTHONsettings -- moved to the dotagents net overlay (lib/curlshim). New for such a launcher:curlike.caller_steering(),requested_url(),find_real_curl()anddescribe_curl(). -
CI installs syshttp at one pinned commit (the 0.1.0.dev7 floor) in every workflow, so a run is reproducible and the floors job tests the floor.
-
There is no hidden 30 s time limit: --connect-timeout bounds the connect only, -m is a deadline for the whole attempt (redirect hops and a trickling body included), and --timeout (not a curl option) is opt-in. -N is real, and the "Output written to FILE" line, which curl never prints, is gone.
-
The default request is curl's: User-Agent: curl/8.22.0 and Accept: /, and no Accept-Encoding: identity. Connection: close remains, the one difference, because each transfer is one request on one connection.
-
Two URLs, an unknown option and a missing option value are curl-style one- line usage errors (exit 2, with the --help hint) instead of argparse's 52-line usage dump.
-
The duho floor is 0.7.0, the first release with duho.Meta (the suite passes at it); -v is documented as printing the request as given, the caller's Authorization and Cookie included, with only the configured proxy's credential redacted.
-
The test run is warning-clean: the fake servers close the connections they open, and the test configuration turns every warning, ResourceWarning included, into an error.
-
Settings are read as syshttp 0.1.0.dev19, the new floor, reads them: every variable has a full name, read as
<PREFIX><NAME>under each prefix ofSYSHTTP_PREFIX, a:-separated list of literal prefixes (none by default: the names as written). Where the entries here write<P>_NAME, the variable isSYSHTTP_NAME.SYSHTTP_PREFIX=AGENTS_(it wasAGENTS) readsAGENTS_SYSHTTP_PROXY,AGENTS_HTTPS_PROXY,AGENTS_NO_PROXYandAGENTS_HTTP_PROXY_AUTHand nothing unprefixed: the conventional proxy variables used to ignore the prefix, and now follow it.AGENTS_:reads the prefixed names first and falls back to the plain ones.CURL_CA_BUNDLEis curl's own variable and is read as curl names it, under no prefix. <P>_PYLIBisCURLIKE_PYLIB, a full name read under each prefix like the others;SYSHTTP_PYLIBis no longer read.no_proxyis read beforeNO_PROXY, as curl does (it was the other way round, which showed only when both were set to different lists).- A proxy setting that is refused (exit 2) names the variable that was found,
under its prefix:
AGENTS_SYSHTTP_PROXY_TYPEforSYSHTTP_PREFIX=AGENTS_. - Cookies follow syshttp's stricter rules, which agree with curl 8.21 on the
cases measured: a
Set-Cookieheader with a control character (the tab apart) is ignored whole, a-bfile's row with one in its name or value is skipped, and-cwrites no row with a control character or a tab in a field. - The syshttp floor is 0.1.0.dev19 (it was 0.1.0.dev14) and the CI workflows install its commit.
Removed
<P>_CURL_COMPATand the per-version answers behind it (curlike.compat): curlike answers as curl 8.22, the latest stable release, and follows new releases.find_real_curl()anddescribe_curl()are unchanged.-
curlike.shim(main,default_entry,find_real_curl): the dotagents net overlay'slib/curlshimis the launcher now. -
Routing comes from syshttp 0.1.0.dev6's
proxy.proxy_map()over proxylib 2 (now a direct dependency): the*_PROXYvariables are read per scheme,NO_PROXYtakes CIDR entries,HTTP_PROXY_AUTHgoes to the machine's proxy (a*_PROXYvariable's or the system's) and<P>_PROXY_AUTHto<P>_PROXYonly. Of a list the first proxy that answers is used; a lone proxy is not probed. The exit-7 message isno proxy of the list is reachable: host:port, .... -
The package stands alone. It was previously the dotagents
netoverlay's curl shim (bin/curl.py) and its fallback (httplib.cli,python -m httplib). The fallback is nowcurlike(curlike.main), and the shim iscurlike.shim. It runs on syshttp, the overlay'shttplibas a package of its own. - Settings follow syshttp's prefix (
SYSHTTP_PREFIX, defaultSYSHTTP):NET_CURL→SYSHTTP_CURL,NET_CURL_COMPAT→SYSHTTP_CURL_COMPAT,NET_CURL_SSL_REVOKE→SYSHTTP_CURL_SSL_REVOKE,AGENTS_PYTHON→SYSHTTP_PYTHON,AGENTS_PYLIB→SYSHTTP_PYLIB. The proxy and hook variables are syshttp's (SYSHTTP_PROXY,SYSHTTP_HOOKS_<KEY>_CURL). WithSYSHTTP_PREFIX=AGENTS, theAGENTS_*names are read instead. - The fallback's TLS is the system's by default (syshttp's sslcontext system
context): the operating system verifies certificates on Python 3.10+, and a
server that asks for a client certificate gets one from the OS store, chosen
as a browser chooses.
--certturns the store off: the named certificate is the one sent.--crlfilekeeps an OpenSSL context, because the OS verifier ignores CRL flags. - A malformed proxy setting is reported as
curl: ignoring the configured proxy: …(wasignoring the agent proxy). curl -Vfrom the fallback sayscurl 0.0.0-curlike-shim … -- curlike fallback, and%{json}'scurl_versionsays(curlike fallback).
Fixed
-k,--proxy-insecureand--doh-insecurebuild a plainsslcontext. The OS verifier (truststore) on macOS still evaluated the chain under them and failed with 60.-
--local-portwith a range moves to the next port whenconnect()finds the port in use. Windows reports a port whose last connection to the same address is in TIME_WAIT there, not atbind(), and the fallback used to give up with exit 7. -
-L follows redirects in curlike's own loop, as curl does: 50 hops by default (--max-redirs -1 is unlimited, 0 refuses the first; exit 47 past the limit); a redirect to ftp:// is exit 1 instead of a direct connection outside the proxy and every connection option, and file:// or gopher:// are exit 1; a -H Host goes only to the first request's host; -D and -i show every hop's headers; -e 'URL;auto' sets each hop's Referer to the page it came from; --location-trusted implies -L.
-
With the cookie engine (-b FILE or -c), the cookies a redirect hop sets are sent to the next hop and filed under that hop's host, not the first URL's.
-
--digest and --anyauth answer a server's 401 once, with one implementation shared with the proxy's: a rejected password is sent once (two requests, not seven) and the 401 and its body are the response; a challenge curlike cannot answer (Negotiate, Bearer) is the response too, not exit 2; algorithm=SHA-256 works on Python 3.9; quotes in the user name or realm are escaped.
-
The netrc is read as curl reads it: quoted words with backslash escapes, comments, a machine name in any case, the first matching entry in file order, a login with no password (an empty one, not None), and the login a user@ URL names. -u USER asks for the password, as curl does.
-
-c no longer stores a cookie whose Domain the host that set it does not domain-match, or a public suffix, and a cookie with no Path gets the directory of the request (RFC 6265, through syshttp 0.1.0.dev14, now the floor).
-
The body is streamed, not held in memory (a 200 MB download peaks near 40 MB, not 470 MB, and the first byte reaches stdout at once): --max-filesize stops a body of unknown length at the cap, --compressed decodes piece by piece, and -T reads its file as it sends it.
-
Response limits are curl's: headers over 300 KB are exit 56, over 5000 headers exit 100, a conflicting or negative Content-Length or a NUL in a header exit 8. http.client's limits and socket.getaddrinfo are no longer replaced for the length of a transfer (concurrent calls cannot leave them changed).
-
A 206 answering -C must start at the resume offset or it is exit 33 (the file is left alone), and --remove-on-error removes a file the failed transfer overwrote; with -C it is exit 2, as in curl.
-
--retry covers a name that does not resolve and, under --retry-all-errors, a body cut short; every failed attempt prints its error line; the warning reads "Retrying in N seconds" (curl 8.21); a Retry-After of 0 is none, and one past --retry-max-time ends the retries with curl's warning.
-
--crlfile loads only the CRLs of its file: a CA certificate inside it is no longer trusted (a file with no CRL is exit 82). The contexts curlike builds no longer apply Python 3.13's strict X.509 checks, so a leaf with no Authority Key Identifier verifies as it does with curl and on older Pythons.
-
--proxy-pinnedpubkey is checked on a proxy connection reopened after a 407, and --pinnedpubkey, -0, --interface, --local-port and the -w timings apply over --unix-socket (it now goes through the one connector); a CONNECT carries a single Host line on Python 3.12+; an over-long SOCKS5 user name, password or host name is exit 97; an unreadable --pinnedpubkey file no longer fails an http:// transfer; --doh-url with an unusable --cacert fails the lookup (77) instead of falling back to the OS store.
-
-H sends a repeated name once per value, replaces only a default of the same name, and -H 'Name:' removes Host, Connection, Accept-Encoding and Content- Type too, as the header always claimed. Header values go out as UTF-8 (they were refused or sent as latin-1), and response header bytes are written back unchanged by -i, -D and -w, with the status line's HTTP version; -w @file is read and written as bytes. A URL with a space or control character is exit 3 (not 8) and nothing is sent; non-ASCII characters in a URL are sent percent- encoded as UTF-8 (a host as IDNA) instead of exit 2.
-
-J reads filename= only at a parameter's start and gives a Windows name as curl does (no drive-relative C:evil out of --output-dir, no device names); -O with no name in the URL writes the last segment or curl_response (exit 23 before); a -c jar that cannot be written no longer ends in a traceback; --etag-save is created before the transfer (26 when it cannot be); -b is a file when it has no = (a missing one holds nothing, - is stdin) and -c - writes the jar to stdout; -d @file also drops NUL and an unreadable data, -H or -w file is curl's three lines; --data-urlencode and --url-query write a space as +; -d x --json y joins with no &; -F takes @"file;name" and type=...;charset=..., and no longer types .json as application/json; -T sends no Content-Type and adds no file name to a URL with a query; -I -X POST sends POST; %{json} writes "" for the addresses of a transfer that never connected; the connect failure reads host:port.
-
Messages follow curl 8.21's words where they had drifted: a bad chunk names the byte (0x7a), a bad gzip stream drops zlib's prefix, a refused proxy connection says "over proxy", a missing --cacert, --crlfile, --netrc-file or --proxy-cacert file is wrapped and followed by the option hint, an unreadable -d, -H or -w file wraps its path, a -T file that is not there prints curl's three lines, an unknown --proto name prints no first line, a proxy with no scheme is shown as http://..., and the glob error words match.
-
curlike.caller_steering()andrequested_url()read argv with curl 8.23's own table of the options that take a value: those the fallback never declared (22 long ones in curl 8.21:--proxy1.0,--hsts,--rate, ..., and-P) no longer leave their value as the URL, and--expand-NAMEis read as--NAME. A--proxy1.0,--preproxyor-K/--config(alone or inside a short cluster such as-sK) counts as the caller naming a proxy.caller_steeringno longer needs duho. -
The proxy-syntax error (exit 5) hides the whole userinfo as
***(http://***@host), whatever the password holds (/,?,#,@); it used to print a password containing/, or its tail after an@. -
Ctrl-C ends
curlike.main()with exit 130 and nothing printed, as with curl, instead of a Python traceback.
Security
-
Answering a 401 (
--digest,--anyauth) for an https URL through an HTTP proxy re-sent the request in clear inside the tunnel: the password or Digest answer, cookies and every-Hvalue were readable by the proxy and the network, and the request then failed with exit 52. With-p, the second CONNECT went to the proxy's own address. The answer now goes over a CONNECT to the origin and the origin's TLS, as the first attempt did. -
-Oand--remote-name-allpercent-decoded the URL's last path segment, so..%2F..%2Fname(and..%5C..%5Cname,C%3A%5C...on Windows) wrote outside the current or--output-dirdirectory, overwriting what was there. The file is named after the segment as written, as curl does (.and..resolved, a backslash separating); on Windows:becomes_and a device name such asCONgets a_. -
--compresseddecodes in pieces of at most 16 KiB per layer: a 1.7 KB body withContent-Encoding: gzip, gzipheld about 2 GB in memory. A response naming more than 5 content encodings (curl's limit) is exit 61. -
--proxy-anyauthand--proxy-digestanswer only a 407 that the proxy sent. An origin's 407, from a host reached directly (--noproxy,NO_PROXY, a redirect hop that bypasses the proxy) or inside a tunnel, is the response, and the proxy credential is never sent to it. -
A proxy URL with a path, query or fragment (
-x,<P>_PROXY,HTTP_PROXY) is the proxy, as in curl, instead of exit 2 with a message that printed the password in full. A refused proxy setting never shows the password or the-Ucredential in its message. -
A
-bjar file is split into rows at a newline only (syshttp 0.1.0.dev19): a value holding a vertical tab, a form feed or another character thatstr.splitlinesends a line at could end its row early and start a row for another domain. A row with a control character in its name or value is skipped, as curl skips it.