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
Nothing yet.
0.2.0 - 2026-10-15
Changed
tftp.pathis built onpathapianduripath. Thepathextra installspathapi>=0.1.0,<0.2andpathapi-uri>=0.1.0,<0.2where it installedpathlib-next[uri], which cannot be installed beside them. To upgrade, importLocalPath,MemPathandFileStatfrompathapiinstead ofpathlib_next, and spell the URI pathfrom uripath import URIPath; URIPath("tftp://host/file")where it waspathlib_next.uri.UriPath.TFTPPathandTFTPURIPathkeep their names, arguments and methods, andURIPath("tftp://...")findsTFTPURIPathwithout an import, now through theuripath.schemesentry-point group.unlink()always raisesNotImplementedError.unlink(missing_ok=True)used to return without doing anything, so thatcopy(overwrite=True)could clear its target.pathapino longer asks a target that cannot be removed to do that, and the write that follows still replaces the file, so a caller ofcopy()changes nothing. A program that calledunlink(missing_ok=True)itself must stop, or askpathapi.protocols.supports(path, Remove)first.move()from a TFTP path raisesNotImplementedErrorbefore it copies anything. It used to copy the file and then raise because the source could not be deleted, which left a stray copy at the destination. Checksupports(path, Remove)to know in advance.stat()says when the server gave no size. A server that does not sendtsize(and a file longer than one block) used to givest_size == 0as if it were known; the result now hassize_known == False, andst_sizeis still 0. A caller that needs the size reads the file or uses a server that reports it.
Added
pathapi.protocols.supports(path, Listing)answers for the server: onex-listprobe of its root on the first question, kept for the life of the client,Falsefor a server without the extension. The classes implementStat,BinaryOpen,Listing,IdentityandCopy, andcapabilities()of either says so.
Fixed
- A failed or abandoned upload no longer replaces the server's file. A
withblock overopen("w")that raised, a text write that could not be encoded (write_text("\u00e9", encoding="ascii")) and a file collected unclosed all uploaded what had been written, even an empty file. Each now sends nothing and the server keeps the file it had; a stream collected unclosed emits oneResourceWarning. Closing a stream normally still uploads.
0.1.1 - 2026-10-14
Changed
- The
cliextra requiresduho>=0.7.4,<0.8. With it every error of the settings file is one line and status 2: a key or a table nobody has, and a key a command does not have, no longer print the usage lines before the line that names the file and the key. Aconfigkey in the file is refused by duho's own check, in its words (pytftp: error: duho: config file <file>: unknown key 'config').
0.1.0 - 2026-10-12
This release breaks the documented API: names, public module paths, constructor signatures and what malformed input raises. Old names are not kept as aliases. "Renamed" is the lookup from an old name to its new one, "Removed" lists what is gone, "Changed" has the detail of each behaviour that differs and "Fixed" the defects corrected on the way.
Added
tftp.relay.AsyncTFTPRelayandAsyncRouteFunction:TFTPRelayon an asyncio event loop. A sibling over the same rules, with the same arguments, ERROR texts, counters and bounds, run on Windows' default Proactor loop as well; its route is anasync def(a plain function is aTypeErrorwhen the relay is built), and a route or a name lookup that outlastsidle_timeoutanswers the client ERROR 0 "relay error". Stopping ends every transfer in flight with ERROR 0 "relay shutting down" to both ends. Importingtftp.relaystill imports no asyncio.TransferResult.to_dict()andRelaySummary.to_dict(): the JSON-ready objects the command line prints for--json, one per transfer.TFTPServerOptions.replace(**changes): a copy with the named fields changed, built through the constructor asdataclasses.replacedoes.tftp.capture.combine_hooks(*hooks): onetrace=hook from several; a hook that raises does not keep the others from the event.tftp.capture.trace_to(writer): a trace hook that appends every event to a writer, andDatagramWriter, theProtocolit takes (write(time, source, destination, payload)).pktcap.PcapWriterandpktcap.PcapngWriterare both one, so a run can be recorded as pcapng too.tftp.capture.dissect_tftp,TFTPLayerandregister_tftp_dissector(registry=None, *, ports=(69,)): TFTP as a layer for pktcap's dissection, sopktcap.read_dissectedgives aTFTPLayeron the datagrams to the request port,pktcap.frame_recordwrites it and the filterproto=tftpselects it. Nothing registers on import; the call registers in the registry given, or in pktcap's default one.TFTPLayer.sessionandTFTPLayer.summary(), andtftp.capture.follow_transfers(frames, tracker): pktcap dissects TFTP by port, so only a datagram to or from the request port had a layer and a transfer's DATA, ACK and OACK, which run between other ports, had none.follow_transfersfeeds the dissected frames to aFlowTrackerand gives each datagram it attributes to a transfer its layer, with the transfer's id insession, so thatpktcap's filterop=DATA and tftp.session=c3selects one transfer's data and a record carries the id. A datagram a snap length cut and one that is not TFTP are left as they were.summary()is the line pktcap'stextoutput prints for the layer ([c3] RRQ 'boot.efi' octet).pktcap_pluginregisters the filter keysession.tftp.capture.pktcap_plugin(registry): the hook pktcap loads by name, soPKTCAP_LOAD=tftp.capture pktcap convert -i boot.pcap -f "op=RRQ and file=*.efi"filters TFTP with the keysop,file,blockandcode(oprefuses a name that is none of the six opcodes when the filter is compiled). It registers the layer andregister_tftp_dissector(registry)and, when that raises, nothing. Importingtftp.capturestill imports neither pktcap nor asyncio and registers nothing.tftp.capture.replay_transfers(source, host, port=69, *, ports, writes, speed, max_delay, limit, timeout, retries)andpytftp replay --input FILE --to HOST: ask a server you name for each transfer a capture holds again, paced by the capture's own times (speed,max_delayandlimitaspktcap.replay_schedulehas them). It is not a replay of datagrams, and it sends nothing to an address in the capture. Reads only by default;writes=True/--writesalso uploads what the capture holds of each write, which overwrites a file on the server, and a write the capture holds only partly is never replayed. It returnsReplayedTransfers(results, skipped).pytftp replayis pktcap'sReplaycommand subclassed (ReplayCmd(pktcap.cli.Replay), with the plugintftp.capturealways loaded):--input/-i FILEand--to HOST[:PORT](port 69 when left out),--request-port,--writes,--speed,--max-delay,--limit(counts transfers),--timeout/-t,--retries/-r,--json,--no-delay,--filter/-f(which frames reach the tracker) and--load.--source-portand--broadcastare refused by the parser: no captured datagram is sent, a client asks again.- pktcap is an optional dependency, the
pktcapextra (pip install "tftp[pktcap]");dependenciesnamesnetimpsalone. Importingtftp,tftp.captureandtftp.cli, transferring, serving and relaying need no pktcap. A function that reads, dissects, replays or writes a capture (analyzeof a path or a stream,replay_transfers,dissect_tftp,register_tftp_dissector,pktcap_plugin,trace_to) raisesImportErrorwith the linecaptures need the 'pktcap' extra: pip install "tftp[pktcap]"when it is missing;analyzeof datagrams needs none.pytftp capture,pytftp replayand--pcaponget,put,serveandrelayprint that line on standard error and end with status 1 before a socket is bound or a file created;pytftp --helpstill listscaptureandreplay.devinstallstftp[cli,path,pktcap]. CapturedTransfer.write_to(directory)writes a transfer's file underdirectorywhatever the capture called it, replacing the example in the capture guide that opened the peer's file name and the command's own private copy.by_prefixandby_subnettake"KEY=HOST[:PORT]"text in a sequence, in place of a pair.pytftp serve --max-duration SECONDS(the longest a transfer may run),--max-upload BYTES(the largest upload accepted; a directory only) and--per-client-only(a client with no directory of its own is refused, where--per-clientserves it the whole root, other clients' directories included; the help of--per-clientsays it is not isolation).tftp.transfer.Requester(send, server, opcode, filename, retries, now, *, ...): the client's opening exchange as an engine class that does no I/O, besideSenderandReceiver. It sends the request, repeats it with back-off, decides which datagram is the first answer, classifies it (OACK, DATA 1, ACK 0 or an ERROR) and says whether the request may be made again without options.TFTPClientandAsyncTFTPClientdrive it, and so can a client on another transport.- Documentation: a command-line guide in the docs site (checked against each command's
--help), an API page fortftp.listing, and the shipped API header split into a top header,tftp/AGENTS.md, and one header per package (tftp/client/AGENTS.md,server,options,backends,relay,capture,packet,path,transferandcli), with the environment variables and the loggers listed; a test checks every signature a header prints against the live object. - A settings file for the
pytftpcommand:--config/-c FILEbefore the command's name, elsePYTFTP_CONFIG, else the user's ownpytftp/pytftp.ini(under$XDG_CONFIG_HOME, else~/.config, or%APPDATA%; absent is no error). INI, JSON, TOML and YAML as duho reads them: top-level keys are the root's options and a table named for a command ([serve],[capture]) sets that command's options by field name, the argument over the file. An empty path (--config "",PYTFTP_CONFIGset empty) means no file, the user's own included. A key or table nobody has (duho's strict check: the usage lines, then one line that names the key, asserve.bogus, and never a value), aconfigkey, an invalid document, a file over 65,536 octets and (POSIX) a default file that is another user's or everyone may write are refused with status 2 and a line that names the file and holds no text of it; without thepktcapextra a[capture]or[replay]table is refused with the line that names the extra. The library reads no settings file. An on/off option now also has its opposite (--no-json,--no-trace,--no-write, ... and--tsize,--options,--createand--packetsfor the options that were--no-X), so the command line can switch off what a file switched on.
Changed
- The servers and the relays listen where a netimps listen specification says.
TFTPServer,AsyncTFTPServer,TFTPRelayandAsyncTFTPRelaytakelisten=(netimps.ListenLike, bound bynetimps.bind_listen) in place ofhost=andinterface=;port=stays and is the port an entry without one gets. An entry is an address ("192.0.2.10","[fe80::1%eth0]:6969"), an adapter name,netimps.Interfaceor MAC (the IPv4 wildcard socket limited to that adapter, which also hears its broadcasts, asinterface=did not on Linux),"*", or several as a list or joined by commas, and each is one socket. With nolistenthe server listens on every address of both families, as"*"and"::": two IPv6-only-clean sockets that share the port chosen forport=0, the IPv6 one left out on a host with no IPv6, where there was one dual-stack socket. A request is answered, refused and traced on the socket it arrived on, from the address it was sent to as before;max_sessionsand the other limits count across all the sockets; a datagram that reaches an adapter-limited socket from another adapter is dropped before it is read as a request. A host name is no longer resolved for listening:listen="localhost"is an adapter name andbind()refuses it.server_addressis the first bound address;server_addresseslists them all. pytftp serveandpytftp relayread--listenin that grammar, repeatable or comma-joined, with-pthe default port; a specification that names nothing bindable is one line and status 2. The settings file'slistenkey is a list of texts.pytftp captureis pktcap's command, subclassed (CaptureCmd(pktcap.cli.Capture), with the plugintftp.capturealways loaded), so reading a capture, the filter, the output and what a capture command takes are pktcap's, and it follows each transfer across its ports withfollow_transfers. A listing is pktcap'stextoutput withTFTPLayer.summary()after the addresses (2023-11-14T22:13:20.512345Z 192.0.2.5:2000 > 192.0.2.1:69 tftp: [c1] RRQ 'boot/ipxe.efi' octet), the default. The exit statuses are pktcap's: a capture file that cannot be opened or a destination that does not resolve is status 1, a file that is no capture or a value out of range 2, and a capture of a link type nothing dissects is status 0 with the count in the summary line on stderr. What moved:
pytftp capture before |
Now |
|---|---|
the positional SOURCE (- for standard input) |
--input/-i FILE (- for standard input); it excludes --interface |
--interface/-i |
--interface |
--filter/-f over events: op, host, src, dst, port, file, block, code, session, leg, direction |
pktcap's filter over frames, with op, file, block, code and session (or tftp.session) beside src, dst, host, sport, dport, port, proto; src=ADDR:PORT is src=ADDR and sport=PORT; leg and direction are gone |
the listing, one line a packet, with [c1] before the addresses |
the same packets, one pktcap line each, [c1] after tftp:; a DATA line has no size; a datagram that is not TFTP, one a snap length cut and an IPv4-mapped address kept as [::ffff:a.b.c.d] differ as the test of the listing names |
--json and --payload |
--format json: pktcap's record, with the tftp layer and its session, and a DATA's octets always |
--transfers on standard output (--json: an object each) |
one readable line each on standard error; a machine reads the transfers from FlowTracker or analyze |
--no-packets, --extract DIR, --port/-p |
unchanged |
| nothing printed for a capture of a link type nothing dissects, status 0 | the count in the summary line on stderr (3 frames read, 0 written, 3 skipped, 3 of an unsupported link type (105)), status 0; -q drops it |
| (new) | --output/-o, --format, --per-record, --max-files, --datagrams, --append, --count, --duration/-d, --hook, --hook-fail-fast, --hook-timeout, --load |
- The
pathextra needs pathlib-next 0.9.13:pathlib-next[uri]>=0.9.13,<0.10, where it was>=0.9.0,<0.10. From 0.9.12copy(overwrite=True)asks its target tounlink(missing_ok=True), which a TFTP path answers by doing nothing, and 0.9.13 types the classes a path is built from so that they are checked without an exemption. - The
cliextra needsduho0.7.3:duho>=0.7.3,<0.8, where it was>=0.6.0,<0.7. Thepytftpcommand, its options and its output are the same, but for the settings file above. AsyncTFTPClientreports an expireddeadlinein the first wait asTFTPClientdoes. Adeadlinethat passes before the server has answered raisesTransferTimeoutError("transfer exceeded its time limit"), whereAsyncTFTPClient.getand.downloadsaid "no response from HOST:PORT" (which is still what both clients say when the retries run out first). The class of the exception is the same.- Defaults that bound what a request can hold.
TFTPServerLimits(max_idle=60.0)is new: a transfer with no datagram from its peer for 60 seconds ends withTransferTimeoutError, and atimeoutthe client negotiated cannot extend it (passmax_idle=Nonefor the old behaviour: a request askingtimeout=255was held for over two hours). It counts the peer's silence, not the transfer's length.max_sessionsnow defaults to 500 on every platform, where it was unlimited outside Windows (Nonestill means unlimited, and on Windows 510, the mostselect()can watch).pytftp serve --max-sessionsdefaults to 500; 0 is unlimited. - A relay holds at most 250 transfers by default.
TFTPRelay(max_sessions=...)defaulted to unlimited outside Windows (two sockets per transfer, nothing bounding what requests could open); it is now 250 on every platform, and a request beyond it gets ERROR 0 "relay busy" as before.Nonestill means unlimited off Windows, and 250 on it.pytftp relay --max-sessionsis unchanged. TFTPRelayrefuses a coroutine route when it is built. A route that is anasync defraisesTypeErrorfrom the constructor, where each request used to log a traceback and be answered ERROR 0 "relay error" with the coroutine never awaited.TFTPError(code, message)raisesTypeErrorfor acodethat is not anint(soTFTPError("no such file")is refused instead of taking the text as its code) or amessagethat is not text, andValueErrorfor a code outside 0..65535.encode_errornever raises.- Options are keyword-only. A callable takes its operands by position and
every option after them by keyword.
TFTPServerOptions,TFTPServerLimitsandNegotiatedtake every field by keyword (TFTPServerOptions(65464, 64)is aTypeError).TFTPServer(root_or_handler, *, listen=None, port=69, ...),AsyncTFTPServer(root_or_handler, *, listen=None, port=69, ...)andTFTPRelay(route, *, listen=None, port=69, ...)take the handler (or the route) alone.encode_request(opcode, filename, *, mode, options),TFTPRequestContext(request, peer, *, local_address, interface_index),AtomicWriter(path, *, overwrite),DirectoryListing(directory, *, root),OptionRegistry.register(handler, *, replace),AsyncReaderBridge(source, *, capacity, size)andAsyncWriterBridge(sink, *, capacity, close_sink)follow the same rule.TFTPClient(host, port=69, ...),PortRange(low, high)andUpstream(host, port)keep the conventional pair. - The client's whole-transfer limit is
deadline.TFTPClient(max_duration=)andAsyncTFTPClient(max_duration=)aredeadline=: seconds, counted from when a transfer starts (client.deadlineis the attribute). It is not thedeadlineattribute ofSender,ReceiverandRelaySession, which is the instant the engine next wants to be called. The per-transfer cap of a policy has one name:TFTPRelay(max_lifetime=)ismax_duration=, asTFTPServerLimits(max_duration=)already was (thelifetimereason of aRelaySummaryis unchanged). - Argument names.
download(..., dest)anddownload_url(url, dest)aredst, andupload(..., source)andupload_url(url, source)aresrc, on both clients and the one-shot functions. The operands of the four one-shot functions are positional-only, sosrc=in theirclient_optionsis the client's source address, not the data.TFTPClient(local_address=)issrc=(client.src), andTFTPRelay(upstream_source=)isupstream_src=. The command line's flags are unchanged. TFTPClientandAsyncTFTPClientcheck their arguments when they are built, where a bad one failed at the first send or was accepted: aportoutside 1..65535 (it wasOverflowErrorat the first send), a negativeretries, adeadlinethat is not positive, afamilyother than0,AF_INETandAF_INET6, and asrcthat is not an(address, port)pair raiseValueError; astrport, a fractionalretries, aboolfor a number and asrcof the wrong shape raiseTypeError.backoffbelow 1 is aValueError, where it was silently stored as 1. The host text is still read when the transfer starts.- Requires
netimps>=0.4.1,<0.5; netimps 0.3 is no longer supported. TFTPClient(timeout=..., max_timeout=...)raisesValueErrorwhenmax_timeoutis belowtimeout, where it used to be raised totimeoutsilently.- Host text is read by netimps' stricter rules, for
TFTPClient,TFTPServer,TFTPRelayandupstream: a port is ASCII digits only ("host:+70"is refused) and brackets hold an IPv6 literal only ("[10.0.0.5]"is refused), each raisingValueError(netimps.NetimpsValueError); a host that is not text, an address, aHostor anFQDN(None, say) raisesTypeErrorwhere it raisedValueError. TFTPURLis a validated, immutable value, not a tuple. The constructor normalises (host lower-cased, mode lower-cased) and refuses what no URL can carry: a port outside 0..65535, a mode other thanoctetandnetascii, an empty file name or one with a NUL, a host that is empty or holds/ ? # @or a space (TFTPValueError), and an argument of the wrong type (TypeError). It equals anotherTFTPURLonly: it no longer equals, hashes like or unpacks as a tuple of its fields.TFTPURL.parse(text)andTFTPURL.try_parse(text)are the way in andstr(url)the way back; both read and write the file name with the packet codec's encoding, sotftp://h/caf%E9keeps its octet where it used to become U+FFFD, and a%25IPv6 zone decodes.str(url)imports nothing, so formatting a URL no longer imports netimps and, on Windows, patchessocket.TFTPURL.parserefuses what a URL has no place for, whereparse_urldropped it: a fragment, userinfo, a repeatedmodeparameter (the last one won), an empty parameter (f;), aNULin the file name (%00), a control character, and a bracketed host that is not an IPv6 address. A literal#in a file name is written%23, and a literal?or;is written%3For%3B.parse_url(None)andparse_url(b"...")raisedTFTPValueError;parseraisesTypeError. A query is read as transfer options (below), wheretftp://h/f?x=1used to name the filefand dropx=1, and port 0 is the default port, 69, asparse_urlread it.- A
tftp://URL carries transfer options, in either of two spellings: whichever of?and;comes first after the file name decides how the rest is read. After?,name=valuepairs separated by&(tftp://h/f?blksize=1428&windowsize=16); after;,name=valuepairs separated by;(tftp://h/f;blksize=1428;windowsize=16, the same URL).modeis a name in both.TFTPURL(host, port, filename, mode="octet", options=None)gainsoptions, a read-only mapping of lower-case names to text that is part of equality and the hash;str(url)writes the;spelling,modefirst and only when it is notoctet, then the options in name order, so a URL with a mode and no options is exactly RFC 3617's form andTFTPURL.parse(str(url)) == url.blksize,windowsize,timeout,tsizeandrolloverare read as theTFTPClientkeyword of the same name and refused withTFTPValueErrorwhen the URL is built if their value cannot be read (ASCII digits only) or the client would refuse its range; any other name is a wire option requested verbatim (extra_options). Names are compared without case; a repeated name, an empty pair, a pair with no=, a fragment, a NUL or control character and both delimiters unencoded are refused. These options are this library's extension: RFC 3617 defines;mode=only, and another tool reads the text after?as part of the file name.TFTPURL("h", 0, "f")andtftp://h:0/fare port 69. - The consumers of a URL apply its options.
download_url,upload_url,pytftp get|put|ls tftp://...andTFTPURIPathturn them intoTFTPClientkeywords. An explicit keyword, command flag orwith_optionsargument wins over the URL's option of the same name, andextra_optionsmerge name by name; a client given toTFTPURIPath.with_clientis used as it is. The command's--mode,--blksize,--windowsizeand--timeoutdefault to nothing given (the library's defaults, 1428, none and 1.0, are unchanged), so the URL's value is used unless the flag is spelled, and--no-optionsdrops the URL's options too.TFTPURIPathreads a;in the last path segment as parameters whatever they are (a file whose name holds a;raisesTFTPValueError; write the;as%3Bin a?list instead), where only;mode=was read and anything else stayed in the file name. - A socket-buffer shortfall on a windowed transfer is logged by netimps, once
per process for each distinct request and grant, at
WARNINGon the loggernetimps._sockets;tftpno longer logs it atDEBUG. - On Windows an address the system marks tentative or duplicate (the
169.254.x.x of a media-disconnected adapter) is no longer one of an
adapter's addresses, so
listen=with an adapter name does not consider it. - Every exception the library raises on its own account is defined in
tftp.exceptionsand is aTFTPError:TFTPDecodeError(bytes that are not a packet) was a plainValueErrorsubclass and is now aTFTPErroras well, and the newTFTPValueError(TFTPError, ValueError)is whatTFTPURL.parseraises for a URL it cannot read (it used to raise a bareValueError;except ValueErrorstill catches it).WouldBlockmoves there too and stays aBlockingIOError, outsideTFTPError, since it is a signal and not a failure.str()of the decode and value errors is the message alone. -
TransferTimeoutError().errnoisNone; it wasTFTPErrorCode.NOT_DEFINED(0), which is not an operating-system error number. -
The five packet types are validated, immutable values, not named tuples.
RequestPacket,DataPacket,AckPacket,ErrorPacketandOptionAckPacketequal only their own type (AckPacket(5) == (5,)wasTrue), are hashable (a request and an OACK were not), do not unpack or index, and refuse a block outside 0..65535 or a wrong type at construction.optionsis a read-only mapping. Each has adecode(data)classmethod,encode()andbytes(packet):bytes(AckPacket(5))isb"\x00\x04\x00\x05", where it was one octet, andbytes(DataPacket(...))raised.decode()returns an ERROR'scodeas aTFTPErrorCode, which carries a number the RFCs do not define as an unnamed member (TFTPErrorCode(258).name == "CODE_258") instead of a plainint. -
The encoders are strict, where they wrote what the decoder refuses. These inputs raised nothing and now raise
ValueError:encode_requestwith an empty file name, a mode other thannetascii,octetandmail, an empty option name or a request over 512 octets (RFC 2347),encode_oackwith no options or an empty option name, andencode_errorwith a code outside 0..65535 (it sent 0), a NUL in the message (it sent?) or a message over 512 octets (it cut it). An option value that is not text or anint(None,True, a float) raisesTypeError; it was written asstr(value).encode_dataandencode_ackraiseValueErrororTypeErrorfor a bad block, wherestruct.errorescaped. The servers and clients still send every ERROR they report, however malformed its code or text. -
PortRangeis a frozen value; the round-robin position moved to the server. It compares and hashes bylowandhigh(PortRange(4000, 4010) == PortRange(4000, 4010)wasFalse), cannot be assigned to, prints asLOW:HIGHwithstr(), and hasparseandtry_parsefor that text (PortRange.of("4000:4010")raised). Iteration no longer starts after the last port handed out, andordered()andtaken()are gone: everyTFTPServerandTFTPRelayowns its position, so two given onePortRangeno longer take ports from one cursor.port_range=takes aPortRangeLike: aPortRange, a(low, high)pair, arangeor the text. A bound that is not anint("5") raisesTypeError, where it was converted; an out-of-range one raisesTFTPValueError, still aValueError. -
TFTPServerLimits,TFTPServerOptionsandNegotiatedcompare equal when their fields do, andTFTPServerLimitsandOptionRegistryprint their contents.Profile.serverreturns a copy each time (likeProfile.client), sotftp.Profile.PXE.server.max_blksize = 512no longer changes the preset. -
Upstreamis a validated, immutable value, not a tuple. It equals only anotherUpstream(Upstream("h", 69) == ("h", 69)wasTrue), is hashable, does not unpack, and checks its port: anintin 1..65535,TypeErrorfor another type (a(host, "70")pair was converted),TFTPValueErroroutside the range.Upstream.parse(value)takes whatrelay.upstream(value)took.ListEntry,RemoteStatandPacketEvent, which the library only hands out, stay named tuples. RemoteError.from_codeis a classmethod (it was a staticmethod), andTFTPError.from_exception(exc)replacestftp.exceptions.error_for_exception.- Option numbers are read in one place and as ASCII digits only, surrounding
space ignored: a server or client no longer takes
blksize=+1428,blksize=1_428or an Arabic-Indic numeral, which the option handlers read withint(), and the capture and relay observers no longer count one of those, nor a digitint()cannot read, as a negotiated size.parse_int, which returnedNoneunder the nameparse, is no longer exported. str(event)is the human line of aPacketEvent(event.format()is gone), andCapturedTransfer.sizeis the transferred byte count (.bytesis gone; the"bytes"key ofto_dict()is unchanged).AsyncTFTPClientis a sibling ofTFTPClient, not a subclass. Both derive from a private base that holds the arguments, the request options and the answer to the first packet, and neither has the other's methods:issubclass(AsyncTFTPClient, TFTPClient)is false, andAsyncTFTPClienthas nopath()(aTFTPPathis synchronous; useTFTPClient(...).path(...)). The twin is exported fromtftpandtftp.client, besideTFTPClient, and no longer fromtftp.aio, which keepsAsyncTFTPServeralone.AsyncReaderBridge,AsyncWriterBridge,is_async_readerandis_async_writerare internal: the clients and the server still take an async reader, an async writer or an async iterable.- A host name that does not resolve is netimps'
ResolutionError(anOSError;NoAnswerErrorwhen the lookup found no such name) from both clients, where it wassocket.gaierror. - The servers open nothing in their constructor, and share one lifecycle.
TFTPServer,AsyncTFTPServerandTFTPRelaycheck and store their arguments;bind()opens the sockets (idempotent, never a coroutine) andstart(),serve_forever()and entering the context manager call it, so a caller can take a privileged port before dropping privileges.server_address(andhas_pktinfo) isNonebeforebind(), and a bind failure (OSError, aValueErrorfor an adapter that does not resolve) comes frombind(),start()orserve_forever(), not from the constructor.shutdown()never blocks;wait_closed(timeout=None)replacesstop(timeout)(blocking on the threaded server and the relay, a coroutine onAsyncTFTPServer) and returnsFalsewhen the timeout passed first;close()isshutdown(),wait_closed()and release, final and repeatable, and raisesRuntimeErrorwhen called from the thread that is serving (a handler callsshutdown()).start()raises whatbind()raised and returns only once serving. AsyncTFTPServer.close()isaclose(). An async class has noasync def close():await server.aclose()releases the listening socket through netimps'aclose(), andclose()andstop()are gone.AsyncTFTPServeris exported fromtftpandtftp.server, and the packagetftp.aiois gone.AsyncTFTPServer.serve_forever()iterates the listener'sdatagrams().- One hook contract per server.
TFTPServertakes plainopen_readandopen_writeand synchronous streams;AsyncTFTPServertakesasync defhooks and asynchronous streams (async read/async close,async write/async close). A handler of the other kind raisesTypeErrorwhen the server is built. A synchronous handler (the four built-in backends included) goes toAsyncTFTPServerthroughtftp.server.ThreadedHandler(handler, *, executor=None), which opens in the executor, or on the loop for a handler withopens_fast; a directory path needs no adapter andAsyncTFTPServer(executor=)is gone. Asynchronous streams are no longer accepted as async iterables or as awriteplusasync drain()pair, andAsyncTFTPClient.downloadanduploadtake a path, bytes (to upload) or an object withasync write(data)orasync read(n), not a synchronous file object. tftpexports what the common task needs; every other name has one home, its role module. No longer importable fromtftp:MODESandRemoteStat(tftp.client),PortRange(tftp.server),Negotiated,OptionHandler,OptionRegistry,DEFAULT_REGISTRY,register_option,PROFILES,DEFAULT_BLKSIZE,MIN_BLKSIZE,MAX_BLKSIZE,MAX_WINDOWSIZE,STANDARD_OPTIONS,EXTENSION_OPTIONS,LISTING_OPTIONSandSUPPORTED_OPTIONS(tftp.options), andencode_request,encode_data,encode_ack,encode_errorandencode_oack(tftp.packet). The README, the shipped header (which now lists every module's exports) and the package docstring no longer say that everything is importable fromtftp, and the README's Python blocks are executed by the test suite.- Importing the package no longer imports
asyncio.AsyncTFTPClientandAsyncTFTPServerare bound on first access; their names and import paths are unchanged. - The contracts are
typing.Protocols intftp.server:TFTPHandler,TFTPReader,TFTPChunkReader,TFTPWriter,AsyncTFTPHandler,AsyncTFTPReaderandAsyncTFTPWriter, with their optional members documented as such. The three marker attributes are public:_tftp_fast_open_isopens_fast,_tftp_copies_iscopies_writesand_tftp_listing_islists_directories. - A writable
MemoryBackendis bounded.max_uploaddefaults to 16 MiB where it was unlimited, and the newmax_entries(default 1024, the names infilesincluded) bounds the names: an upload past either is ERROR 3, where one client stored 20,000,000 octets and any number of names. A name that exists can still be replaced. PassNonefor the old behaviour. HTTPBackendfetcheshttpandhttpsonly.HTTPBackend("file:///srv")and anybase_urlthat is not anhttporhttpsURL raiseValueError; aurl_forURL that is not one is refused with ERROR 2; and the default opener carries the HTTP(S) handlers only, where urllib's own also readfile:andftp:anddata:(a redirect from the origin to anftp:host was fetched). Redirects betweenhttpandhttpsare still followed, andopener=still lets the caller choose any scheme.-
HTTPBackendsends what it declared and names itself. Requests carryUser-Agent: tftp/<version>where urllib'sPython-urllib/3.xwent, and aPUTcarriesContent-Type: application/octet-streamwhere urllib's form content type went. A header inheaderswins over either default. -
Capture:
CapturedTransfer.missing_blocksis a tuple of(first, last)ranges, where it was a list with one number per missing block (a capture of 400 DATA packets made 13 million, 0.5 GiB);missing_countcounts them, andto_dict()has"missing_blocks"as[first, last]pairs and"missing_count". A DATA more than a window (at least 64 blocks) ahead of the highest block seen is counted as a packet and not placed. - Capture:
FlowTracker(ports=(69,), *, keep_payloads=True, on_complete=None, max_tracked=1024).keep_payloadsis keyword-only. A live capture no longer holds every transfer it ever saw: pastmax_tracked, the finished transfer quiet for longest (else the quietest) is handed toon_completeand dropped.analyze()andmax_tracked=Nonekeep everything.pytftp capturesummarises and writes a transfer when the tracker lets it go. - The implementation modules are private. Inside
tftp.packet,tftp.options,tftp.transfer,tftp.relay,tftp.backends,tftp.path,tftp.capture(events, filters, flows) andtftp.server, every module starts with an underscore (tftp.packet.codecistftp.packet._codec,tftp.options.baseistftp.options._handler,tftp.transfer.baseistftp.transfer._engine). A name's home is its package:from tftp.server import TFTPServerLimits. The loggers keep their names:tftp.client,tftp.server,tftp.relayandtftp.backends. - Annotations are complete and true. The package is checked with mypy (Linux, macOS
and Windows). Every public callable is annotated, and every public annotation resolves
with
typing.get_type_hintson Python 3.9, except the signatures that name a netimps type (netimps is imported lazily) andTFTPClient.path. Where an annotation said more than the code did, it now says what is true:by_interfacekeys areHostLike, anInterfaceor anint;AsyncTFTPServer's keywords are spelled out instead of**kwargs;Sender,ReceiverandTransfertakebackoff,max_timeout,expiresandmax_idleas keyword parameters;TFTPRequestContext.optionsis a read-only mapping andTFTPRequestContext.interfaceanetimps.InterfaceorNone;downloadanduploadtake any object withwrite(data)orreadinto(buffer)/read(size), as they always did, andAsyncTFTPClient.downloadanduploadname the asynchronous stream shapes. - Public aliases are exported.
tftp.client:SinkLike,SourceLike,ProgressFunction,AsyncSink,AsyncSource;tftp.transfer:SendFunction,SupportsWrite,SupportsReadinto,SupportsRead;tftp.relay:UpstreamLike;tftp.capture:Endpoint.OptionRegistry.copy()returns the subclass it was called on. - The command line is a package with one entry,
tftp.cli.main(argv=None) -> int. The console script maps to it (pytftp = "tftp.cli:main") andpython -m tftpcalls it;runis gone, andmainalways returns a status (0, 1 or 2), whererunreturnedNonefor success.tftp.cliexportsmainand nothing else: the command classes (Pytftp,Get,Put,Ls,Serve,RelayCmd,CaptureCmd) are no longer importable from it, and the package imports without duho (a run without thecliextra still prints one line naming it and exits 1).-v,-qand--loglevelare accepted before the subcommand (pytftp -v get ...) as well as after it. - The three handlers behind
pytftp serve's deployment flags are library classes.tftp.backends.Remap(inner, rules),PerClient(root, make, *, fallback=True)andCaseInsensitive(root, ...)(aFilesystemBackend) are public, documented and tested without a parser; the command line's--remap,--per-clientand--ignore-casebuild them.Remaptakes"REGEX=REPLACEMENT"text or(pattern, replacement)pairs and raisesTFTPValueErrorfor a rule it cannot read; the helper that read the text,parse_rule, is no longer separate, andclient_directory(peer)isPerClient.directory(peer).FilesystemBackendand its subclasses print their root. - The command line reports one way.
pytftp getandputprint theirreceived N bytes in .../sent ...line on stdout, where it was on stderr (it stays on stderr whenget HOST FILE -writes the file to stdout), and-qsilences it.--jsonprints one object on one line for every command:getandlsprinted an indented multi-line document.get HOST FILE - --json, which printed the text and dropped the JSON, is a usage error (exit 2). -
The command line refuses what it used to ignore (exit 2, one
error:line): a third argument after atftp://URL,-4with-6(argparse's "not allowed with"),-b,-w,--no-tsizeor--no-optionsbeside--compatand, forserve,--max-blksize,--max-windowsize,--allow,--refuseor--fit-mtubeside--compat.put HOST DIRECTORYsaysnot a file: DIRECTORY is a directory, where it said no such file.--helpshows each option's default and what omitting a value means, and the--port-range,--remap,--route-subnetand--route-prefixerrors name their flag. -
Reading a capture is pktcap's. The reader, the frame decoder and live capture leave
tftp.capturefor pktcap (thepktcapextra, above); where each lives now (every one is imported frompktcap, andtftp.capturere-exports none):
| Left | Now |
|---|---|
read_datagrams(source) |
pktcap.read_datagrams(source, *, dissector=None, max_frame_size=262144) |
read_frames(source), which yielded (time, linktype, frame) |
pktcap.read_frames(source), which yields pktcap.CapturedFrame(time, linktype, data, interface) |
FrameDecoder(linktype).decode(time, frame) |
pktcap.FrameDissector().dissect(frame), which reads any link type |
UdpDatagram |
pktcap.CapturedDatagram, which adds fragmented and truncated |
LINKTYPES |
pktcap.LINKTYPES |
sniff(interface, *, stop) |
pktcap.sniff(interface, *, stop, dissector) |
live_capture_supported() |
pktcap.has_live_capture() |
the modules tftp.capture.frames and tftp.capture.live |
gone |
CaptureFormatError (tftp.capture) |
pktcap.CaptureFormatError |
FilterError (tftp.capture) |
pktcap.CaptureFilterError |
PcapWriter, itself a trace hook; the module tftp.capture.pcap |
pktcap.PcapWriter(target) (or pktcap.PcapngWriter) in trace=trace_to(writer); the module is gone |
analyze() and pytftp capture let pktcap's CaptureFormatError through; it is a ValueError, as
it was. FlowTracker.feed(datagram) and feed_all take
anything with time, source, destination and payload (tftp.capture.DatagramLike,
new), and analyze(source) a path, a stream or an iterable of those.
The filter grammar is pktcap's (pktcap.parse_capture_filter, compile_capture_filter), and this
library has no filter of its own (see "Removed").
The writer: a pcap file written through pktcap promises the datagrams it holds, each
with its time to the microsecond, both addresses and its payload, and not its octets.
Against the old writer's file three things differ and nothing else: the header's snap
length is 262,144 (it was 65,535); the IPv4 identification counts every datagram (it
counted the IPv4 ones), so it differs only in a trace that mixes families; and a
writer that wrote nothing leaves no file, so --pcap on a run that moved no datagram
leaves a file of 0 octets (it was a 24-octet header). Used from the library, the writer
creates its file at the first datagram, and a path that cannot be opened is that hook's
one logged failure, not an error at construction; --pcap FILE opens the file itself
when the command can run, so an unwritable path is the same error: line at the same
moment. It refuses with a ValueError naming the argument a port outside 0-65535, a
payload over 65,507 octets (IPv4) or 65,527 (IPv6), a time outside 0 to 2**32 and a
host that is not an address, where the old writer raised struct.error for the first
three (a host name was a ValueError in both); in a hook that is the hook's failure,
not the transfer's.
What a caller sees for an input the old reader took: a frame of more than 262,144
octets, a pcapng section with more than 4,096 interfaces and a block too short for its
kind are a pktcap.CaptureFormatError (the old reader returned the first, accepted the
second and raised struct.error for the third); a text stream is a TypeError at the call; an
empty input is a capture with nothing in it, as before.
- Inputs that now raise (the detail is in the entries above):
TFTPError("no such file"), acodethat is not anint:TypeError; outside 0..65535:ValueError.TFTPClient(timeout=..., max_timeout=...)withmax_timeoutbelowtimeout:ValueError.- Host text with a signed or non-digit port, or brackets round an IPv4 address:
ValueError; a host that is not text, an address or aHost:TypeError. - A
tftp://URL with a fragment, userinfo, a repeated name or both option delimiters unencoded, and a value no URL can carry:TFTPValueError. - The encoders given an empty file name, an unknown mode, a request over 512 octets, an OACK with no
options, an ERROR code outside 0..65535 or a NUL in its message:
ValueError; an option value that is not text or anint:TypeError. - A
PortRangebound that is not anint:TypeError; one out of range:TFTPValueError. - A handler of the other kind, or an asynchronous handler for the synchronous server:
TypeErrorwhen the server is built. - A capture frame over 262,144 octets, or a block too short for its kind:
pktcap.CaptureFormatError; a text stream:TypeError; a trailingandor anorin a filter:pktcap.CaptureFilterError. - On the command line, the flag combinations and arguments that used to be ignored: exit 2 with one
error:line.
Renamed
Old names are not kept as aliases.
| Old | New |
|---|---|
tftp.errors (module) |
tftp.exceptions |
TftpError |
TFTPError |
ProtocolError |
TFTPProtocolError |
TransferTimeout |
TransferTimeoutError |
TransferAborted |
TransferAbortedError |
UnknownTransferId |
UnknownTransferID |
MalformedPacket (tftp.packet) |
TFTPDecodeError |
FilterError (tftp.capture) |
pktcap.CaptureFilterError |
Client, AsyncClient |
TFTPClient, AsyncTFTPClient |
Server, AsyncServer |
TFTPServer, AsyncTFTPServer |
Relay |
TFTPRelay |
Handler |
TFTPHandler |
RequestContext |
TFTPRequestContext |
Opcode, ErrorCode |
TFTPOpcode, TFTPErrorCode |
Request, Data, Ack, OptionAck, Packet |
RequestPacket, DataPacket, AckPacket, OptionAckPacket, TFTPPacket |
Error (the ERROR packet, not an exception) |
ErrorPacket |
TftpURL, TftpPath, TftpUriPath |
TFTPURL, TFTPPath, TFTPURIPath |
UdpDatagram |
pktcap.CapturedDatagram |
FileSystemHandler (tftp.server) |
FilesystemBackend (tftp.backends; the root still exports it) |
MemoryHandler, HttpHandler, UpstreamHandler |
MemoryBackend, HTTPBackend, UpstreamBackend |
TftpBackend (tftp.path) |
private |
entry point tftp.path.uri:TftpUriPath |
tftp.path:TFTPURIPath (the scheme is still tftp) |
parse_url(url) |
TFTPURL.parse(url) (and TFTPURL.try_parse(url)) |
format_url(host, filename, port, mode) |
str(TFTPURL(host, port, filename, mode)) |
relay.upstream(value) |
Upstream.parse(value) |
tftp.exceptions.error_for_exception(exc) |
TFTPError.from_exception(exc) |
tftp.listing.parse_listing(data), format_listing(entries) |
tftp.listing.loads(data), dumps(entries) |
PacketEvent.format() |
str(event) |
CapturedTransfer.bytes |
CapturedTransfer.size |
tftp.options.parse_int(text) |
private (read_decimal) |
PortRange.of(value) |
PortRange.parse(text); the functions take a PortRangeLike |
ServerOptions, ServerLimits |
TFTPServerOptions, TFTPServerLimits |
ServerContext, ClientContext |
ServerOptionContext, ClientOptionContext |
Blksize, Blksize2, Timeout, Utimeout, Tsize, Windowsize, Rollover, Cookie, Mstfwindow, XList, XMtime (tftp.options) |
the same with the suffix Option: BlksizeOption ... XMtimeOption |
Stats |
TFTPStats |
Route (tftp.relay, the callable's type) |
RouteFunction |
tftp.DEFAULT, tftp.STRICT, tftp.PXE, tftp.HPA, tftp.LEGACY |
Profile.DEFAULT, Profile.STRICT, Profile.PXE, Profile.HPA, Profile.LEGACY (PROFILES[name] is unchanged) |
supports_pktinfo (property of the servers and the relay) |
has_pktinfo |
Server.stop(timeout), Relay.stop(timeout), await AsyncServer.stop() |
shutdown(), then wait_closed(timeout) |
await AsyncServer.close() |
await AsyncTFTPServer.aclose() |
tftp.aio.AsyncServer |
tftp.AsyncTFTPServer, tftp.server.AsyncTFTPServer |
TransferResult.ok, CapturedTransfer.complete, Transfer.done, Transfer.stalled |
is_ok, is_complete, is_done, is_stalled (the "ok" and "complete" keys of the command line's and CapturedTransfer's dictionaries are unchanged) |
Client(max_duration=), AsyncClient(max_duration=) |
deadline= |
Relay(max_lifetime=) |
max_duration= |
download(host, filename, dest), download_url(url, dest), Client.download(filename, dest) |
dst |
upload(host, filename, source), Client.upload(filename, source) |
src |
Relay(upstream_source=) |
upstream_src= |
Client(local_address=) (client.local_address) |
src= (client.src) |
the type aliases PathOrFile, Progress (tftp.client), SendFn (tftp.transfer), Predicate (tftp.capture) |
SinkLike and SourceLike, ProgressFunction, SendFunction; Predicate goes with the filter (see "Removed") |
read_datagrams, read_frames, FrameDecoder, LINKTYPES, sniff, live_capture_supported, CaptureFormatError, PcapWriter (tftp.capture) |
pktcap's own, each named in the table under "Reading a capture is pktcap's" |
_tftp_fast_open_, _tftp_copies_, _tftp_listing_ (marker attributes of a handler or stream) |
opens_fast, copies_writes, lists_directories |
entry point pytftp = "tftp.cli:run" |
pytftp = "tftp.cli:main" |
Removed
host=andinterface=ofTFTPServer,AsyncTFTPServer,TFTPRelayandAsyncTFTPRelay,--interfaceofpytftp serveandpytftp relay(the parser refuses it; the settings file no longer takesinterface), anddual_stack(the property of the servers).listen=names an address (whathost=was) or an adapter (whatinterface=was,listen="eth1"); nothing is dual-stack any more. A host that listens on both families needs no argument.- The capture filter of
tftp.capture:compile_filter,Predicate,FILTER_KEYSand thefilter=argument ofanalyze. What a filter does is pktcap's:pktcap.compile_capture_filter(text, pktcap.frame_filter_for(registry))over the frames ofpktcap.read_dissected, with the keys ofpktcap_plugin(op,file,block,code,session) once the registry holds it, orpytftp capture --filter/pktcap convert --load tftp.capture --filter.analyze(source)returns every event, and a caller that wants some keeps them in Python. The keyshost,src,dstandportare pktcap's (src=ADDR:PORTissrc=ADDR and sport=PORT);leganddirectionbelong to a trace event, which a captured frame does not have. tftp.uriandtftp.resultare gone as module paths.TFTPURL,download_url,upload_urlandTransferResultare imported fromtftp, where they were already exported.tftp.transfer,tftp.listingandtftp.netasciistay public topic modules.PYTFTP_MCP=stdiono longer serves the commands as tools. Nothing in the command is designed to be called as one (serveandrelaynever return,getreplaces files); the variable is ignored.- The package
tftp.aio,AsyncTFTPServer.close()andstop(), andAsyncTFTPServer(executor=); an asynchronous stream is no longer accepted as an async iterable or as awriteplusasync drain()pair (see "AsyncTFTPServer.close()isaclose()" and "One hook contract per server"). tftp.cli.runand the command classesPytftp,Get,Put,Ls,Serve,RelayCmdandCaptureCmdas importable names:tftp.cliexportsmainand nothing else.- The capture reader, the frame decoder and live capture, and the modules
tftp.capture.pcap,tftp.capture.framesandtftp.capture.live: they are pktcap's (the table under "Reading a capture is pktcap's"). PacketEvent.format()(usestr(event)),CapturedTransfer.bytes(usesize),PortRange.of,PortRange.ordered()andPortRange.taken().
Fixed
- The documentation cited the wrong section for broadcast requests. RFC 1123 4.2.3.4 is access
control; "Broadcast Request" is 4.2.3.5. The header, the protocol page and the server's docstrings say so.
The header also named
stream_mtimeas importable (it is not), the guide said handlers always run on the event-loop thread (theiropen_readandopen_writerun in a worker unless the handler hasopens_fast), and the capture guide showed a trace time in milliseconds (it prints microseconds). - A capture can no longer make
analyze()orpytftp captureallocate what it claims. A pcap record or pcapng block that stated 1 GiB made a 48-octet file take 1 GiB; the ceilings are checked before the octets are read and a longer one is apktcap.CaptureFormatError. - A pcapng block shorter than its fixed fields is a format error. An enhanced packet
block with a 4-octet body, or an interface or packet block cut after its header, raised
struct.errorandpytftp captureprinted a traceback and exited 1; it prints oneerror:line and exits 2. pytftp capturesays when it could not read a frame. A capture of a link type nothing dissects (802.11, link type 105) printed nothing and exited 0, as an empty capture does; the summary line on stderr now holds the count and the link-type numbers (3 frames read, 0 written, 3 skipped, 3 of an unsupported link type (105)), and the status stays 0;-qdrops the line.- A datagram a capture's snap length cut is no longer read as a short final DATA.
A cut DATA shorter than the block size ended its transfer as complete and
--extractwrote a short file under the full name.FlowTrackercounts a datagram whosetruncatedis true as a packet and does not place it: the transfer is incomplete, its block is inmissing_blocks, and the file is written with.partial.DatagramLikehastruncatedas an optional member (absent means whole).missing_blocksalso names a cut block that is the highest one seen. - IP fragments cost time in proportion to their number. Once the last fragment of a datagram was seen, each later one re-sorted and re-copied every piece (8,000 fragments of one datagram took 3.7 s); a reassembly is also bounded in octets, in fragments and in capture time.
pytftp capture --interface NAMEfor an interface that does not exist is oneerror:line and status 2, where it was a traceback.- On Windows a finished download or upload no longer fails when a virus scanner holds the
new file. The rename of the temporary file into place was refused with "access denied"
in about one transfer in six with the machine busy;
AtomicWriter.close()now tries it again for up to half a second on Windows, then raises the error as before. -
A transfer's reported duration is measured on the high-resolution clock. On Windows before Python 3.13 the monotonic clock ticks every 15.6 ms, so a transfer that finished inside one tick reported
duration0.0 and athroughputof 0, in a client'sTransferResultand in a server's. Deadlines and timeouts still run on the monotonic clock. -
RemoteError()with no code raisedTypeError. It now carries the undefined code (0), asTFTPError()does. pytftp get,putandlsreport a failure in one line. A download to a directory that does not exist or that is a directory, and a host that does not resolve, ended with a Python traceback (getandputcaughtTFTPErroronly); the three commands now share one place that runs the transfer and printserror: ...and exits 1 for aTFTPErroror anOSError, naming the target rather than the temporary file beside it.--pcap FILEis created once the command can run.get,put,serveandrelayopened and truncated it before their arguments were checked, beforeputlooked for its source and before the port was bound, so a command that could not run emptied an existing capture, and a running server's capture was emptied by a secondservethat failed to bind. An unwritable--pcapis anerror:line with exit 1.- A reader that closes the pipe ends
pytftp capturequietly.pytftp capture f | head -1ended with a traceback and exit status 120 on Windows; it exits 1 and says nothing, aslsand the JSON reports do. - Text a peer chose is escaped before it reaches a terminal or a log.
summarize()andstr(event)wrote a request's mode and every option name and value (and an OACK's) as received, so a client's escape sequences reached the terminal ofpytftp serve --traceandpytftp capture; they are written as Python escapes now, as the file name and an ERROR message already were, and so is a peer's text in the server's log lines. - A capture timestamp the platform cannot convert no longer ends
pytftp capturewith a traceback:str(event)prints the number itself. - One packet a capture reading cannot take fails its transfer's record, with an
unreadable packeterror, and not the decoding of the whole file. - A send the host refuses ends that transfer, with its reason. A server dropped it
without a word (a
blksizethe host could not send left the client waiting out every retry); on both servers the peer now gets ERROR 0,on_completegets a result with the error, the reason is logged and the server goes on. A full send buffer is still loss. The clients already raised theOSError; tests now pin it. The server does not capblksizeby the host's defaults: on FreeBSD a client whose own receive buffer is smaller than the block gets no data (the kernel drops the datagram), which the header states. - Transfers in flight when a server stops are reported.
shutdown()sent them ERROR 0 but calledon_completefor none of them and leftcompletedandfailedwhere they were, on both servers; each now yields aTransferResultwhoseerrorisTransferAbortedError, and counts as failed. - On Windows one file is reachable under one name.
FilesystemBackendrefuses a name component ending in a dot or a space (the file system drops it, sof.openedf), one with a character Windows does not allow in a name, and every nameos.path.isreservedreserves:CONIN$,CONOUT$, the superscriptCOMandLPTnames, and a device name followed by spaces. Before Python 3.13 the same list is used. - A number in an option is ASCII digits, and nothing else. A server
acknowledged a
blksizepadded with white space as 1024, and echoed atimeoutas it was written; the acknowledgement is now written from the number that was read (timeout=005is answered5). A number of more than 19 significant digits is not a number.timeoutandutimeoutrequested together are acknowledged both only when they name the same time, as tftp-hpa does (a server ran onutimeoutwhile also acknowledging a differenttimeout), andmstfwindowobeysmax_window_bytesaswindowsizedoes (a 65464-octet limit gave a window of 261856 octets). - A client refuses (ERROR 8) an acknowledged
timeoutorutimeoutthat is not the one it asked for (RFC 2349), where it applied any value from one microsecond to 10^24 seconds. The client'stsizeandx-mtimereaders are the same reader as the server's. - The option fallback also answers ERROR 4 and ERROR 0. A client with
fallback=Truerepeats a request that carried options once without them after ERROR 8, 4 or 0, the errors a server older than the option extension sends for the extra data (RFC 2347: the client "may" repeat it);stat()probes again for the size alone after the same errors. It repeated only after ERROR 8. OptionRegistry.registerraisesValueErrorfor a name that is not lower-case: such a handler was registered and never negotiated.- A transfer through a raw stream no longer reports success with octets
missing.
as_writewrites until a block is taken where a raw sink took 100 octets per call and a 10247-octet transfer ended with 2007 in the sink and no error (a sink that takes nothing fails the transfer, and a raw sink with nothing ready holds the block), andas_readintotakesNonefromreadintoorreadas "nothing ready", where a non-blocking source ended the transfer after 512 octets of 10247. NetasciiWritercompletes a short write of the stream it wraps. Over a raw stream that took part of what it was given, the rest was dropped with no error; it writes throughas_write, so the rest is written, a stream that takes nothing raisesOSError, and one with nothing ready raisesWouldBlockwith the block held.- A netascii read request asking
tsizeno longer makes the server read the whole file before it answers. The size of a netascii transfer needs the file scanned, on the loop that serves every other transfer: after eight such 27-octet requests for a 768 MiB file, a small download took 4.3 s where an idle server takes 0.01 s. The OACK leavestsizeout for netascii, as tftp-hpa does; an octet transfer still answers it. - A directory listing from a server can no longer name a path.
listing.loads(and solistdir(),TFTPPath.iterdir()and a recursivecopy()) skips a line whose name is empty,.,..or holds/, a backslash or a NUL, and one whose size or time is not ASCII digits: it returned../up.txt,/abs.txt,a/b,C:\x, negative sizes and times and read1_0as 10, which a caller joiningentry.nameto a local path would have followed out of its directory. - An ERROR's text is read one way: up to its first NUL, with an octet that is
not UTF-8 shown as U+FFFD, whether the ERROR answers a request (
decode) or arrives mid-transfer. The first read left a lone surrogate in the text. -
A windowed receiver follows a sender that wraps to block 1 (rollover not negotiated); at
windowsize=4both ends timed out at block 65536. The engine runs a window of at most 32767 blocks whatever was negotiated: with a window above half the block numbers, a late ACK was taken for one that acknowledged the whole window, and the sender counted 65535 blocks as delivered. A datagram of fewer than 4 octets is dropped bySenderandReceiveralike; a 3-octet ACK ended a sending transfer with "unexpected opcode 4". -
start()afterclose()raisesRuntimeErroronTFTPServer,AsyncTFTPServerandTFTPRelay. The threaded server waited five seconds and returned as if it were serving (the thread died on the same error), and the asynchronous one returned at once after serving before, or never returned when it had not. A secondstart()raisesRuntimeError, where the asynchronous one started a second serving task and madeclose()wait for ever. - A server or relay whose startup fails part-way (a selector registration, a socket pair, a worker pool) closes what it had opened: the constructor used to leave the listening socket, the selector, the socket pair and the pool behind. A listener that cannot finish setting up closes its socket.
TransferTimeoutError,TransferAbortedErrorandTFTPProtocolErrorcan be copied and pickled, so aTransferResultholding one crosses amultiprocessingboundary; each raisedTypeErrorbefore.pytftp serve ROOT --remap ...on a plain directory answered every request with ERROR 0 (astrwas wrapped where a handler belongs); only combined with--per-clientor--ignore-casedid it work. A remapped name also lost the request's listing flag, sopytftp ls HOST aliasfor a remapped directory answered "file not found":TFTPRequestContext.with_filename()copies a context with another filename and--remapuses it.- On Windows an idle
pytftp servedid not stop on Ctrl-C until a datagram arrived, and Ctrl-Break and SIGTERM endedserveandrelaywithout closing the server, the capture or logging the final counters. The command now stops on all three, idle or not, with status 0 and theserved:/relayed:line; the signal wakes the loop through its wake socket, so an idle server still costs no polling. pytftp capture --transfers(andFlowTracker(keep_payloads=False)) reported 0 bytes, 0 retransmissions and no missing blocks unless--extractkept the payloads. They are counted from the sizes and block numbers either way.- A
TFTPPathbound to anAsyncTFTPClientreturned wrong results without raising (read_bytes()gaveb"",write_bytes()reported success, and nothing was sent: the client's coroutines were never awaited).AsyncTFTPClienthas nopath(), andTFTPPath,TFTPURIPath(throughwith_client()andwith_backend()) refuse an asyncio client withTypeError. - A server named by a link-local IPv6 address with a zone (
fe80::1%7) was never heard under the defaultstrict_source=True: the reply's source was compared as text, and a received address carries no zone in its text. Both clients and the relay now compare addresses by value (packed address, and the scope id when both sides have one) and send to a resolved socket address with the scope id as a number, which also makes a request to such a server work on the Proactor event loop (the host refused the zone in the text, and the error was dropped). The relay's upstream leg is fixed the same way. - A send the host refuses ends an asyncio client's request with that
OSErrorat once; it was reported asTransferTimeoutErrorafter every retry. An ICMP report from the peer is still loss. - On Windows, one datagram longer than the client's receive buffer, from any
address, ended a synchronous transfer with an uncaught
OSError(WinError 10040). The buffer is now longer than any datagram: a stray one is answered with ERROR 5 and the transfer carries on, and a DATA longer than the negotiatedblksizeis aTFTPProtocolError. - With a window above 1, one duplicated, lost or late ACK, or one reordered
DATA pair, made every remaining window be sent and acknowledged twice (a
3001-block transfer at
windowsize=16sent 5922 DATA datagrams). An ACK that moves the sender's window on now sends only the blocks that fit (an ACK askingblksize=65464 windowsize=64for one block used to draw 64 DATA, 4 MiB, per 4-octet ACK), and ACKs resend blocks already in flight at most once per two windows of progress; timeouts are unchanged. The receiver reports a gap twice at most. After one such event the traffic is the control's plus one window, at windowsize 1, 2, 4 and 16; windowsize 1 is unchanged. - A request nothing follows up no longer costs the whole negotiated window: a
transfer allocates the buffer for a block when it first reads it, where a
single 53-octet request asking
blksize=65464 windowsize=64made the server allocate 4 MiB at once. A served file's read buffer is 16 KiB. - A finished transfer is released when it finishes: its timer entry, which
stays queued until its deadline, no longer keeps the transfer, its file and
its window alive (200 downloads asking
timeout=255kept 799 MiB referenced, outside every limit). - A handler's
TFTPErrorthat could not be encoded as an ERROR (a NUL in the message, a code outside 0..65535) ended the synchronous server's thread for every client, and left anAsyncTFTPServersession open for good. Every ERROR now encodes: a NUL in the text is sent as?, the text is cut to 512 octets, and a code outside 0..65535 is sent as 0. A transfer records its failure before it sends the ERROR, and an exception escaping one dispatch of the server loop, the asyncio server or the relay ends that one transfer (logged once, with its traceback) and not the loop. TFTPServer(max_sessions=...)andTFTPRelay(max_sessions=...)above whatselect()can watch on Windows (510 and 255) raiseValueErrorat construction, where the serving thread died at the first request past it.- The shipped API header named
NETIMPS_NO_SOCKET_PATCH, which netimps 0.4 rejects at import; the variable isNETIMPS_SOCKET_PATCH=0. - The client's first answer is checked. A write is answered by a whole ACK 0
and a read by a whole DATA 1 (or an OACK); a DATA of another block, an ACK
cut short or a packet that does not decode is
TFTPProtocolErrorafter an ERROR 4, where the blocking client waited out its retries andAsyncTFTPClientwaited for ever (or raisedIndexErrorfor a cut ACK) and an upload began from a cut ACK. download()to a path no longer loses the file that was there. It writes a hidden temporary file beside the path and replaces the path when the transfer succeeds; the file was opened (and so truncated) before the host was resolved, and removed on any failure, an unparsable host or a server's "not found" included. A path that exists and is not a regular file (a device, a pipe) is written in place and never removed. A symlink at the path is replaced, the directory must be writable, and on Windows a file another handle has open cannot be replaced (PermissionError, the file intact).AtomicWriter(path, *, overwrite=True, mode=None)takes the file's permissions withmode.- A local failure during a transfer is raised as itself, where the client
raised a bare
TFTPErrorwhose__cause__held it: a full disk isOSError(ENOSPC), as the header says, and an exception from an asynchronous stream is that exception. The server is still sent the ERROR for it. max_sizebounds what a server can make a client hold.TFTPClient( max_size=), andmax_size=ondownload,getandlistdir, refuse a download past it with ERROR 3 and the newTransferTooLargeError, before the block that passes it is written, and a server announcing more in itstsizeis refused before any data moves. A server sending more than thetsizeit announced isTFTPProtocolErrorwhere it was accepted.listdir()is bounded at 16 MiB by default; the bound on a download is off unless set.- The
tsizeannounced for an upload from a wrapped file object (agzip.GzipFile, say) is the size of what is read, wherefstatof the file underneath announced the compressed size (139 octets for 100000). - A trace hook that raises is logged once.
TFTPClient,TFTPServerandTFTPRelaylogged one traceback per datagram (nine for a 3,000-octet transfer, a pcap writer on a full disk wrote one per packet of every transfer) andAsyncTFTPClientdiscarded the exception silently; all of them now log the first failure of a hook with its traceback and count the rest. The transfer is unaffected, as before. - Trace events and pcaps from the clients and the relay name a real address
as the local end.
PacketEvent.localwas the wildcard (0.0.0.0,::) of an unconnected socket, sopytftp get --trace --pcapshowed0.0.0.0:55758 > 127.0.0.1:55025and wrote 0.0.0.0 into the pcap; it is now the address the route to the peer uses, once per transfer for a client and per peer for the relay. - A transfer ends everything it started.
AsyncTFTPClientleft the task that feeds an asynchronous stream pending for ever after a download or an upload failed, was refused, timed out or was cancelled (asyncio logged "Task was destroyed but it is pending" at exit), read up to 1 MiB from the source of an upload before the server had answered, and closed its socket twice (CPython 3.9.0 to 3.9.10 and 3.10.0 to 3.10.2 log a traceback for that on the Proactor loop). Every task and the socket are now ended and closed before the call returns, and the source is read after the server accepts the request.stream(buffer=N)holdsNoctets, where it heldNplus a second 1 MiB; closing it early ends the transfer. The blocking client sends ERROR 0 when an exception fromprogress(or a signal) interrupts a transfer, where the server waited out its retries, and it reports progress for a file that arrives as DATA 1 with no OACK. deadlinebounds every call.size(),stat()and the request phase of a transfer end by it (asize()withdeadline=0.5took 6 seconds, aget()with a longertimeoutwaited the wholetimeout), and the limit has one start across the retry without options in both clients, whereAsyncTFTPClientgave the second attempt a limit of its own.- A
withblock that raises abandons the upload through a path. Leaving a block overpath.open("wb")by an exception used to complete the upload and commit what had been written: a truncated file, over a complete one if the name existed (a failedcopy()left the same). The client now sends ERROR in place of the next block, the server discards the upload and keeps what it had, and the transfer thread is joined. A block that ends normally commits as before. copy(overwrite=True)onto a name the server has works.unlink()on aTFTPPathorTFTPURIPathstill raisesNotImplementedError, except forunlink(missing_ok=True), which is whatpathlib_next'scopy()asks of its target: it does nothing, and the write that follows replaces the file.move()from a TFTP path is not supported: it copies and then raisesNotImplementedErrorbecause the source cannot be deleted.TFTPPath.as_uri()keeps a leading slash (/boot/xistftp://h//boot/x, asstr(TFTPURL)writes it), so/boot/xandboot/xstay distinct. A client's ownon_negotiatedhook fires for reads and writes through a path (the path replaced it on its copy of the client), andTFTPPath(mode=)andwith_mode()raiseValueErrorfor a mode other thanoctetandnetascii(TypeErrorfor a value that is not text), where the path failed when first used.tftp.path.TFTPURIPathwithouturitoolsraisesImportErrornaming thepathextra.- The HTTP gateway's
PUTis exact. A WRQ's announcedtsizewas sent asContent-Lengthand then every octet uploaded: a client that announced 5 and sent 69 was told the upload succeeded while the origin stored 5 octets, and an origin that ignoresConnection: closeran the surplus as a second request. The last octets of the body are now sent only when the transfer ends, more octets than announced fail it with ERROR 3 and fewer with ERROR 0, and the origin stores nothing and sees one request. A netascii upload, whose announced size is not the size received, is chunked. - The HTTP gateway no longer fetches ahead of a peer that has not answered.
One unacknowledged request made the gateway pull the whole 1 MiB buffer from
the origin (1.9 MB had left the origin after 2 seconds) and hold the
connection until the session ended. A download is read ahead by about 16 KiB
until the transfer has taken data, and by up to
bufferas it takes more. - A file name that is not UTF-8 reaches the origin percent-encoded as the
octets that arrived, where it raised
UnicodeEncodeErrorand logged a traceback per request. The response of a failed request is closed (a 404 left a socket and a temporary file to be collected, with aResourceWarning), and aContent-Lengththat is not ASCII digits is no size.
0.0.0 - 2026-10-03
Added
Client: download and upload over IPv4 and IPv6, to and from paths, binary files or memory, withblksize,windowsize,tsizeandtimeoutnegotiation (and theutimeout,rollover,blksize2andcookieextensions on request), progress andon_negotiatedcallbacks, exponential backoff of retransmissions, an optional time limit, and a one-time retry without options when a server refuses them (ERROR 8).blksize="mtu"sizes blocks to the route's interface MTU; hosts may carry a port ("[::1]:6969").Server: one event-loop thread serving any number of transfers, each on its own socket bound to the address the request was sent to (pktinfo); dual-stack::listening by default; a negotiation policy (ServerOptions: maximum block and window size, window memory bound, allowed and refused options, fittingblksizeto the arrival interface's MTU); resource limits (ServerLimits: request size, filename length, option count and length, sessions per client, transfer duration); ignoring requests sent to broadcast or multicast addresses; blocking handlers opened in worker threads; dallying on the last ACK of an upload;on_completefor every transfer; counters for metrics (stats,stats_snapshot()).FileSystemHandler: serves a directory with containment against.., symlinks and Windows device names; backslash separators; atomic uploads;create/overwritepolicy;max_uploadand free-space checks fromtsize.- Handler protocol (
open_read/open_write) for generated content; sources and sinks may report "not ready yet" (WouldBlock) and wake the transfer from another thread, so slow backends apply backpressure instead of blocking the server. - Backends:
MemoryHandler;HttpHandler, a TFTP-to-HTTP(S) gateway (GET and PUT, HTTP status codes mapped to TFTP errors,Content-Lengthastsize);UpstreamHandler, a terminating proxy to another TFTP server whose two sides negotiate independently;Pipefor writing such handlers. Relay: a transparent relay forwarding requests byte for byte to upstream servers and datagrams unchanged between the two transfer IDs, with routing by client subnet, filename prefix or arrival interface, idle and lifetime limits, per-transfer summaries and counters.tftp.aio:AsyncClient(includingstream(), an async generator with backpressure) andAsyncServer(async handlers and streams, pktinfo on every event loop including Windows' Proactor loop).- Option negotiation through a registry of option handlers, so applications can
add their own; compatibility profiles
STRICT,DEFAULT,PXE,HPAandLEGACY. - Capture and debugging:
trace=hooks on the client, server and relay reporting every datagram as aPacketEvent;PcapWriter(Wireshark-readable pcap); pcap/pcapng reading from files or live pipes with IP fragment reassembly;FlowTracker/analyze()reconstructing transfers and their files from a capture; a filter language; live capture on Linux. tftp://URLs (RFC 3617):parse_url,format_url,download_url,upload_url.Client.size(): a file's size without transferring it (atsizeprobe the server counts as declined, not failed).pathextra:TftpPath, a pathlib-nextPathbound to a client (client.path(...)), andTftpUriPath, thetftp://scheme for pathlib-next'sUriPath; whole-file streaming reads and writes,stat()without a transfer,copy()/move()across schemes.- Typed errors:
RemoteErrorraised asFileNotFound,AccessViolation,DiskFull,IllegalOperation,UnknownTransferId,FileAlreadyExists,NoSuchUserorOptionNegotiationError;TransferAborted. - Transfer engine (
Sender/Receiver) with RFC 7440 windowing, the Sorcerer's Apprentice fix, unknown-TID handling, tolerance of repeated OACKs and unlimited file size via block-number rollover, usable without sockets. - Streaming netascii in both directions; packet encode/decode for every RFC 1350 and RFC 2347 packet, keeping a request's raw bytes for forwarding.
pytftpcommand (cliextra):getandput(also by URL),serve(a directory,--httpgateway or--upstreamproxy),relay,capture;--compatprofiles,--traceand--pcapon every command that moves packets,--jsonoutput.port_range=onServer,AsyncServerandRelay(PortRange): transfer sockets take their ports from a range a firewall can allow;--port-rangeonpytftp serveandrelay.pytftp serve --per-client(serveROOT/<client address>/when it exists),--ignore-caseand--remap REGEX=REPLACEMENT.mstfwindow, Windows bootmgr's variable-window option, as an extension a server may allow (fixed window of 4).- pytftp's
x-listandx-mtimeextensions (LISTING_OPTIONS): directory listings (tftp.listing) and modification times.Client.stat(),Client.listdir()(async too),pytftp ls,pytftp serve --listing, anditerdir/walk/glob/is_dir/st_mtimeonTftpPathandTftpUriPath. - Hosts, networks and interfaces accepted as objects (
ipaddresstypes,netimps.Host,netimps.Interface) wherever an address is taken, typed with netimps' aliases. interface=onServer,AsyncServerandRelay(and--interfaceonpytftp serve/relay): listen on one network adapter, by name,netimps.Interface, MAC or address.RequestContext.interface: the arrival interface as anetimps.Interface.- Loopback throughput benchmark (
benchmarks/run.py). - Licensed under MIT.