Skip to content

v10.0.0

Choose a tag to compare

@github-actions github-actions released this 26 Oct 18:41
· 35 commits to main since this release
5b59b80

Note

This release accumulated 329 merged pull-requests, grouped by section and namespace; the linked pull-requests carry the detail, doc/News.rst the reader-facing version.

  • ✨ New packages pyTooling.ControlFlow, pyTooling.CallGraph, pyTooling.CI, pyTooling.REST, pyTooling.Documentation, pyTooling.DataModel, pyTooling.Timeout and pyTooling.Graph.GraphViz; traces render as Gantt charts and export OTLP/JSON; SPDX license expressions, version expressions and epochs; a test report format with four names per item.
    ⚠️ 32 exceptions renamed to ***Error without aliases; Stopwatch.Duration and Span.Duration are timedeltas; InheritDocString takes one strategy; membership tests are Contains***; ExtendedType enforces its slot and mixin rules; attribute scopes are enforced; versions follow PEP 440's spellings; class constants are upper case (e.g. HEADLINE).
  • 🔧 ExtendedType creates classes faster (26 to 59 % less time per class with postponed annotations) and @readonly reads 4× faster; FreeBSD is supported; packages default to Python 3.11 to 3.15.
  • 🪳 ExtendedType's abstract-method and slot handling; the GraphML writer; LineSegment2D.Length and AngleTo; calendar versions compare every part; KeyValueFlag and OptionalValuedFlag parse their values; configuration, URL and version-range corner cases.
  • 📖 Most tbd pages are written: tutorials, the glossary, competing solutions; hierarchies drawn as trees.
  • 🧪 3461 testcases instead of 1326, run in parallel.
  • ⚙️ The pipelines run on pyTooling/Actions@r9, test Python 3.15 and PyPy 3.12, run their single jobs on Python 3.15, clean up artifacts with CleanupArtifacts.yml, and refuse to tag a package PyPI would reject.

Important

The Sphinx extension and everything about GitHub Actions (run model, workflow file model, trace reader, Sphinx domain gha, the pyTooling program) moved into pyTooling.Sphinx and pyTooling.GitHub before this release; dev's history was rewritten, so no pyTooling release contains them. Their pull-requests are listed at the end, marked as moved.

Since v9.0.0 the package grew from 303 to 511 classes in 52 to 71 modules, and the testsuite from 1326 to 3461 passing testcases.

✨ New Features

  • pyTooling.Attributes

    • An attribute class states its scope as class keyword argument (#523): class Hook(Attribute, scope=AttributeScope.Method). A derived class inherits it; overriding _SCOPE in the class body still works.
  • pyTooling.Attributes.ArgParse

    • splitFormat parses an option's value [<format>:]<file> (#429), e.g. --output=json:report.json. The formats are a StringEnum; a value naming none gets its DEFAULT, an enumeration without one raises a ValueError with a note. A Windows drive or a colon deeper in a path stays part of the path.

    • NamedAndValuedArgument and NamedTupledArgument are exported (#400), taking __all__ of pyTooling.Attributes.ArgParse.Argument from 13 to 15 names.

  • pyTooling.CallGraph

    • pyTooling.CallGraph describes a program's call graph (#530): a CallGraph of Functions and Calls from caller to callee, so a recursion is a cycle. A function is defined in the described code, elsewhere, or a placeholder for unknown targets of an indirect call; it may refer to its control flow graph, a call to its call site. Execution counts and key-value pairs as in pyTooling.ControlFlow; FunctionCount, ContainsFunction, GetFunction, IterateFunctions, CallTo/CallFrom and ToGraph().
  • pyTooling.CI

    • A service-independent model of CI pipelines (#438): PipelineGroup → Pipeline → Workflow / Matrix → Job / MatrixJob / MatrixWorkflow → Step, with times (a group spans and iterates what it holds, #418), an OpenTelemetry Outcome and dependencies between siblings (AddNeed, Needs, Dependents, Validate()). Workflow.ToGraph() converts to a pyTooling.Graph. GitHub Actions and GitLab CI map onto it; CIError is its base exception, QualifiedNameMixin names elements as GitHub Actions does (#412), every element carries key-value-pairs (#446).

    • Workflows, matrices, jobs and steps carry an ordered list of rules (#570): a Rule holds a condition as written (never evaluated), a RuleOutcome (GitLab's six rules:when values) and key-value-pairs for service-specific parts. The first matching rule decides; an element without rules runs, one whose rules all fail doesn't. A GitHub if: is one rule, GitLab's rules:/workflow:rules the list. RulesMixin gives Rules, RuleCount, AddRule, IterateRules; a rule given to a second element raises AlreadyInPipelineError.

    • pyTooling.CI doesn't load pyTooling.REST (#622): JSONObject is imported from pyTooling.REST and no longer re-exported, so the pipeline model loads no REST client (urllib.request, http.client, ssl): import pyTooling.CI takes 39 ms instead of 61 ms (Python 3.15).

  • pyTooling.CLIAbstraction

    • Program._CopyParameters copies every argument set on a program to another instance (#497), for deriving a configured variant. An argument the target doesn't declare or has set already raises a KeyError. The CLIAbstraction example uses it.
  • pyTooling.Common

    • pyTooling.Common.StringEnum is a StrEnum that parses itself (#427, #429): Parse converts a string to its member, raises TypeError for a non-string and ValueError (listing the accepted values) for an unknown value, and answers None or "" with the alias DEFAULT - or with None where a value may be absent. MediaType, SpanKind and Result derive from it.

    • pyTooling.Common.getTypeName returns an object's type name without its module (#592, #593): a class, function or method (built-ins included) its own name, any other object its class' name. Exception messages use it, the Got type '…' notes keep getFullyQualifiedName; pyTooling's 23 uses of __class__.__name__ call it now.

  • pyTooling.Configuration

    • A configuration node answers the mapping and sequence protocols (#375) — Dictionary.keys(), .values(), .items() and .get(), and a sequence node answers len() and indexing.

    • A configuration dictionary can be iterated by keys and by items (#366) — IterateKeys(), IterateValues() and IterateItems() on Dictionary, for YAML and JSON alike. Bare iteration still yields only the values (__iter__ is untouched).

  • pyTooling.ControlFlow

    • pyTooling.ControlFlow describes a function's control flow graph (#529): a ControlFlowGraph of BasicBlocks and Edges with an entry, an exit and an optional unwind block. An EdgeKind says why control continues (fall-through, jump, exception, abnormal exit); blocks and edges carry an optional execution count and key-value pairs. BlockCount, ContainsBlock, GetBlock, IterateBlocks, EdgeTo/EdgeFrom; ToGraph() converts to a pyTooling.Graph, which pyTooling.Graph.GraphML writes.
  • pyTooling.DataModel

    • pyTooling.DataModel holds the foundations of data models (#571, #572): KeyValuePairsMixin attaches key-value-pairs to an element, accessed like a dictionary (element[key], get(key, default) (#576), del, in, len, iteration over the keys). The root classes of pyTooling.CI, pyTooling.ControlFlow, pyTooling.Graph (#575), pyTooling.Tracing (#576) and pyTooling.Tree.Node (#574) derive from it instead of each defining the same methods, and reject non-mapping key-value-pairs (TypeError). Predicate[T] (#577), Callable[[T], bool], types element-selecting functions in 29 annotations across eight packages.
  • pyTooling.Dependency

    • UnparsableURLWarning (#636): a project URL a package index publishes that isn't a URL is reported and skipped; UNKNOWN and empty values count as not published.

    • New exception PackageIndexError(DependencyError) (#583): the package index responded with an HTTP error status other than 404 Not Found. It is caused by the requests.HTTPError.

    • PackageDependencyGraph.ToGraph() converts a dependency graph into a pyTooling.Graph (#498): a vertex per package version (ID: the PackageVersion; license, release time and URLs as typed key-value pairs) and an edge to every version it needs, so graph algorithms (topological order, cycle detection) and GraphViz/GraphML apply. The Package Dependencies page documents the format.

    • The package-override file has a JSON schema (#389), pyTooling/Resources/PackageOverrides-v0.1.schema.json, shipped in the wheel. LicenseOverrides.SCHEMA_FILES maps a structure version to its file and SCHEMA_VERSION_LATEST names the newest, like ReportWriter.SCHEMA_FILES/SCHEMA_VERSION_LATEST for the test report XSD (both dict[SemanticVersion, Path]). It rejects misspelled fields (licenseUrl), a bare date as analysedAt and an unquoted version; a file stating no packages is valid. Editors validate via # yaml-language-server: $schema=....

    • RequirementsFile reads a requirements file as a tree (#377): -r lines are followed, every file knows its Parent, Root and Hierarchy. AllRequirements yields the requirements in stated order, the nearer statement winning; AnalyzedRequirementFiles lists the tree by resolved path. RequirementsFile.Parse(path) reads the files (#585); the initializer takes what a file states, for building a tree by hand (a file in two trees raises AlreadyInRequirementsTreeError). A diamond reference is read once (#601), the second is a RequirementsFileReference; only a real cycle raises CircularRequirementsFileError. AnalysisDuration (#595) is Parse's time.

  • pyTooling.Documentation

    • pyTooling.Documentation is a new module (#340) holding the helpers that work on doc-strings. splitDocString() splits one into its summary and its body.
  • pyTooling.Exceptions

    • ExceptionBase.Message (#584): read-only property returning the exception's message (args[0]).

    • Exceptions carry the object they are about (#579, #594), e.g. KeyNotFoundError.Key, CycleError.Path - about 35 exceptions across the packages (MetaClasses: new common base InvalidBaseClassError). The new parameters are keyword-only, required where every raise knows the value (#634). Not-found errors are also LookupErrors (#604, #599), RequirementsFileNotFoundError a FileNotFoundError, UnsupportedValueTypeError a TypeError. Warnings carry their subject too (#598, #608, #609), e.g. ProjectName/Version; BrokenRequirementWarning is the base of UnparsableRequirementWarning and UndeclaredExtraWarning; ⚠️ PermissionWarning takes a message and carries the Directory.

    • Common bases for the errors every data structure raises (#568, #616, #348, #644): DuplicateElementError, AlreadyInHierarchyError, NotInSameHierarchyError, NotAChildError (#578), InUseError, ElementNotFoundError (a LookupError) and CycleError. A package's exception derives from its base exception and a common base, so one clause catches e.g. every duplicate; pyTooling's errors do (e.g. DuplicateVertexError, AlreadyInTreeError, Graph.CycleError), messages unchanged. Upstreamed from pyEDAA.Reports.

  • pyTooling.Filesystem

    • File.ToTree() converts a file storage object to a tree node (#552), like Filename.ToTree() and SymbolicLink.ToTree(): its value is the file object, with kind and size as key-value pairs.

    • Root.Delete() deletes a scanned directory tree and breaks its reference cycles (#621): all directories, filenames, symbolic links and file objects are disconnected, also those a Collapse() removed. A dropped Root was freed only by the cyclic garbage collector; after Delete(), reference counting frees it at once (31 296 files: 19.1 MiB).

  • pyTooling.GenericPath

    • A path or a URL composes with a resource (#414): path / resource and URL / resource, taking a string or a path on the right, as pathlib.PurePath does. The right side is an RFC 3986 relative reference with its own query and fragment; an absolute path replaces, a trailing delimiter on the left is dropped first. WithoutTrailingDelimiter and WithoutTrailingSlash remove one. A string naming a scheme, an authority or a forbidden character is refused.
  • pyTooling.Graph

    • A graph's transitive edges (#443): IterateTransitiveEdges() yields the edges a longer path implies (A → C beside A → B → C), IterateTransitiveEdgesWithPath() each with that path, RemoveTransitiveEdges() removes them (transitive reduction), AnnotateTransitiveEdges() marks every edge EdgeKind.Direct or EdgeKind.Transitive, optionally with the path as key-value-pair. Edges and links have a kind (BaseEdge.Kind, EdgeKind / LinkKind, set by edgeKind / linkKind or kind), possibly from a user enumeration, and it is a type parameter (#445): Edge[..., MyKind, ...] types Kind as MyKind.
    • Graph.Delete() deletes all elements of a graph and breaks their reference cycles (#617): subgraphs, views, components, vertices, edges and links are disconnected and their key-value-pairs removed; the empty graph can be refilled, and reference counting frees it at once (100 000 vertices: 112.6 MiB, 217 ms instead of 345 ms for gc.collect()). A vertex's value is kept.
  • pyTooling.Graph.GraphViz

    • pyTooling.Graph.GraphViz writes Graphviz' DOT language (#475), the counterpart of pyTooling.Graph.GraphML: Node, Edge, DefaultAttributes, HTMLLabel and RecordLabel with checked attributes, quote(), and Graph/Subgraph documents (#478, with #476, #477). FromGraph() and FromTree() convert a pyTooling.Graph or pyTooling.Tree; a derived class overrides _ConvertVertex() and siblings. BaseGraph.ContainsNode() asks for a node by identifier (#484).
  • pyTooling.Licensing

    • 28 more SPDX licenses (#337, #388, #430, #483), taking SPDX_INDEX from 4 to 32 — closes #15. Among them 0BSD, MIT-0, the six Creative Commons 4.0 licenses (CC-BY-4.0 … CC-BY-NC-ND-4.0; no PyPI classifier, so asking for one raises ValueError) and Pillow's MIT-CMU (OSI approved since 2024-09-20). A License is hashable and equals its SPDX identifier string.

    • The GNU licenses' texts in all formats gnu.org publishes (#486): LICENSE_TEXT_URLS gains txt, md and tex for GPL-2.0, LGPL-2.1, LGPL-3.0 (new entry) and AGPL-3.0, rst for GPL-3.0; LICENSE_URLS gains AGPL-3.0's page.

    • pyTooling.Licensing models an SPDX license expression as a tree (#367), e.g. Apache-2.0 OR MIT or GPL-2.0-only WITH Classpath-exception-2.0. LicenseExpression is the base of SPDXLicense, AndOperator, OrOperator, WithOperator and OrLaterOperator (+ suffix). Each node knows its Parent and Root; assigning Parent re-roots the subtree. IterateExpression() walks depth-first in infix order. A malformed expression raises LicenseExpressionError.

  • pyTooling.LinkedList

    • LinkedList gains five specific exceptions and Graph three (#322): 20 raise sites name what went wrong instead of raising the module's base, from which every new class derives.
  • pyTooling.MetaClasses

    • ThisClass is a sentinel for a class variable holding its own class (#410), which doesn't exist yet while its body runs.

    • A slotted class can be referenced weakly (#336) with the weakref class keyword argument — closes #49. weakref=True adds __weakref__ to the slots like any field: derived classes inherit it, a mixin-class passes it on.

    • A class or a mixin can name the members it expects (#329), with the expects class keyword argument: checked at class construction, reported on instantiation, like an abstract class. An override expects only what its own marker names (#533), a class fulfilling an expectation its base missed can be instantiated with parameters (#532), and the search for marked methods skips object (#536) and classes, so a class variable holding a class with expectations keeps it (#626).

    • ExtendedType's expects accepts a typing.Protocol class (#611), besides an iterable of names: the protocol's methods, properties and annotated fields, inherited ones included, sorted by name. A non-protocol raises TypeError, typing.Protocol itself ValueError. Annotating self with the protocol lets a type-checker check the mixin's calls.

  • pyTooling.Packaging

    • ⚠️ A package describes itself once (#340): its short description is the first paragraph of its module doc-string, folded to one line. Without description and doc-string, DescribePythonPackage raises a PackagingError; description moved behind the required parameters.

    • A package can declare any entry point group (#333) — consoleScripts, guiScripts and pytestPlugins; only console_scripts was reachable.

    • pyTooling.Packaging raises its own PackagingError (#340), so a caller can catch what this module reports.

    • ⚠️ Direct references are left out of the extras (#561): PyPI rejects metadata with a requirement like pyTooling.Sphinx @ git+https://…@dev with a bare 400 Bad Request. DescribePythonPackage removes them from every extra and reports each as a critical DirectReferenceWarning; base requirements are kept. pyTooling's own setup.py collects and prints them.

  • pyTooling.REST

    • pyTooling.REST is a new package: a client for JSON REST APIs (#413) on the standard library. A resource is addressed by its path below the base URL; GetJSONObject reads one and its next page, four write methods change one. Transiently failing idempotent requests are retried with exponential backoff. MediaType.Matches checks a Content-Type header, RFC 6839 suffix included.
  • pyTooling.TerminalUI

    • TerminalApplication.WriteErrorNote writes an error's note (#424) - the advice for fixing it; Error was the only severity without one.

    • TerminalApplicationProtocol (#611): the 22 members a command-handler mixin-class can expect from a TerminalApplication (e.g. Width, Exit, Write***), with signatures and doc-strings. With expects=TerminalApplicationProtocol, mixing it into anything else fails on instantiation.

  • pyTooling.Testing

    • Testcase provides all eight assertions Python 3.14 added (#653) on Python 3.11 to 3.13 - besides assertHasAttr/assertNotHasAttr also assertIsSubclass, assertNotIsSubclass, assertStartsWith, assertNotStartsWith, assertEndsWith and assertNotEndsWith - with Python 3.14's messages.

    • A test run produces a report that can hold what JUnit cannot (#331, #332, #334, #335).

      @testsuite and @testcase mark what a runner collects; each item has four names — ID, title, summary, description, the last two falling back to the doc-string. Two pytest plugins: pyTooling.Testing.PyTest collects the markers (a marked method of a unittest.TestCase class under its own name, #480), pyTooling.Testing.ReportWriter writes our own XML format (--pytooling-xml=report/unit/TestReport.xml) alongside --junit-xml.

      Test suites nest instead of being flattened into a dotted classname, every level can carry prose, line breaks survive. The format has a versioned schema, TestReport-v0.1.xsd, shipped as a package resource. The names also reach the JUnit file as test suite properties keyed by the level's dotted path. Markers aren't collected as testcases (#341); a summary over 200 characters is left out with a PytestCollectionWarning (#519).

  • pyTooling.Timeout

    • New package pyTooling.Timeout: a cooperative timeout for the current thread (#600, #602, #603). with Timeout(20.0): sets the thread's current timeout; code below asks Timeout.Remaining() for the seconds left, and a loop calls Timeout.RaiseIfExpired(message) to abort with a TimeoutExpiredError (also a TimeoutError) carrying Seconds and Overrun. Timeout.Current() returns the timeout; a nested one can only shorten the time left. Nothing interrupts a thread: the limit acts where code checks it. The dependency resolver honours it (#606). Time is measured with time.perf_counter_ns(), as in Stopwatch and Tracing.
  • pyTooling.Tracing

    • A span or trace left by an exception records it (#514) in Span.Exception; the exception propagates as before, and the OTLP/JSON export reports the span with status STATUS_CODE_ERROR and the exception's type and message (OTLPStatus).

    • A trace renders as a Gantt chart (#404). pyTooling.Tracing.Render.GanttLayout arranges a trace's timespans in rows independently of a drawing library - a job's waiting time as a light bar in front of it, filters for CI traces (ciSpanFilter, StepExclusion), a category per runner image including the MSYS2 environment, per-category waiting and running statistics. The Renderer base-class holds colors, title and legend; MatplotlibRenderer draws SVG, PNG or PDF. New extra pyTooling[diagram] installs matplotlib and plotly.

      The layout derives from pyTooling.Diagram.Gantt (#421), a new namespace for diagram data models: a Diagram of Rows of Bars on a stated origin; a bar answers its position as times and as distances from the diagram's origin and from its row.

    • A Gantt chart written as SVG is collapsible (#405): MatplotlibRenderer(layout, collapsible=True) embeds a script, so a click on a called workflow or job hides or shows the rows below it; collapsedKinds (default: jobs) start collapsed. Script and stylesheet are resource files in the new package pyTooling.Resources.Tracing, embedded so the file stays self-contained. A collapsible chart can't be written as PNG or PDF.

    • A Gantt chart as an interactive HTML page (#406): pyTooling.Tracing.Render.Plotly.PlotlyRenderer draws the same rows with plotly - zoomable, pannable, a hover text per bar with begin, end and duration, and a legend toggling categories; the time axis starts at the trace's begin. Write() writes HTML or plotly's JSON; PlotlyJavaScript embeds plotly's library (default, works offline) or loads it from the CDN. Both renderers check every constructor parameter (None, type, range); QUEUED_LEGEND_LABEL names the waiting bars.

    • A trace's elements share one base-class, TraceElement (#422): a Span and an Event have a name, a parent and attributes, validated in one place. get(key, default=None) reads an optional attribute - since #576 the mixin's, with IterateAttributes() yielding (key, value) pairs.

    • pyTooling.Tracing.CI reads a CI pipeline into a trace (#403): PipelineTrace, WorkflowSpan, MatrixSpan, QueuedSpan, JobSpan, StepSpan, each naming its kind and taking the OpenTelemetry CI/CD attributes as parameters. They are service-independent; pyTooling.GitHub's reader of GitHub Actions runs is the first reader.

    • A trace can be built from times recorded elsewhere (#402), e.g. a CI job or a log line; a Span took its times only from a with-statement.

    • A software execution trace exports itself as OTLP/JSON (#339): Trace.ToOTLPJSON(), ToOTLPJSONString() and WriteOTLPJSONFile(), accepted natively by an OpenTelemetry collector and by Jaeger since v1.35. The document is typed (twelve TypedDict classes for the OTLP messages); identifiers are drawn when a Trace or Span is constructed, so two exports report the same traceId and Trace.TraceID can be handed to another process. Timestamps are exact to the microsecond (#515).

    • A trace reads itself back from OTLP/JSON (#348): Trace.FromOTLPJSON(), FromOTLPJSONString() and ReadOTLPJSONFile(). The tree is reassembled from the spans' parentSpanId references without recursion; re-exporting reproduces the document byte for byte. A document is first validated against the new JSON schema OTLP-Traces-v1.0.schema.json (TraceCollection.SCHEMA_FILES, SCHEMA_VERSION_LATEST) (violations become notes of the TracingError, naming their position) which needs the new extra pyTooling[tracing] (jsonschema); writing needs none.

    • A TraceCollection holds what several documents carry (#348): complete traces and fragments - timespans whose parent arrived in no document yet. A fragment's root is a Fragment, keeping the identifiers of its trace and awaited parent. Documents may be added in any order (AddOTLPJSON(), AddOTLPJSONString(), AddOTLPJSONFile()); a fragment is attached once its parent arrives, a colliding document changes nothing. Traces and timespans are indexed by identifier (collection[identifier], in, TraceIDOfSpan(), IterateFragmentsOf()); the collection writes itself back as OTLP/JSON. Errors: TraceNotFoundError, SpanNotFoundError, DuplicateTraceError, DuplicateSpanError, SpanCycleError.

    • Writing a file into a missing directory raises a DirectoryCreatedWarning (#348) before the directory is created, so a program can report the path.

  • pyTooling.Tree

    • Node.Children and Node.ChildCount (#573): a tuple of the direct children and their number; the Tree page named Children already.

    • Node.RemoveChild and Node.RemoveChildren (#578), counterparts of AddChild / AddChildren: a removed child takes its subtree along and becomes a root, like assigning None to its Parent. A node that isn't a direct child raises the new NotAChildError, carrying the node in Child, derived from the common base pyTooling.Exceptions.NotAChildError.

    • Node.Delete() deletes a node and its subtree and breaks their reference cycles (#620): a node with a parent is removed first, like RemoveChild(); then every node of the subtree loses root, parent, children and key-value-pairs and is unusable - its value is kept; reference counting frees it at once (100 000 nodes: 40.3 MiB in 19.5 ms instead of 52 ms for gc.collect()).

  • pyTooling.URI

    • New package pyTooling.URI (#662): URLs, their hosts, schemes and query parameters, and the SCP-like address - formerly pyTooling.GenericPath.URL.

    • URI is the generic URI of RFC 3986 (#663) - Scheme, SchemeSpecificPart, Query, Fragment - for every scheme (mailto:, tel:, urn:); URL derives from it. URI.Parse returns a URL for a URI with // and a scheme of Protocols. URL.Parse(text, defaultScheme=Protocols.HTTPS) prepends a scheme to a URL without one, as a browser does.

    • URN is a Uniform Resource Name of RFC 8141 (#664), derived from URI: NamespaceID, NamespaceSpecificString, Resolution (?+); the q-component (?=) is read into Query, the f-component into Fragment. URN.Parse checks the namespace identifier and the characters; URI.Parse returns a URN for the scheme urn - urn:isbn:0451450523 had been a URI with the scheme-specific part isbn:0451450523.

    • SCPAddress reads [user@]host:[path] (#661), as scp and Git write it (git@github.com:org/repo.git); ToURL() gives ssh://git@github.com/~/org/repo.git.

    • A query parameter is an object (#660): QueryParameter with Key, Value, Values; KeyValuePair for a key written once, KeySequencePair for a repeated key (Value = last value).

    • URL.Parse(url, iri=True) reads IRIs (#639, RFC 3987): a Unicode host becomes its IDNA 2008 / UTS #46 A-label (www.müller.de → www.xn--mller-kva.de), other non-ASCII characters are percent-encoded; Host.UnicodeHostname gives the Unicode form. New extra idna (pyTooling[idna]).

  • pyTooling.Versioning

    • ⚠️ PythonVersion understands the spellings PEP 440 normalizes, and keeps them (#436, #517): 10.0.0-pre1, -preview1 and c1 are release candidates, 10.0.0-dev, .dev and dev are .dev0. A parsed version is written as parsed, compares and hashes like its normal form; Normalize() returns that form, Parse(..., normalize=True) returns it directly. SemanticVersion keeps the release level's spelling too (spelling parameter, ReleaseLevelSpelling). Since #517 an own case-insensitive pattern reads every PEP 440 spelling - see Bug Fixes.

    • Calendar versions carry a release level, a post-release, a development release, a build number and a postfix (#640, #649), as SemanticVersion does: CalendarVersion, YearMonthVersion, YearWeekVersion, YearReleaseVersion and YearMonthDayVersion parse, compare, hash and write e.g. 2024.01-rc1, 2024.01.post2.dev3, 2024.10.15+deadbeef and OSVVM's 2025.06a. Separators are permissive (., - or none); a letter without a number is a postfix, so 2025.06a follows 2025.06, and the postfix separator is kept (#649): 2025.06a is written 2025.6a. New keyword-only parameters level, number, post, dev, hash and postfixSeparator; format() knows %p, %b, %r, %R, %n, %d and %P. Parsing a numbers-only calendar version is 5-13 % slower.

    • pyTooling.Versioning parses and matches version expressions (#369) — what a requirement file writes, in four dialects (e.g. npm's ^1.2.3, dpkg's >>1.0, <<2.0). VersionConstraint is one comparison and a container of the versions satisfying it (in). VersionComparison names the ten comparisons; RangeVersionConstraint is the base of the three shorthands deriving an upper bound: CompatibleVersionConstraint (PEP 440's ~=), CaretVersionConstraint and TildeVersionConstraint (npm's ^ and ~). A VersionExpression is a conjunction of constraints (an empty one matches every version), the ecosystem-neutral base of PythonVersionExpression, NPMVersionExpression and DebianVersionExpression. Both convert with ToVersionRange() where the comparison describes a single interval.

    • ⚠️ A version number can carry an epoch (#370), for a project whose new version numbers sort below the old ones: SemanticVersion.Parse("2:1.0.0") (Debian's spelling, the default), PythonVersion.Parse("2!1.0.0") (PEP 440's). A scheme spelling the separator differently overrides _EPOCH_SEPARATOR (ClassVar[str], #374). Version.Epoch reads it, Parts.Epoch says it is present, and an epoch orders before every other part — see Breaking Changes for the renumbering.

    • A VersionRange can be open at either end (#371): either bound may be None (unbounded), so >=1.0 and Maven's [1.0,) are expressible and VersionConstraint.ToVersionRange() works one-sided. __and__ and both setters handle it; two bounds still need compatible version types.

  • pyTooling.Warning

    • DirectoryCreatedWarning (#348): a non-critical warning carrying the Directory a writer is about to create; pyTooling.Tracing's OTLP/JSON writers raise it.

⚠️ Breaking Changes

  • pyTooling.Attributes

    • ⚠️ In a class not built by ExtendedType, GetFunctions() no longer returns marked methods (#499); GetMethods() does.

    • ⚠️ An attribute's scope is enforced (#489): applying an attribute where its AttributeScope disallows raises the new AttributeScopeError; pyTooling's own attributes keep the default scope Any.

  • pyTooling.Attributes.ArgParse

    • ⚠️ TerminalApplication._PrintHelp moved to ArgParseHelperMixin, which owns the parsers it prints (#329). An application calling _PrintHelp() derives from both classes.

    • ⚠️ An OptionalValuedFlag passes a string or None instead of a boolean (#673) - see Bug Fixes. A bare option passes defaultValue, "" by default, which is false: ask args.x is not None whether the option was given.

  • pyTooling.CLIAbstraction

    • ⚠️ A program found in PATH is the full path (#495) in Program.Path and as the first item of ToArgumentList(), e.g. /usr/bin/git instead of git.

    • ⚠️ str() of a Program or an argument is a command line escaped for the platform (#496), instead of every argument in double quotes; a key-value flag writes one pair per key. Code parsing that text adapts; ToArgumentList() returns the list.

    • ⚠️ Program._executableNames is _EXECUTABLE_NAMES (#613): class constants are upper case; a class still setting _executableNames is no longer read. There is no alias.

  • pyTooling.Configuration

    • ⚠️ pyTooling.Configuration.Configuration.__init__ no longer takes root and parent (#368) — see Changes. A format implementation drops both arguments.
  • pyTooling.Decorators

    • ⚠️ InheritDocString loses merge, summaryOnly and order; DocStringMergeOrder is renamed to DocStringMergeStrategy (#327), DerivedFirst to BaseLast, without aliases. The default changes: on an entity with a doc-string, @InheritDocString(Base) appends the base's instead of replacing it. merge=True is DocStringMergeStrategy.BaseFirst: a one-substitution migration.
  • pyTooling.Dependency

    • ⚠️ The extra pypi is named dependencies (#635): install pyTooling[dependencies]; there is no alias.

    • ⚠️ A release version the parser can't read raises a critical UnsupportedVersionWarning (#581) instead of a print(), e.g. pytz' pre-PEP 440 2004d … 2013d. The release is still skipped; the warning carries ProjectName and Version. Without a WarningCollector, Project.UpdateDetailsFromPyPIJSON, Project.DownloadDetails and PythonPackageIndex.DownloadProject raise UnhandledCriticalWarningError; call them inside with WarningCollector() as warnings:.

    • ⚠️ BrokenRequirementWarning and ReleaseDetailsWarning are critical warnings (#564): without a WarningCollector, RequirementsFile.Parse, Project.DownloadReleaseDetails, PythonPackageIndex.DownloadProject(..., LazyLoaderState.PostProcessed) and the Resolver raise UnhandledCriticalWarningError (the warning is its __cause__) instead of dropping it. Wrap such a call in a collector.

  • pyTooling.Exceptions

    • ⚠️ ExceptionBase has no message field anymore (#584): use str(ex) or the new read-only ExceptionBase.Message. pyEDAA.OutputFilter's ClassificationException read it; fixed by edaa-org/pyEDAA.OutputFilter#118.
  • pyTooling.Filesystem

    • ⚠️ Directory.ScanDuration, AggregateDuration and Docker.LayerCake.SlicingDuration return a timedelta (#595) instead of a float of seconds. Call .total_seconds() for the number.

    • ⚠️ PermissionWarning takes a message and carries the directory (#609): PermissionWarning(path) becomes PermissionWarning(message, directory=...), and warning.Path becomes warning.Directory.Path.

  • pyTooling.GenericPath

    • ⚠️ PathMixin.Parse(path, root=None) reads the flavour's ELEMENT_TYPE (#414) instead of taking the path and element classes: a path flavour outside pyTooling drops both arguments and declares ELEMENT_TYPE.

    • ⚠️ The four mixin-classes of pyTooling.GenericPath are spelled ***Mixin (#419), without aliases: PathMixIn, ElementMixIn, RootMixIn, SystemMixIn → PathMixin, ElementMixin, RootMixin, SystemMixin. No sibling repository imports them.

  • pyTooling.Graph

    • ⚠️ The elements take their key-value-pairs from pyTooling.DataModel.KeyValuePairsMixin; the key and value type parameters are removed (#575): Graph takes 11 type parameters instead of 23, Vertex and Subgraph 11 instead of 17, View and Component 11 instead of 19, Edge and Link 4 instead of 5 (the kinds are type parameters now, #445). The old number raises a TypeError, also at import for an annotated class field. The 16 ***DictKeyType/***DictValueType type variables are removed; non-mapping key-value-pairs raise a TypeError.

    • ⚠️ Iterating a Graph yields its keys (#575), as len(graph) counts its key-value-pairs; it yielded ID-less vertices, then the other vertices' IDs. IterateVertices() yields the vertices.

    • ⚠️ pyTooling.Graph: the kind precedes keyValuePairs (#443) in EdgeToVertex, EdgeFromVertex, LinkToVertex, LinkFromVertex and the constructors of Edge and Link: a key-value mapping passed positionally lands in the kind. Pass keyValuePairs by keyword.

  • pyTooling.MetaClasses

    • ⚠️ slots=True on a class whose base-class has no slots raises BaseClassWithoutSlotsError (#638): declare the base with slots=True, or drop slots=True from the derived class. No class of the family is affected.

    • ⚠️ A mixin-class' slot that exists already raises DuplicateFieldInSlotsError (#504): a field of the class itself, a slot of the primary inheritance line, or a slot of a second mixin-class - see Bug Fixes.

    • ⚠️ A slotted secondary base-class has to be a mixin-class, also without fields (#505), else ExtendedType raises BaseClassIsNotAMixinError: declare it with mixin=True or apply @mixin. No consumer in the workspace uses such a base-class.

    • ⚠️ A class variable shadowing a mixin-class' slot raises DuplicateFieldInSlotsError (#373) at class creation - see Bug Fixes. ClassVar[...] is not the fix: rename the class variable, or drop the slot.

    • ⚠️ Annotating __weakref__ or __dict__ raises an ExtendedTypeError (#336) — see Changes. Pass weakref=True instead of annotating __weakref__; a class whose instances need a __dict__ uses slots=False.

  • pyTooling.Packaging

    • ⚠️ DescribePythonPackage raises when it finds no description (#340), instead of publishing a package without one: pass description or give the file named by sourceFileWithVersion a module doc-string. description moved behind the required parameters; pass it by name.

    • ⚠️ A direct reference in an extra raises UnhandledCriticalWarningError, unless a WarningCollector collects it (#561): a setup.py whose extras may contain one wraps setup(...) in with WarningCollector() as warnings:. Known: pyTooling.Sphinx, pyTooling.GitHub, pyEDAA.Reports.

    • ⚠️ A package described with DescribePythonPackage carries no License :: classifier (#338); its license is the SPDX expression in license only. A consumer reading the license from the classifiers reads license instead.

  • pyTooling.Platform

    • ⚠️ UnknownPlatformError(message, *, unknownValue) (#594, #668) replaces *args: pass one message, and the unknown value as unknownValue; a message only is a TypeError. UnknownOperatingSystemError derives from it (#669) and requires unknownValue too.
  • pyTooling.Stopwatch

    • ⚠️ Stopwatch.Duration, Activity and Inactivity return a timedelta (#595) instead of a float of seconds. Use the new DurationInSeconds, ActivityInSeconds and InactivityInSeconds for the number.
  • pyTooling.TerminalUI

    • ⚠️ An application names its package once, as DUNDER_MODULE (#637): ISSUE_TRACKER_URL is removed and _PrintVersion() takes no module - set DUNDER_MODULE = myPackage in the application class.

    • ⚠️ TerminalBaseApplication.PrintMissingDependencyException() is PrintMissingDependencyError() (aa18469), after the exception it prints. There is no alias.

    • ⚠️ TerminalApplication.HeadLine is HEADLINE, TerminalBaseApplication.Foreground is FOREGROUND (#613): the old names are no longer read, and **self.Foreground fails. There are no aliases.

  • pyTooling.Testing

    • ⚠️ ApplicationTestcase.RunEntrypoint and RunModule merge environment into this process's environment (#492) instead of replacing it; a value of None removes a variable.

    • ⚠️ ApplicationTestcase._consoleScript and _runnableModule are _CONSOLE_SCRIPT and _RUNNABLE_MODULE (#613): the old names are no longer read. There are no aliases.

  • pyTooling.Tracing

    • ⚠️ Span.Duration returns a timedelta (#605) instead of a float of seconds; arithmetic with a number or a float format spec raises a TypeError. Use Span.DurationInSeconds for the number.

    • ⚠️ Iterating a Trace, Span or Event yields the attribute keys (#576), as for every key-value element; IterateAttributes() yields the former (key, value) pairs. Keys are Hashable, values Any; the OTLP export still checks each value.

  • pyTooling.Tree

    • ⚠️ len() and iteration of a Node address its key-value-pairs (#573), like [], del and in: len(node) counts the pairs, iteration yields the keys; both addressed the children. Use ChildCount, Children or GetChildren().

    • ⚠️ Node takes two type parameters instead of four (#574): Node[IDType, ValueType]; the type variables DictKeyType and DictValueType are removed. Node[a, b, c, d] raises a TypeError, also at import for a module-level alias (pyTooling/pyTooling.Sphinx#51). Key-value-pairs given as anything but a mapping raise a TypeError.

  • pyTooling.URI

    • ⚠️ pyTooling.GenericPath.URL is pyTooling.URI (#662): import URL, Host, Protocols, URLError, ... from pyTooling.URI; there is no alias.

    • ⚠️ URL(...) requires a scheme (#663): None raises ValueError. Pass a Protocols member.

    • ⚠️ URL.Query holds a QueryParameter per key (#660), not a string: read url.Query[key].Value. The constructor's query is an iterable of parameters, not a mapping.

    • ⚠️ URL.Parse rejects strings that aren't URLs (#636): scheme:// and a host are required (only file/unix may omit the host); "", "nonsense", host:port and opaque URIs (mailto:, sip:) raise URLError. URL_REGEXP requires the scheme and ends in \Z.

    • ⚠️ Host rejects a host name that is None or empty with a ValueError (#485). An empty name was accepted, and None raised a TypeError.

  • pyTooling.Versioning

    • ⚠️ The third numeric part of a YearMonthVersion, YearWeekVersion or YearReleaseVersion is its build number (#640): YearMonthVersion.Parse("2024.10.15") is build 15 of 2024.10 instead of a ValueError. To reject a date, parse with YearMonthDayVersion or check Parts.Build in version.Parts.

    • ⚠️ A calendar version compares and hashes every part (#640) — see Bug Fixes: YearMonthVersion.Parse("2025.06a") == YearMonthVersion.Parse("2025.06") is False, and a build number counts. To compare only the numbers, compare Year and Month (Week, Release, Day).

    • ⚠️ A postfix makes a version newer (#640) — see Bug Fixes: 1.2.3 < 1.2.3+build.5. To sort both alike, remove the postfix first.

    • ⚠️ SemanticVersion.Parse("v2.0.0-rc.1") is release candidate 1 (#640) — see Bug Fixes. Code reading the release number from Postfix reads ReleaseNumber.

    • ⚠️ SemanticVersion.Parse("1.0a1") is written 1.0.a1 (#436), as parsed, not 1.0.alpha1. A version constructed without a spelling is written as before.

    • ⚠️ A dev or post part counts when it's present (#436): 1.0.dev0 == 1.0 and 1.0.post0 == 1.0 are False now, as PEP 440 orders them.

    • ⚠️ SemanticVersion.Parse("1.2.3foo") raises ValueError (#672) instead of returning 1.2 with the postfix 3foo; so do 1.2.3_x and a fifth number (1.2.3.4.5) - see Bug Fixes. Separate a postfix by ., - or + (1.2.3.foo, 1.2.3-1).

    • ⚠️ PythonVersion.Parse rejects what PEP 440 doesn't spell (#517): a local version after a . (1.0.0.foo raises ValueError; write 1.0.0+foo, after - it still works), and the release levels final and pl.

    • ⚠️ A release level without a number is a pre-release (#494): SemanticVersion.Parse("1.0.0-alpha") was a final release with the postfix alpha; it is 1.0.0.alpha0.

    • ⚠️ Every member of pyTooling.Versioning.Parts has a new value (#370): Epoch = 1 takes the lowest bit, the other fifteen doubled (Major 1 → 2, Prefix 128 → 256, Hash 512 → 1024). Use by name is unaffected; a persisted value or a comparison against a literal has to be re-read.

    • ⚠️ VersionRange.__and__ no longer includes bounds the operands exclude (#372) — see Bug Fixes.

  • General

    • ⚠️ Exceptions and warnings require their descriptive values (#634, #668); a message only is a TypeError, pass the values by keyword. Optional in v9.0.0: DuplicateVertexError (vertexID), DuplicateEdgeError (edgeID), SupervisedThreadError (threadName), VersionValidatorError (version). Without an initializer of their own in v9.0.0: KeyNotFoundError (key), UnsupportedValueTypeError, InterpolationError, PathExpressionError; NoSessionAvailableError, ProjectNotFoundError, ReleaseNotFoundError (projectName), ReleaseDetailsWarning; the slot, mixin, metaclass and abstract-class errors of MetaClasses and UnannotatedFieldWarning; UnknownPlatformError (unknownValue); AlreadyInTreeError, NotInSameTreeError (otherNode).

    • ⚠️ Membership tests are named Contains*** (#484), without aliases: Has*** checks a property or feature, Contains*** asks for a key, name or ID. Graph.HasVertexByID / HasVertexByValue → ContainsVertexByID / ContainsVertexByValue, GraphMLDocument.HasKey → ContainsKey. Vertex methods asking for an edge or link keep Has***.

    • ⚠️ 32 exception classes in 15 modules are renamed to the ***Error suffix, without aliases (#320); v9.0.0 has only the ***Exception names. ***Exception is reserved for a package's base exception (pyTooling: ToolingException, unchanged), as in PEP 8 and the builtins. EnvironmentException becomes EnvironmentVariableError, not EnvironmentError (a builtin alias of OSError).

🔧 Changes

  • pyTooling.Attributes

    • ⚠️ An attribute files a method under methods when the decorator runs (#499), recognized by its qualified name, instead of ExtendedType moving it there after searching every module-level function the attribute marks: 100 classes of 50 marked methods beside 10000 such functions take 16-29 ms instead of 531-553 ms. GetMethods(scope=...) still needs ExtendedType.

    • ⚠️ An attribute's scope is enforced (#489) — closes #384. A function in a class body is recognized as a method by its qualified name (<Class>.<name>), so all three kinds are checked when the decorator runs, for any class. Attribute.Scope is an instance property; on the class it returned the property object.

    • Attribute._SCOPE (#613) is the class constant's name; it was _scope.

  • pyTooling.Attributes.ArgParse

    • ⚠️ _PrintHelp is a method of ArgParseHelperMixin (#329), which owns the parsers it prints; it writes through TerminalApplication's Write*** methods.
  • pyTooling.Cartesian2D, pyTooling.Cartesian2D.Shapes, pyTooling.Cartesian3D

    • The type checks quote their types (#659), as the other packages do: "Parameter 'x' is not of type 'float' or 'int'." (was "integer or float"), "... 'Offset2D' or 'tuple'.", "... 'Point2D'.".
  • pyTooling.CLIAbstraction

    • ⚠️ str() of a Program or an argument is a command line escaped for the platform (#496): shlex.join for POSIX, subprocess.list2cmdline on Windows (pyTooling.CLIAbstraction.Argument.formatCommandLine). Every argument was put in double quotes, inner quotes unescaped, and a key-value flag's list in one pair of quotes; it is -Dkey1=value1 -Dkey2=value2 now. repr() is a Python literal; dry-run mode logs the escaped command line.

    • ⚠️ Class constants are spelled in upper case (#613): Program._EXECUTABLE_NAMES, and the argument classes' _PATTERN, _NAME, _VALUE_PATTERN, _FALSE_PATTERN and _PATTERN_WITH_VALUE (were _executableNames, _pattern, _name, ...).

  • pyTooling.Common

    • getResourceFile and readResourceFile import importlib.resources when called (#624), not at module level, where it loaded 37 modules (zipfile, compression.*, ...): import pyTooling.Common takes 8.7 ms instead of 21.6 ms (Python 3.15). Both take a str or a Path (#389).
  • pyTooling.Configuration

    • A configuration node's length is its raw node's length (#548), not a copy taken at construction - in preparation for writable configurations.

    • ⚠️ pyTooling.Configuration.Configuration.__init__ takes only configFile (#368): a configuration is the root node, its own root, without parent. pyTooling.Configuration.JSON and pyTooling.Configuration.YAML already passed only configFile.

  • pyTooling.Decorators

    • Importing pyTooling.Decorators doesn't load inspect (#624): InheritDocString's decorator function imports inspect.cleandoc when it runs.

    • ⚠️ InheritDocString takes one strategy, a DocStringMergeStrategy, instead of merge, summaryOnly and order (#327); its default BaseLast appends the base-class' doc-string below the entity's own.

  • pyTooling.Dependency

    • ⚠️ The extra pypi is named dependencies (#635), by what it serves: MissingDependencyError hints and the docs name pyTooling[dependencies].

    • ⚠️ A release version the parser can't read is reported as a critical UnsupportedVersionWarning (#581) instead of by print().

    • ⚠️ BrokenRequirementWarning (with UnparsableRequirementWarning and UndeclaredExtraWarning) and ReleaseDetailsWarning derive from CriticalWarning (#564): a dropped requirement or release went unnoticed without a collector. UnknownLicenseWarning, UnresolvedRequirementWarning and UndeclaredExtraMarkerWarning stay non-critical. Without a collector, Project.DownloadReleaseDetails raises for the first unreadable release after all downloads finished, dropping none.

    • PythonPackageIndex.DownloadProject looks up a project once (#597) and returns it for every later call, also under another spelling (PEP 503 canonical name); a project cached at a lower load state is loaded further. A 404 Not Found is remembered (UnknownProjects) and raises ProjectNotFoundError again without a request.

    • New module pyTooling.Dependency.Resolver (#598, #599, #606): Resolver(index, *, strategy, limit, preReleases) picks the newest release matching each requirement and follows its requirements level by level, in parallel over one aiohttp session; ResolutionStrategy (Lazy/Eager), limit and PreReleasePolicy (default: pip's rule) steer it. Each ResolvedRequirement has a Status (ResolutionStatus); an unknown project gives the new UnresolvedRequirementWarning, a failed request the new critical PackageIndexFailureWarning; neither raises. No backtracking. Markers are evaluated against environment (CurrentEnvironmentResolver: the running interpreter's); extras add their requirements. Inputs: ResolveRequirements(), ResolveRequirementsFile(), ResolvePackages() (an undeclared extra raises the new ExtraNotFoundError). The current Timeout bounds a resolution (#606): the unresolved rest gets TimeoutExpired and a critical ResolutionTimeoutWarning; the partial result is returned.

  • pyTooling.Exceptions

    • ⚠️ ExceptionBase hands its message to Exception.__init__ (#584): args is (message, ) (was ()), repr() shows the message. The message field and the __str__ override are removed; str(ex) is unchanged.

    • MissingDependencyError is a ToolingException and an ImportError (#591), so except ToolingException catches a missing optional dependency; except ImportError still does. It is deliberately no ExceptionBase. ImportError.name is the missing package.

  • pyTooling.Filesystem

    • Base.ToTree is abstract (#552): every concrete element implements it; a derived class without ToTree raises AbstractClassError on instantiation instead of NotImplementedError on the call.

    • Element.Path of pyTooling.Filesystem is abstract (#547): Element and a derived class without Path raise AbstractClassError on instantiation instead of NotImplementedError when read.

    • ⚠️ The scan and slicing durations are the stopwatch's timedeltas (#595), which print as h:m:s.

    • ⚠️ PermissionWarning carries the Directory that couldn't be read (#609) and a message (Directory '<path>' can't be read.), which it didn't have.

  • pyTooling.GenericPath

    • ⚠️ PathMixin.Parse creates the path from the flavour's ELEMENT_TYPE (#414), so a flavour needs no Parse of its own.

    • ⚠️ The four mixin-classes of pyTooling.GenericPath are spelled ***Mixin (#419), e.g. PathMixin (was PathMixIn).

  • pyTooling.Graph

    • ⚠️ The elements take their key-value-pairs from pyTooling.DataModel.KeyValuePairsMixin (#575), with Hashable keys and Any values, instead of each class defining the same dictionary methods.

    • ⚠️ Iterating a Graph yields its keys (#575), like every element with key-value-pairs.

    • ⚠️ An edge or link takes its kind as a parameter before keyValuePairs (#443): edgeKind / linkKind in the vertex methods, kind in the constructors of Edge and Link.

    • An edge joining two components of a Graph moves the smaller component into the larger one (#502), not always the destination's into the source's (quadratic): a 10000-vertex graph from an edge list takes 55 ms instead of 633 ms, 10000 EdgeFromNewVertex calls 39 ms instead of 1830 ms. Of two equal components, the source's is kept.

  • pyTooling.Graph.GraphML

    • Attribute values are escaped with str.translate instead of xml.sax.saxutils.escape (#624), whose import loaded urllib.request, ssl, http.client, email and 80+ more modules: import pyTooling.Graph.GraphML takes 28 ms instead of 59 ms (Python 3.15). Output is byte-identical; a short ID takes 141 ns instead of 390 ns.

    • GraphML's Base declares its four writing methods abstract (#513): Tag, OpeningTag, ClosingTag and ToStringLines raised a bare NotImplementedError, so an incomplete derived class could be instantiated. An element written in only one way uses @notimplemented.

    • GraphMLDocument._XML_NS and _XSI (#613): the namespace constants are package-private and upper case; they were xmlNS and xsi.

  • pyTooling.MetaClasses

    • ExtendedType creates classes faster, and a @readonly property reads faster (issue #531), measured on Python 3.14:

      • a textual annotation is compiled once per distinct text (#534): 1.0 µs instead of 11.4 µs for a repeated text, 26 to 59 % less time per class with postponed annotations;
      • abstract-method implementations are searched only if a base-class has abstract methods (#535): pyVHDLModel's 412 classes 2.2 ms → 0.95 ms;
      • __new__ is recognized as an abstract base-class' replacement without raising and catching an AttributeError (#537): 0.4 µs less per class;
      • the __slots__ check skips the walk below slotted ExtendedType base-classes (#538): 10.7 µs → 7.6 µs at hierarchy depth 6;
      • @readonly declares its __get__ overloads under TYPE_CHECKING only (#539): 41 ns instead of 189 ns per read.
    • ⚠️ BaseClassIsNotAMixinError is raised (#505) for a secondary base-class built by ExtendedType with slots=True but without mixin=True. It was declared but never raised: with fields, such a class raised BaseClassWithNonEmptySlotsError, without, it was accepted.

    • pyTooling.MetaClasses uses TAttributeFilter of pyTooling.Attributes (#506) instead of its own copy, a union of attribute instances, while GetMethodsWithAttributes takes attribute classes.

    • ⚠️ Annotating __weakref__ or __dict__ raises an ExtendedTypeError (#336); both became slots silently, and a __dict__ slot gave up what slots save. The exception's note names the keyword to use instead.

    • ExtendedType binds an annotated class variable before __init_subclass__ runs (#373): the hook read the inherited value of a class variable a derived class overrode with an annotation (Sep: ClassVar[str] = "#" in Leaf(Base): ":", now "#"). SemanticVersion's epoch separator and VersionExpression's per-dialect pattern rely on it.

  • pyTooling.Packaging

    • DEFAULT_PY_VERSIONS is 3.11 to 3.15 (#491, #557): without pythonVersions, a package published python_requires >=3.10, though 3.10 reaches its end of life in October 2026.

    • ⚠️ A package's license is stated as an SPDX expression only; no License :: classifier is added (#338) — closes #219. The classifier made every build warn SetuptoolsDeprecationWarning: License classifiers are deprecated.; license has always been filled from License.SPDXIdentifier. A caller's classifier is kept and reported on the console.

  • pyTooling.Platform

    • FreeBSD is a supported platform (#417): IsNativeFreeBSD no longer implies x86-64, AArch64 is recognized, and GetMemoryUsage reports the resident size via resource.getrusage, with 0 for the virtual size the standard library doesn't expose there.

    • ⚠️ UnknownPlatformError takes one message and carries the unknown value (#594, #668): UnknownValue is the architecture or platform name the system reported; the initializer took *args, unlike every other exception of the package.

    • ⚠️ UnknownPlatformError is the base-class of three specific errors (#669), which Platform raises instead of it: UnknownOperatingSystemError (os.name), UnknownEnvironmentError (the platform on Windows, sys.platform on POSIX) and UnknownArchitectureError (the processor architecture). except UnknownPlatformError still catches all of them. UnknownOperatingSystemError was a PlatformError without data.

  • pyTooling.Process

    • ProcessInformation._PROCESS_STATUS_FILE and _PAGE_SIZE (#613) are the class constants' names; they were _processStatusFile and _pageSize.
  • pyTooling.Stopwatch

    • ⚠️ Duration, Activity and Inactivity return a timedelta (#595); the new DurationInSeconds, ActivityInSeconds and InactivityInSeconds return the former float; DurationInNanoseconds and str() are unchanged. A stopped stopwatch converts its duration once, on first access.
  • pyTooling.TerminalUI

    • ⚠️ DUNDER_MODULE is the one source of the package's dunder variables (#637): _PrintVersion() and the exception printers read __issue_tracker_url__ etc. from it; None or a missing variable prints no tracker line. It replaces the class variable ISSUE_TRACKER_URL and the module passed to _PrintVersion(module).

    • ⚠️ TerminalBaseApplication.PrintMissingDependencyError() (aa18469, pushed to dev without a pull-request) is named after the exception it prints, MissingDependencyError.

    • ⚠️ HEADLINE and FOREGROUND (#613) are the class constants' names; they were HeadLine and Foreground.

    • pyTooling.TerminalUI imports without colorama (#614) and writes uncolored text then: the palette FOREGROUND holds empty strings. Without the terminal extra the import raised MissingDependencyError, so the fallback was never reached. _PrintVersion takes its red from the palette.

  • pyTooling.Testing

    • ⚠️ ApplicationTestcase merges a test's environment (#492) instead of passing it as the whole environment, which lost e.g. SystemRoot (needed by Winsock) on Windows unless the caller wrote {**environ, ...}. Named variables are set, None removes one, the rest is inherited.

    • ApplicationTestcase waits 60 seconds for a program by default (#483), not 10, as RunEntrypoint and RunModule timed out on loaded macOS and PyPy runners.

    • ApplicationTestcase no longer requires _RUNNABLE_MODULE (#426): a program without __main__ module is tested through its entry point; only RunModule needs the module and raises an ApplicationTestingError with a note without it.

    • ⚠️ _CONSOLE_SCRIPT and _RUNNABLE_MODULE (#613) are the class constants' names; they were _consoleScript and _runnableModule. Test-class constants throughout the testsuite follow the same rule.

  • pyTooling.Tracing

    • ⚠️ Span.Duration returns a timedelta (#605), as Stopwatch.Duration does; the new Span.DurationInSeconds returns the former float. The Duration of every pyTooling.CI element and GanttLayout.Duration are timedeltas too, each with a DurationInSeconds (new in v10.0.0, so not breaking).

    • Span.Format() and Trace.Format() show -- for a timespan that never started (#516) instead of raising a TypeError that made the whole trace unprintable. Duration still raises.

    • ⚠️ A Trace, Span or Event takes its attributes from pyTooling.DataModel.KeyValuePairsMixin (#576), with Hashable keys and Any values; iterating one yields the attribute keys.

    • Trace and span identifiers are read from os.urandom (#632) instead of secrets.randbits: 338 instead of 895 ns per identifier, and secrets loaded random, hmac, hashlib and bisect; CategoryStatistics.AverageWaitTime/AverageRunTime import statistics.fmean when called. Import time (Python 3.15): pyTooling.Tracing 22.40 → 18.92 ms, pyTooling.Tracing.Render 30.19 → 22.49 ms.

  • pyTooling.Tree

    • ⚠️ len() and iteration of a Node address its key-value-pairs (#573), like [], del and in. They addressed the children, which ChildCount and Children do now.

    • ⚠️ Node takes its key-value-pairs from pyTooling.DataModel.KeyValuePairsMixin (#574): Node[IDType, ValueType] instead of four type parameters; DictKeyType and DictValueType are removed.

  • pyTooling.URI

    • ⚠️ The URL module is the package pyTooling.URI (#662), as it grew beyond a path flavour; pyTooling.GenericPath stays the base of hierarchical paths.

    • ⚠️ URL derives from URI (#663): Query and Fragment come from the base-class, URL.Scheme stays a Protocols member, URL.SchemeSpecificPart is //user@host/path; a scheme of None was accepted and raises ValueError now. URL has slots, so an instance has no __dict__.

    • ⚠️ A repeated query key keeps every value (#660): ?tag=a&tag=b kept only b and was written back as ?tag=b; it is a KeySequencePair now and round-trips. URL(..., query=...) takes QueryParameters and rejects a key given twice.

    • ⚠️ URL.Parse follows RFC 3986 (#636): scheme and host required, a path after the host starts with /, forbidden characters and ports above 65535 raise URLError; user names with . or percent-encoding parse. Every group of URL_PATTERN was optional (https://www.müller.de/ gave host www.m). RESTClient raises its documented ValueError for a non-URL. An empty query (?), empty fragment (#) and a query parameter without = round-trip (#648): ?flag has the value None (v9.0.0 raised "not enough values to unpack"). A : after the host without a port, a path in place of the port and a drive letter as host raise their own URLError with a note giving the probable URL; Git's SCP-like git@github.com:org/repo.git gets the note of its ssh:// URL (#656).

  • pyTooling.Versioning

    • ⚠️ A two-part calendar version reads a third numeric part as its build number (#640); a fourth still raises a ValueError pointing to CalendarVersion and YearMonthDayVersion.
  • General

    • ⚠️ Descriptive values every raise passes are required (#634, #668): 51 exceptions and warnings, e.g. ExtraNotFoundError(projectName, version, extra), PackageIndexError(projectName), DuplicateVertexError(vertexID), KeyNotFoundError(key), UnknownPlatformError(unknownValue); a value a raise can't know stays optional (CycleError.Path).

    • ⚠️ Membership tests are named Contains*** (#484): Contains*** asks for a key, name or ID, Has*** checks a property or feature of an object.

    • ⚠️ 32 exception classes carry the ***Error suffix (#320), as PEP 8 asks.

    • An annotation admitting None is spelled Nullable[...] (#549) at the six places that still wrote Union[..., None].

    • Copies are made with dict(), list(), set() and tuple() (#540) instead of comprehensions that only copy their input, at 11 sites in 9 modules.

    • Two collections are iterated by unpacking instead of itertools.chain() (#527) in pyTooling.Filesystem, pyTooling.Graph, pyTooling.Graph.GraphML and pyTooling.MetaClasses; Graph.ContainsVertexByValue() keeps chain(), as any() stops at the first match.

    • 339 f-strings that interpolate nothing lost their f prefix, across 33 modules (#330).

    • An empty line between two consecutive block statements (#479) (if, for, while, with, try), in 111 places in 38 files. Subgraph.__init__ checks its graph parameter with if/elif like every other parameter check.

🪳 Bug Fixes

  • pyTooling.Attributes

    • Attribute.GetAttributes(method, includeSubClasses=False) returned the attributes of derived attribute classes too (#489); the parameter was ignored.
  • pyTooling.Attributes.ArgParse

    • NamedKeyValuePairsArgument, ShortKeyValueFlag and LongKeyValueFlag parsed no key-value pair (#665), since v9.0.0: they passed FlagArgument's parameterization (action="store_const") to add_argument, so -Dkey=value left -key=value unprocessed and the handler received True. The new argparse.Action KeyValuePairsAction collects the pairs into a dictionary (-DA=1 -DB -> {"A": "1", "B": None}), None if the option isn't given; a new parameter metaName (default KEY=VALUE) names the value in the help page.

    • ⚠️ OptionalValuedFlag, ShortOptionalValuedFlag and LongOptionalValuedFlag parsed no value (#673), since v9.0.0: the same boolean-flag parameterization made --color pass True and --color=red an error. With nargs="?" the handler receives the value (--color=red, --color red), the new defaultValue for a bare option, or None without it; a new metaName names the value in the help page.

  • pyTooling.CLIAbstraction

    • ⚠️ A program searched in PATH kept the bare executable name (#495), so a variant built with executablePath=program.Path raised CLIAbstractionError: Program 'git' not found. unless the working directory had a file of that name.

    • A program in dry-run mode raised an AttributeError (#488): Program.__init__ and Executable.StartProcess called a nonexistent LogDryRun. Program.LogDryRun records the skipped action in Program.DryRunMessages; a derived class may override it.

    • Four rows of the CLI argument prefix table were wrong (#352): ShortBooleanFlag writes -with-flag/-without-flag, not -flag/-no-flag, and the three Windows valued-flag classes write /flag:value, not /flag=value. Three :class: references pointed at the wrong module and rendered as plain text.

  • pyTooling.Cartesian2D

    • LineSegment2D.Length squared the x-difference twice and ignored the y-difference (#631), wrong whenever both differ in magnitude: a vertical segment had length 0.0, (0, 0) → (3, 4) 4.24 instead of 5.0. So LineSegment2D.AngleTo raised ZeroDivisionError for a vertical segment and returned wrong angles otherwise; Rectangle and Square (pyTooling.Cartesian2D.Shapes) raised ZeroDivisionError for every shape with an edge parallel to the y-axis, and Square rejected rotated squares. LineSegment3D.Length was correct.

    • LineSegment2D.AngleTo and LineSegment3D.AngleTo divided by both lengths and called math.acos (#641): a zero-length segment raised ZeroDivisionError, and parallel segments raised ValueError: math domain error when the normalized scalar product rounded outside -1..1 (22678 of 100000 random parallel 2D float pairs); elsewhere up to 2.1e-08 instead of 0.0. The angle is atan2(|cross product|, scalar product) now - exactly 0.0, pi / 2 and pi for integer coordinates - and a zero-length segment raises ValueError with a note. Both check other (#652): None raises ValueError, a non-segment TypeError, instead of an AttributeError.

  • pyTooling.Cartesian2D.Shapes

    • Rectangle accepted a parallelogram (#633), raising only if all four angles were 0.0, and Square a rhombus. Two adjacent inner angles must be 90° (math.isclose), which also rejects crossed quadrilaterals; edge lengths compare with isclose, so a rotated rectangle with float corners passes; coincident corners raise ValueError instead of ZeroDivisionError. Found by the testcases of #629.
  • pyTooling.Configuration

    • A key stated without a value raised an UnsupportedValueTypeError (#490) - key: in YAML, null in JSON. It reads as None; get() returns its default only for an absent key; a variable referencing such a key raises a PathExpressionError instead of becoming "None".

    • An empty YAML file crashed instead of reading as an empty configuration (#376), with AttributeError: 'NoneType' object has no attribute 'keys'.

    • .. above a configuration's root crashed with an AttributeError (#368), in QueryPath("..:key") and in a top-level ${..:key} reference; it raises a PathExpressionError now. Also, Configuration.__init__ overwrote the root node's _root with None, and nested nodes referred to their parent as root; every node refers to the configuration now.

  • pyTooling.Decorators

    • A type checker rejected every function decorated with @export (#550): its type variable was bound to type or FunctionType, which doesn't match a def; bound to type or Callable now, the decorated object keeps its signature.
  • pyTooling.Dependency

    • Project.DownloadDetails() and Release.DownloadDetails() parsed an error page as JSON for an HTTP error status other than 404 (#583), turning a server error into a JSON decode error or a project or release filled from an error document. Both raise a PackageIndexError naming the status and the requested project or release.

    • Release.__init__ couldn't copy its requirements (#540): it unpacked each key of the mapping into two, so {"test": [...]} raised a ValueError. No caller in pyTooling passes it yet.

    • Every release of a project was described by the latest release's metadata (#362): Project.DownloadReleaseDetails() fetched the project endpoint per release (N identical requests); requests 2.20.0 reported 2.32.0's dependencies (charset-normalizer instead of chardet). Release.DownloadDetails() was always right.

    • An environment marker was read as a broken extra (#362), e.g. importlib-resources ~= 3.0; python_version < "3.7". Releases before core-metadata 2.1 have no provides_extra, so every extra == "…" requirement fell through (sphinx_design 0.0.1 kept 1 of 11). Extras are recovered from the markers naming them; a declared provides_extra stays authoritative.

    • The package index downloaded and replaced projects it had loaded already (#597): Release.DownloadDetails passed True as LazyLoaderState and registered a new, unloaded project per requirement; a project failing with 404 Not Found stayed registered; Project/Release re-downloaded their details for a higher load state.

  • pyTooling.Filesystem

    • Directory.ToTree() marked a directory's node as a file and dropped its files and symbolic links (#554): the node is a NodeKind.Directory with subdirectories, files and symbolic links as children. Filename.ToTree() and SymbolicLink.ToTree() take the optional format too, so a user-defined format renders the whole tree.

    • Directory.ResolveSymbolicLinks() raised AttributeError for a relative link target through another symbolic link or a file (#647), e.g. dirlink/f.txt with dirlink -> dir, failing a scan of /usr/share. A link inside a target's path is replaced by its target, as the OS does; a file inside it and a link cycle register the link as broken, a link through one with an absolute target as unconnected.

  • pyTooling.Graph

    • Graph field annotations resolved to a property, not to a class (#512): Graph and Component are also property names in five classes, so typing.get_type_hints(cls, vars(pyTooling.Graph)) raised TypeError or silently resolved to the property; the fields use the aliases _Graph and _Component. LinkedList.Node annotated LinkedList with one of its two type parameters (TypeError: Too few arguments).

    • pyTooling.Graph's field annotations didn't match the classes' type parameters (#445): value and weight were swapped in the Edge[...] / Link[...] subscripts, and typing.get_type_hints(Vertex) raised TypeError: Too few arguments for BaseGraph.

  • pyTooling.Graph.GraphML

    • GraphML: a link between two subgraphs was lost (#511): it is stored in the two subgraphs, but only the graph's links were read. The subgraph translation, a copy of the graph's, also wrote None values and declared a key per vertex and edge; both share one translation now.

    • GraphML: a vertex or tree node without an ID was written as <node id="None"> (#510), all with the same ID, connecting edges by it. Such a node gets a stable generated ID vertex<number>, as in the DOT export.

    • Of several GraphML edges without an ID, only the last added was written (#509), e.g. with EdgeToVertex without edgeID. They are kept in BaseGraph.EdgesWithoutID and written before the edges with an ID. An element's ID is annotated Nullable[str].

    • The GraphML writer wrote None and invalid XML (#500): <data key="nodeValue">None</data> for an element without a value, id="None" for an edge without an ID, and unescaped IDs and key names, so &, < or " invalidated the document.

  • pyTooling.LinkedList

    • A linked-list node ignored its key (#470): Node.__init__ stored the value as Key, so a key given to a node was lost.

    • LinkedList.Clear() left its nodes linked (#619) to the list and their neighbours, so re-inserting one raised NotInSameListError, and the nodes formed a cycle only the garbage collector freed. Clear() unlinks every node now.

    • LinkedList.GetNodeByIndex() documented an exception it never raises, and two methods raised exceptions no :raises: field mentioned (#322).

  • pyTooling.MetaClasses

    • sphinx_autodoc_typehints warned twice per slotted class (#658): ExtendedType's generated __getstate__ and __setstate__ were named ExtendedType.__new__.<locals>.__getstate__ in pyTooling.MetaClasses, skipped with "Cannot handle as a local function". They carry the class' module and qualified name (Node.__getstate__) now; creating a slotted class takes 0.3 to 0.5 µs longer.

    • ⚠️ ExtendedType ignored slots=True silently when the primary base-class had no slots (#638): no __slots__, instances got a __dict__, despite the documented error. It raises BaseClassWithoutSlotsError now; class creation time is unchanged.

    • singleton=True was ignored on a class derived from a non-singleton ExtendedType class (#646): the inherited __isSingleton__ (False) won, so class Final(Base, singleton=True) gave Final() is Final() → False. A class derived from a singleton stays one, also with singleton=False (documented on the MetaClasses page).

    • @singleton, @mixin and @slotted dropped a class' own options (#646): recreating an ExtendedType class with only their own option, @singleton on a slots=True class returned one neither slotted nor singleton, on a mixin-class a non-mixin. The class keeps slots, mixin, weakref and singleton; the decorator adds its option.

    • On Python 3.14+, ExtendedType raised NotImplementedError for a field annotation naming something undefined (#623) without from __future__ import annotations, also for a self-reference like _parent: Node: it called __annotate__ with Format.STRING; annotationlib.call_annotate_function() builds the strings now. Python 3.11-3.13 are unaffected.

    • A slotted class naming object as its base-class raised an IndexError (#546): class C(object, metaclass=ExtendedType, slots=True). An explicit object base-class behaves like none.

    • ⚠️ A ClassVar naming a mixin-class' slot produced a broken class (#373): the name was in __slots__ and a class attribute, so the field was read-only (AttributeError: … attribute '_shared' is read-only). It raises DuplicateFieldInSlotsError now.

    • ⚠️ A mixin-class' field was added to the slots even if that name was a slot already (#504): two slots of one name, or for __weakref__ Python's TypeError: __weakref__ slot disallowed. It raises a DuplicateFieldInSlotsError now. A field two mixin-classes inherit from one mixin-class is one slot now, not two.

    • The initial value of a slotted field was dropped (#524): _name: str = "root" with slots=True or mixin=True raised an AttributeError when read before __init__ assigned it. An intermediate __init__ assigns the hierarchy's values before any __init__ runs, also if a derived __init__ skips super().__init__() (#544: 916 → 438 ns per instance with one initial value, 3.14). __allSlotDefaults__ keeps the hierarchy's initial values; a hierarchy without any keeps its own __init__ (#553).

    • An abstract property didn't make its class abstract (#508): ExtendedType looked for @abstractmethod/@mustoverride on the property, not its getter, and a property implementing an abstract method didn't count. pyTooling.Licensing.BaseLicense is abstract now, as its abstract properties say.

    • A class or static method marked abstract didn't make its class abstract (#503): ExtendedType looked for the marker on the classmethod/staticmethod object, not the wrapped function. Version is abstract now, as its abstract Parse says.

  • pyTooling.Packaging

    • loadRequirementsFile kept a comment behind a requirement (#491), so an extra's metadata - pyTooling's own test extra among them - carried e.g. colorama ~= 0.4.6 # 'terminal'.
  • pyTooling.Stopwatch

    • Stopwatch.Exclude raised UnboundLocalError on its second use (#365): only the uncached path bound the returned context manager, so a loop died on iteration two.

    • Stopwatch.Start() on a stopped stopwatch raised "Stopwatch was already started." (#655), though IsStarted is False after Stop(); it raises "Stopwatch was already used (started and stopped)." with a note to create a new one.

    • A failing Stopwatch.Stop() changed the stopwatch (#655): a second Stop() replaced the first one's StopTime, and a Stop() before Start() left a stop time behind, so the following Start() raised.

  • pyTooling.TerminalUI

    • A message's indentation was recorded but never printed (#325): Line._indent — the indent parameter of every Write* method plus BaseIndent — never reached the output.
  • pyTooling.Testing

    • pytest tried to collect TestingError (#483), as its name starts with Test. __test__ = False keeps it out. pyproject.toml drops the option junit_xml, which pytest doesn't know.
  • pyTooling.Tree

    • Splitting a tree by node.Parent = None left the split-off IDs registered in the old tree (#580), where GetNodeByID and Size still found them, the new tree's registry empty. They move to the new tree now.
  • pyTooling.URI

    • ⚠️ Host accepted an empty host name, and None raised a TypeError (#485), though its doc-string promised a ValueError for both. Parsing is unchanged.

    • pyTooling.URI raised a bare ToolingException (#409); it raises URLError now, naming the first character RFC 3986 forbids with its percent-encoding, and the known schemes.

  • pyTooling.Versioning

    • ⚠️ A calendar version ignored everything but its numbers (#640): YearMonthVersion(2025, 6, postfix="a") equalled YearMonthVersion(2025, 6), hashed alike and was written 2025.6; a build number didn't count either. CalendarVersion.Parse rejected every release level, post-release, development release and postfix (2024.01a, 2024.01-dev, 2024.10.15+deadbeef, OSVVM's 2025.06a).

    • ⚠️ A postfix didn't order a version (#640): 1.2.3 and 1.2.3+build.5 were unequal, yet neither was less and both <= and >= were true, so sorting depended on input order. No postfix is less than any now; postfixes compare as strings (2025.06 < 2025.06a < 2025.06b).

    • ⚠️ SemanticVersion.Parse read SemVer's v2.0.0-rc.1 as release candidate 0 with the postfix 1 (#640), and rejected a postfix with a . or - (v1.2.3+build.5), which SemVer's build metadata allows.

    • Version.__hash__ hashed the prefix and the flags, which == doesn't compare (#640): SemanticVersion(1, 2, 3) == SemanticVersion.Parse("v1.2.3"), but a set holding one didn't find the other.

    • str(ReleaseLevel.Gamma) raised a ToolingException (#640), so format(version, "%R") failed for a gamma release.

    • Comparing a YearMonthVersion, YearWeekVersion, YearReleaseVersion or YearMonthDayVersion with an int raised TypeError (#666): the operand was built as cls(major=other), and these classes take year. YearMonthVersion(2024, 1) > 2024 works now.

    • %% was no literal percent sign in a format specification, and only SemanticVersion checked the specifiers (#666, #671): f"{version:%%M}" raised Unknown format specifier '%1', a trailing % raised IndexError, a calendar version passed an unknown specifier through (%M.%X gave 2024.%X), and a % in a value (postfix a%b) was taken for an unknown specifier. %%M %M.%m gives %M 1.2; every version class raises ValueError for an unknown specifier or a trailing %.

    • SemanticVersion.Parse("1.2.3.4-rc1") read the release level as a postfix (#666): build 4 and release candidate 1 now. A Development release level with a number dropped the number (1.2.3-dev); it raises ValueError now.

    • ⚠️ SemanticVersion.Parse lost a number followed by letters (#672): 1.2.3foo was 1.2 with the postfix 3foo - the pattern backtracked and took .3foo as a postfix - and 1.2.3.4.5 was build 4 with the postfix 5. A postfix after a . doesn't start with a digit now, so these are syntax errors.

    • repr() of a SemanticVersion ended after the micro number (#666): 1.2.3-rc1.post2.dev3+abc was 1.2.3; it writes every part. repr() of a PythonVersion is its PEP 440 normalized form.

    • VersionSet given an iterator was empty (#666): the type check consumed it.

    • ⚠️ A release level without a number wasn't one (#494): PythonVersion.Parse("1.0a") didn't parse, and SemanticVersion read -alpha as a postfix; PEP 440 normalizes 1.0a to 1.0a0, SemVer reads -alpha as a pre-release. PythonVersion also wrote a fourth numeric component (1.2.3.4) as 1.2.3, though it kept and compared it.

    • ⚠️ PythonVersion.Parse misread most spellings PEP 440 normalizes (#517): through SemanticVersion's pattern, 1.0rev1 became 1+0rev1, 1.0-1 1.0+1, 1.0.0-rc.1 1.0.0rc0+1 and more, while 1.0.post, 1.0.0-rc.1.dev2 and 1.0+Ubuntu-1 raised ValueError. Its own pattern now reads ., - or _ around a release level, post- and development release, rev/r for post, the implicit post-release 1.0-1 and implicit numbers, case-insensitive; a local version compares normalized. ⚠️ A local version after . and the levels final/pl are rejected, as PEP 440 has neither.

    • ⚠️ Version compared a dev or post part's number, but not whether it was present (#436), so 1.0.dev0 == 1.0. PEP 440 orders 1.0.dev0 < 1.0 < 1.0.post0.

    • ⚠️ VersionRange.__and__ discarded both operands' bound handling (#372): the result defaulted to BothBoundsInclusive, so intersecting two ranges that exclude their bounds included them, and a version outside both operands tested inside. Each bound keeps its range's handling; for equal values the stricter one wins.

  • pyTooling.Warning

    • A supervised warning collector raised a TypeError (#469) when left after collecting a warning: it called ThreadSupervisor.AddWarnings() without the thread's name, and with an exception passed an empty name, so ReRaise() named no thread. Both pass current_thread().name now.

    • WarningCollector.Raise took an AttributeError of a collector's handler for a missing collector (#562), swallowing it for a warning or turning it into UnhandledCriticalWarningError/UnhandledExceptionError for a critical warning or an exception. The collector is looked up with getattr(..., None); the handler's exception propagates.

  • General

    • Building the documentation on Windows ended in a UnicodeEncodeError (#385) rather than in a build; run.ps1 builds in UTF-8 mode.

    • 22 parameters were annotated non-optional but defaulted to None (#354) - Packaging (14), Configuration (4), Graph (2), Stopwatch and Tree - an implicit Optional PEP 484 prohibits. All are Nullable[...] now; mypy --strict goes 820 → 797, no implicit-Optional notes left.

    • doc/conf.py imported pyTooling.Packaging before inserting the repository into sys.path (#328), so nine modules were documented from the installed package and the rest from the checkout.

    • 39 field annotations in 27 classes resolved to a property, not to a class (#667), as #512 fixed for pyTooling.Graph: a field annotated with a class whose name is also a property of its class - RequirementsFile._path: Path next to RequirementsFile.Path - evaluated to the property in inspect.get_annotations(cls, eval_str=True) and typing.get_type_hints(cls, vars(module)); in pyTooling.Platform, without postponed annotations, Python 3.14+ returned the properties even from get_type_hints(Platform). The fields use module-level aliases (_Path = Path) in CI, CLIAbstraction, Dependency, Diagram.Gantt, Filesystem, Graph, Platform, TerminalUI, Tracing and Versioning.

📖 Documentation

  • pyTooling.Attributes

    • The Attributes and Configuration overviews and the ArgParse page are written (#393, #394, #395); Filtering Attributes documented a predicate parameter only GetMethodsWithAttributes has. #351 adds the ArgParse page's example programs (attributes and plain argparse, side by side) and completes the Attributes documentation.
  • pyTooling.Attributes.ArgParse

    • The eighteen ArgParse constructors document their parameters (#323); each carried the same sentence describing *args/**kwargs, which none of them takes.
  • pyTooling.Cartesian

    • The Cartesian pages are written (#396).
  • pyTooling.CI

    • The Pipeline page has a section Rules (#570): evaluation order, what a RuleOutcome decides, an example; the GitHub and GitLab mappings name where if:, rules:, rules:if and rules:when go.
  • pyTooling.CLIAbstraction

    • The Program page and the version-constraint reference are written (#397, #398): five open sections on pyTooling.CLIAbstraction.Program, and what a version constraint, an epoch and a validator are in pyTooling.Versioning.

    • The CLI argument reference is written (#352): six sections state what to assign, what comes out, and what decides between a class and its neighbour (e.g. single value vs. tuple).

  • pyTooling.Dependency

    • The Exceptions and Warnings section lists UnsupportedVersionWarning as a critical warning (#581), and PackageIndexError (#583). The intersphinx mapping includes requests, so HTTPError links to its documentation.

    • An info box explains why the dependency warnings are critical (#564): data must never disappear without a report. The example collecting the warnings imports Path.

    • The package-override file states the version range a license was checked for (#486): <package> >=<checked>, <<next major>, so an unchecked release warns again. docutils is BSD-2-Clause AND BSD-3-Clause; cycler, kiwisolver and sphinxcontrib-jquery have overrides; only pillow still needs one, which MIT-CMU resolves.

  • pyTooling.DataModel

    • A page Data Model Foundations (#571, #572) under Data Structures: key-value-pairs and the mixin's two uses - as the only base-class of a model's slotted root class, or beside one's own base-class - with examples; a section Predicate (#577).
  • pyTooling.Exceptions

    • The Exceptions page has a section Common Bases (#568): the three bases, the pyTooling exceptions deriving from them and an example.

    • The ExceptionBase section describes how an exception stores its message (#584): it names args, str(ex) and the three read-only properties Message, HasNotes, Notes.

  • pyTooling.Filesystem

    • The filesystem root reference, parent reference and sizes are documented (#353), including why there are three size properties: hard links make the size of a directory ambiguous.

    • New section Deleting a Scope (#621) on Root.Delete(), reference cycles and the garbage collector.

  • pyTooling.Graph

    • pyTooling.Graph's properties are worded as the coding guide says (#444): 14 read-only properties said "to get"; the ones exposing a field say "to access", the computed ones "to return".

    • New section Deleting a Graph (#617) on Graph.Delete().

  • pyTooling.MetaClasses

    • The Meta-Classes background page is written (#358): what a class statement calls, the five steps of class creation, lighter tools to try first (class decorator, __init_subclass__, __set_name__, dataclass_transform), what ExtendedType adds, why slots need a meta-class.

    • ExtendedType's reference (#432): the Slotted and Mixin sections have an example and their rules, each with its exception; a table lists the class keyword arguments; a duplicated slotted section is merged, and the mixin tab of doc/index.rst is written.

    • Weak References (#336): doc/MetaClasses.rst explains why a slotted class can't be referenced weakly, weakref=True, the once-per-hierarchy rule and mixin-classes.

    • Naming the expected members with a protocol (#611): a new subsection of Expected Members; the keyword table names both forms.

  • pyTooling.Stopwatch

    • The Stopwatch page shows how to measure again (#655): a stopped stopwatch can't be restarted or reset; Measuring Again creates one per measurement, or adds regions up by pause/resume or preferPause=True. The class doc-string says so; entering a stopped stopwatch raises with that note.

    • The Stopwatch page explains three durations (#595): The Three Durations (was The Two Durations) shows Duration as a timedelta and the ...InSeconds floats, and the split-time example uses them.

    • Stopwatch.__enter__ and __exit__ document each condition they raise for (#520); __getitem__ documented a KeyError, but raises an IndexError.

    • The Stopwatch page is written (#365): ten subsections instead of four tbd entries, including the split-time model - every operation ending a span records a split time, tagged activity or inactivity.

  • pyTooling.TerminalUI

    • Command handlers in mixin-classes (#611): the Terminal page's section A Complete Application shows a handler mixin-class expecting TerminalApplicationProtocol.

    • colorama is optional (#614): the module doc-string, MISSING_DEPENDENCY_EXIT_CODE, PrintMissingDependencyError and the Terminal page say the module imports without it; the exit-code block is aligned.

  • pyTooling.Tree

    • New section Deleting a Tree (#620) on Node.Delete().
  • pyTooling.Warning

    • Designing Warning Classes has a hint box Warning or critical warning? (#564): decide by what a caller who never sees the warning gets - a complete result (Warning) or one silently lacking something it relies on (CriticalWarning).

    • The warnings page explains collecting, escalating and documenting warnings (#563): warning kinds, raising, collecting, escalation, applications (setup.py, terminal applications), threads, designing warning classes; its example raised Python's built-in Warning and didn't run. pyTooling.Warning's doc-strings are corrected (e.g. CriticalWarning's inverted tip). Functions in Filesystem, Dependency and MetaClasses name the warnings they report; each warning class states whether it's critical (.. important::) or not (.. attention::); :raises: lists UnhandledCriticalWarningError / UnhandledExceptionError where they can result.

  • General

    • The documentation coverage page names its tool (#625): pyTooling.Sphinx' report:doc-coverage, reading the source with pyEDAA.Reports.DocumentationCoverage.Python.Analyzer, not docstr-coverage.

    • News lists the final outcome since v9.0.0, as these notes do (#586): every ⚠️ item is in Breaking Changes and in its own section.

    • A parameter's description opens with Optional, exactly when it has a default (#545): 25 parameters gained it, 56 without a default lost it.

    • Hand-drawn hierarchies are drawn by pyTooling.Sphinx' tree directive (#541): the CI pipeline, the tracing data model and the tree example; their classes link to the API reference.

    • A doc-string's summary names no example (#542): in 8 doc-strings, the example moved into the description.

    • The Dependency page renders every extra's table from the package index (#569, #528) with pyTooling.Sphinx' dependency-table directive, expanded without a depth limit.

    • The report pages are rendered by pyTooling.Sphinx' domain report (#526) instead of the extension sphinx_reports; doc/requirements.txt takes pyTooling.Sphinx[reports].

    • Two doc-strings didn't parse as ReST (#521): lazy's usage list was folded into its summary, and the Windows GetMemoryUsage had an unexpectedly indented line.

    • The README - the PyPI page - no longer describes what the package doesn't have (#501): exceptions carry their ***Error names, the Terminal example is the landing page's (CheckPythonVersion went in v9.0.0), ObjectWithSlots is SlottedObject, the CLI abstraction claims no escaping. Seven dead anchors and eight link labels without target are repaired; the landing page's Terminal example imports NoReturn.

    • Wrong statements in the documentation are fixed, and the tracing page has a data model (#487), e.g. an uncollected warning is dropped; both configuration backends interpolate and return every scalar as str; pyTooling.Dependency doesn't build on pyTooling.Graph; the CLIAbstraction example uses only existing methods. Every __enter__/__exit__ doc-string opens with Enter/Exit the context and ...; GitHub links use the gh role.

    • Doc-strings state what the code does (#485): Attribute.GetClasses' subclassOf filters by the annotated classes' base class; PrintMissingDependencyError's code example broke into lines inside its code block; a continuation line of GraphML.Graph.OpeningTag at column 0 indented the whole doc-string.

    • The published documentation counts its page views with GoatCounter (#481): doc/conf.py adds GoatCounter's script (site pytooling) only to builds on GitHub Actions.

    • Pages compare pyTooling with competing solutions: the pipeline model (#453), Tracing (#454), Warning (#455), TerminalUI (#456), ExtendedType (#457), Attributes (#458), REST (#459), Licensing (#460), Configuration (#461), Dependency (#462), Versioning (#464), the test annotations (#465), the stopwatch (#466), LinkedList (#467) and CLIAbstraction (#468): per competitor its disadvantages, the overlap and where it is ahead. Tools not usable from Python are named in each section's introduction (#472).

    • Two tutorials are written — Attributes (#355) and CLI Abstraction (#356) — and the tutorials get an overview page (#359).

    • The glossary is written (#399, #415): 47 → 122 terms (102 entries, some with an alternative name), all written and cross-linked - 17 had only undocumented as body. WSL is the Windows Subsystem for Linux, SPDX the Software Package Data Exchange.

    • Three modules say in their doc-string that importing them needs an extra (#380). pyTooling.Dependency.Python, pyTooling.Configuration.YAML and pyTooling.TerminalUI guard a module-level import and raise MissingDependencyError.

    • The Exception Hierarchies tutorial is written (#357): six steps, from one base exception per package to documenting every exception that escapes.

    • The release history is written (#342): 50 entries back to v0.5.0, structured like these notes; this release's entry is filled in.

    • Schemas are published for a human to read (#343): a page per schema with its full source, a copy button, a download and an xmlschema-graph drawing by pyTooling.Sphinx; the package-override JSON schema too (#389). Both are served at their $id URL under https://pyTooling.GitHub.io/pyTooling/Schemas/.

    • The documentation-coverage backlog is at four real problems (#324), all fixed; 12 of the 18 remaining reports are tool artifacts.

🧪 Unit Tests

  • 3461 passed, 30 skipped, 3 xfailed, 1317 subtests — up from 1326 and 93 subtests at v9.0.0.

  • pyTooling.Cartesian2D, pyTooling.Cartesian3D

    • Rectangle, Square, Volume, Cuboid, Trapezium and Cube are tested (#629), so are LineSegment2D.Length/AngleTo, LineSegment3D.Length and axis-aligned and rotated rectangles and squares (#631), and AngleTo of parallel, opposite and zero-length segments in 2D and 3D (#641). Type checks of points, offsets and sizes assert their messages and notes (#657).
  • pyTooling.MetaClasses

    • Deferred annotations on Python 3.14+ (#623): DeferredAnnotations compiles class bodies without the future import - a self-reference, an undefined name and a ClassVar of an undefined name.

    • The generated __getstate__/__setstate__ are named like methods of their class (#658), also for a derived and a nested class.

    • A pickled graph is checked after unpickling (#629): PickledGraph.test_SimpleGraph asserted on the original graph; new testcases compare every vertex and edge.

  • pyTooling.Stopwatch

    • The Stopwatch tests allow a 4 times longer sleep on GitHub Actions (#525), where shared runners overshot a 0.5 s sleep by up to 3x; local runs keep the narrow limit.
  • pyTooling.Tracing

    • pyTooling.Tracing.CI has its own test module (#657): the attribute keys, SpanKind and Result, and every timespan - kind, parent, the attributes its parameters set, skipped unknown values.

    • Reading OTLP/JSON is tested (#348): tests/unit/Tracing/OpenTelemetry.py grows from 50 to 191 testcases - byte-identical round-trip, 2000 nested spans without recursion, schema violations as notes, fragments and collections merged in any order, the errors and their generic bases. They are skipped where jsonschema can't be installed: PyPy on Windows.

  • General

    • The last 14 f-strings in the tests that interpolate nothing lost their f prefix (#518).

    • URL.Host's parameter checks are tested, and the classifier test skips without trove-classifiers (#485) instead of failing.

    • The suite runs in parallel with pytest-xdist (#433): 17 s instead of 31 s; two terminal tests accept a worker's 80×24 characters. CI too since #434 (-n auto); Coverage.py follows the workers (patch = ["subprocess"]), 83 % either way; #435 limits the measurement to the package (include = ["pyTooling/*"]).

    • Five FigLet banners named a module the file has nothing to do with (#347), copied along with the file they were copied from.

    • The matplotlib testcases are skipped on PyPy 8.0 on Linux (#474) (no Pillow wheel, building one fails), and since #558 on macOS (no numpy pp80-darwin wheel), until pp80 wheels ship.

  • pyTooling.TerminalUI

    • TerminalApplicationProtocol stays in step with TerminalApplication (#611): every member exists with the same signature (methods) or return type (properties) and carries a doc-string; expects with a protocol, a derived protocol, a plain class, a protocol's implementation and typing.Protocol itself.

    • The palette with and without colorama (#614): the module loaded with colorama blocked has empty palette entries.

⚙️ GitHub Pipeline

  • Only the wheel is published on PyPI (#582): publish_sdist: 'false', an input of pyTooling/Actions v9.3.0. The sdist can't build a wheel (setup.py reads requirements.txt, which it lacks); the pure-Python wheel installs everywhere.

  • Artifacts are cleaned up by CleanupArtifacts.yml (28c5b7e, pushed to dev without a pull-request): IntermediateCleanUp and ArtifactCleanUp no longer call the deprecated IntermediateCleanUp.yml and ArtifactCleanUp.yml and name the artifacts by the keys of Parameters.yml's artifact_names, like CompletePipeline.yml. IntermediateCleanUp deletes the platform tests' artifacts, keeping the package artifact on tag runs for PublishOnPyPI. Both run unless the pipeline is cancelled.

  • The single jobs and mypy use Python 3.15 (3ae2c76, pushed to dev without a pull-request): documentation, checks and packaging use pyTooling/Actions@r9's default; mypy checks with python_version = "3.15".

  • The pipelines use pyTooling/Actions@r9 (#390, #543) instead of @r7, in Pipeline.yml, Benchmark.yml and Performance.yml: MSYS2 runs on UCRT64 and Clang64 only, as Actions v9.0.0 removed mingw32 and mingw64.

  • Python 3.15 and PyPy 3.12 are tested (#557, #558): CPython 3.11 to 3.15, PyPy 3.11 and 3.12. PyPy isn't run on Windows on ARM.

  • A package PyPI would reject is neither tagged nor uploaded (#560): Package reports pypi_compliant (pyTooling/Actions v9.1.0); TriggerTaggedRelease fails before tagging, PublishOnPyPI checks again; before, a release PyPI refused was tagged and its release page published anyway.

  • A release version that doesn't match the code, or that PyPI already has, is neither tagged nor published (#588): new job VersionCheck (CheckReleaseVersion.yml@r9, as in CompletePipeline.yml) compares it with __version__ in pyTooling/Common/__init__.py and, on the release commit, asks PyPI; TriggerTaggedRelease, ReleasePage and PublishOnPyPI require its success.

  • A pre-release's release page is published as a pre-release (#437). ReleasePage passes prerelease and latest from Prepare's new output is_prerelease (pyTooling/Actions v8.5.0), so a tag like v10.0.0-rc1 doesn't replace the latest release.

Others

  • pyTooling.MetaClasses

    • ExtendedType's meta-class parameter is named metacls everywhere (#507); __new__ and three classmethods called it self.
  • General

    • f-strings without placeholder are plain strings (#590): f"…" became "…" in 4 files (10 strings); every string keeps its value.

    • One from <module> import ... statement per module and scope (#630), continued by \ and aligned to the first imported name: 624 → 585 import statements in the package, 705 → 642 in the tests; needlessly function-local imports moved to the module's import block. Names, order and namespaces are unchanged. pyTooling.Packaging separates its stdlib and pyTooling import groups by a blank line and imports Sized from typing (#654).

    • Unused imports are removed (#628): one name each in CallGraph, ControlFlow, Dependency.Python, Diagram.Gantt and Tracing.Render.Matplotlib, two in Filesystem, and 12 in seven test modules; no other module imports them from there.

    • Version 10.0.0 (0f68e7b), pushed to dev directly; earlier version bumps went through #555 and #556.

    • doc/make.bat runs Sphinx with Python 3.15 (4ed65b8, pushed to dev without a pull-request): py -3.15.

    • A Prospector profile (#522), .prospector.yml, for Codacy: tabs for indentation, D213 instead of the contradicting D212, 120 columns.


Related Issues and Pull-Requests

Merged into this release and not named in a section above, newest first:

  • #670 - Platform.StaticLibraryExtension's doc-string names a for macOS, as the code returns.
  • #651 - the Tree Rendering example shows the current markers, and a table explains Render()'s parameters.
  • #650 - Rule.When is annotated -> RuleOutcome; it named a nonexistent When.
  • #596 - two #: field comments of pyTooling.MetaClasses are aligned with their block.
  • #645 - one :raises: field per parameter: 12 fields named several parameters as alternatives.
  • #643 - every module variable is documented by a doc-string below it: 42 variables in 14 modules carried a #: comment line above or a trailing #: comment.
  • #642 - the package-override schema tests are skipped where jsonschema can't be installed: PyPy on Windows, where rpds-py has no wheel.
  • #589 - str() of a KeyNotFoundError is its message, without the quotes #584 had added.
  • #567 - name no enumeration members in the doc-strings of Span.State and ciSpanFilter, both new in this release.
  • #551 - name the override's value and file in the warning for a license override LicenseExpression can't parse; license overrides are new in this release (#363).
  • #493 - align the guarded imports with the module's import column.
  • #482 - link pyTooling.GitHub's trace page by its new label.
  • #452 - pyTooling.CI's Base checks only a parent's type, as the root takes no parent.
  • #447 - fix a malformed table and a label without a blank line in the documentation.
  • #387 - state the licenses the package index can't answer for in the package-override file.
  • #364 - generate the dependency tables instead of maintaining them by hand. Partly moved to pyTooling.Sphinx.
  • #363 - resolve a release's license, and say so when it can't be resolved.
  • #326 - closed, integrated into #329.
  • #428 - closed by GitHub during a re-base, replaced by #429.

Moved to pyTooling.Sphinx, newest first:

  • #463 - compare the Sphinx extension with competing solutions.
  • #471 - DependencyTable's configuration values as a class member of the directive.
  • #431 - document the roles and directives of the Sphinx extension.
  • #392 - render the badges with a directive, its coordinates stated as options.
  • #391 - the xsd-graph directive moves into pyTooling.Documentation.Sphinx.
  • #381 - say why a package needs a license override (UnresolvedLicenses).
  • #379 - :version-format: and :dependency-format:.
  • #378 - rename the configuration, take iterables, stop capping the depth.
  • #360 - one Sphinx extension for the whole family.
  • #349 - .. condensed-class:: renders an interface from source.
  • #344 - .. xsd-graph:: draws an XML schema.

Moved to pyTooling.GitHub, newest first:

  • #473 - compare the gha domain's warnings without the temporary path on macOS and Windows too.
  • #442 - summarize a workflow with gha:parameter-table, gha:interface, gha:dependencies, gha:yaml and gha:autoinputs.
  • #441 - draw a workflow's pipeline with gha:pipeline-graph.
  • #440 - a Sphinx domain gha for GitHub Actions workflows.
  • #451 - build a pipeline from a workflow file, and give a run the dependencies of its file.
  • #450 - model a job's steps and matrix, and read the reusable workflows a workflow calls.
  • #449 - model the jobs of a workflow file, and the reusable workflows they call.
  • #448 - model the parameters of a reusable workflow.
  • #439 - read a GitHub Actions workflow file: its triggers and permissions.
  • #425 - draw the pipeline: --gantt on the pipeline command.
  • #423 - pyTooling's program: the pyTooling command line interface.
  • #411 - Workflow declares its own parent type.
  • #407 - a data model of a GitHub Actions workflow run.

Issues addressed in this release:

  • #15 - implement more SPDX identifiers and licenses.
  • #49 - __weakref__ in types with slots, implemented in #336.
  • #219 - the deprecated license classifiers.
  • #246 - JSON output for OpenTelemetry and Jaeger.
  • #346 - SPDX license expressions, implemented in #367.
  • #384 - enforce an attribute's scope, implemented in #489.

Still open (a closes keyword fires only on a merge into the default branch); they close with this release:

  • #18 - extract the description from the first paragraph of a package's doc-string.
  • #383 - an attribute's scope as a class keyword argument, implemented in #523.
  • #612 - expects accepts a typing.Protocol, implemented in #611.

Published from Pipeline workflow triggered by github-actions[bot] on 2026-10-26 18:41:19 UTC.

This automatic release was created by pyTooling/Actions::PublishReleaseNotes.yml