Changelog¶
Every release and what changed in it. Newest first; versions follow calver (YY.M.D) and match
oryxflow.__version__.
All notable changes to oryxflow are recorded here. This file is read by humans and by AI coding agents diagnosing regressions after an upgrade, so the format is load-bearing:
- Newest first. One
## [version] - YYYY-MM-DDheading per release; version is calverYY.M.Dmatchingsetup.py/oryxflow.__version__. Unreleased work goes under## [Unreleased]. - Group bullets under
### Added/### Changed/### Deprecated/### Removed/### Fixed/### Security(Keep a Changelog: https://keepachangelog.com/). - Every breaking change is a bullet that STARTS with the literal token
BREAKING:and carries a same-bulletMigration:clause with the old→new fix. - Name the actual symbol in backticks (
`Task.persist`,`RunResult.summary()`), never prose. Agents grep this file for the symbol in their traceback.
[Unreleased]¶
[26.8.2] - 2026-08-02¶
Added¶
Workflow.dependents(task, root=None, paths=False)andWorkflow.dependencies(task=None, target=None, paths=False)(plusWorkflowMultivariants with aflow=selector) — ask the DAG "what depends on this task" / "what does it depend on" instead of grepping or hand-rolling a walk overrequires().taskmay be a class, family string, or instance; a class/string is not instantiated, so it works for fanned-out / DAG-internal families thatget_task()can't build.paths=Truereturns the orderedroot→taskroutes (a list of task lists) instead of the deduped set. Backed bycore.find_deps(set) and the newcore.find_paths(ordered, re-exported asoryxflow.find_paths); both are now memoized, so the walk is polynomial on diamond-heavy DAGs instead of exponential.dependents(X)is the discoverable, correctly-named form of the confusingly-arguedtaskflow_downstream(task, task_downstream)(kept as an alias).Workflow.check_inputs(tasks=None, raise_on_unused=False, include_clean=False)— static AST lint that reports a declared@oryxflow.requiresdependency whose datarun()loads and never reads. Such a dead dependency is invisible to every dependency query (the edge is real, only the data is dead) yet still forces its whole upstream band on every cold build.preview()surfaces these automatically in anUNUSED INPUTSblock, deduped per family;run()does not lint, keeping the execution path free (callcheck_inputs()explicitly for CI). Three verdicts —unused/clean/unanalyzed(a shape it can't prove isunanalyzed, never silentlyclean); outer unpack elements are dependencies, inner are that dep'spersists(a top-level_is a finding, an inner_is normal). Suppress a deliberately-unused dependency with a# oryxflow: input-unusedcomment. New moduleoryxflow/inputcheck.py.@oryxflow.requires_each(task, **grid)— declare one dependency per value instead of one dependency:@oryxflow.requires_each(ModelTrain, model=MODELS)on the task that combines them. Like@oryxflow.requiresit copies the dependency's parameters onto the decorated task, minus the ones being fanned out (those differ per branch, so the combining task must not carry them), and it definesrequires()as arequires_gridover the values. Naming several parameters fans out over their cartesian product. Use it instead of hand-writing{v: Task(param=v, shared=self.shared) for v in values}, which only reaches the branches with the parameters you remember to forward.@oryxflow.requires,@oryxflow.inheritsand@oryxflow.requires_eachnow stack on the same task, in any order and any number. The normal combining task needs the fan-out and a shared dependency that is deliberately not fanned out — the table the branches were built from, a baseline to score them against, labels to render with:@oryxflow.requires({'input': ReportInput})above@oryxflow.requires_each(RegionNarrative, region=REGIONS). Previously each decorator ownedrequires()outright, so the second one raised and the only way through was@oryxflow.inheritsplus a hand-writtenrequires(). The parameter rule holds across all of them: the combining task gets every dependency's parameters except the fanned-out ones.@oryxflow.requires_eachaccepts a single-entry{name: Task}dict to name the fan-out group; the group defaults to the dependency's own task family. A named group qualifies its dependency keys with that name (chart_north), which is how two fan-outs over the same values are disambiguated. Unnamed groups keep bare value keys, so existing tasks are unaffected.@oryxflow.requires_eachandTask.requires_gridaccept a callable grid value —region=lambda self: REGIONS[self.sector]— for a fan-out computed from the task's own parameters, which previously forced a hand-writtenrequires(). The callable sees the task's parameters, not its inputs.@oryxflow.requires_eachandTask.requires_gridacceptderive={'name': fn}— a further parameter set per branch from that branch's fanned values, for the setting that follows from the value the branch was built for:@oryxflow.requires_each(RegionLoad, region=list(SOURCE), derive={'source': lambda v: SOURCE[v['region']]}). Each function is handed that branch's values (v['region']) and its result is passed to the branch as a parameter, so it counts towards the branch'stask_id: editing one entry inSOURCEinvalidates exactly that branch. Previously the only places to put such a lookup were the branch'srun()— where it is invisible to the cache, so changing it silently returned the old output — or a hand-writtenrequires(), which drops every parameter you forget to forward. Derived names stay out of the dependency keys (inputLoad(task='north')is unchanged) and off the combining task, for the same reason fanned names are.inputLoad(flatten=False)groups a fan-out's branches under one key ({'input': df, 'RegionNarrative': {'north': ..., 'south': ...}}), so a task that mixes a fan-out with shared dependencies no longer has to pop the keys it recognises and assume the rest are branches.inputLoad(task='<group>')andinputLoadConcat(task='<group>')select just the branches;inputLoadConcat(flatten=False)returns one DataFrame per group.
Changed¶
- Traversal-scoped memoization makes no-op re-runs and
preview()near-instant on wide fan-out DAGs. Three engine questions used to recurse over each task's whole upstream closure once per path through the DAG, not once per task:TaskData.complete(cascade=True),_resolve_requires(), andTask._code_fingerprint. On a 41-branch fan-out over a shared aggregator (75 tasks) a no-oprun()did 1,428 completeness checks, 586requires()resolutions and 8,439 fingerprint evaluations;preview()did 5,552 / 873 / 13,685. A new per-traversal memo (oryxflow.core.traversal_scope, opened bybuild(),preview(),Workflow.complete(), thetaskflow_*walks,dependents/dependenciesandaccept_code) collapses each to one execution per unique task — the same no-oprun()now does 75 / 43 / 75. Behaviour is unchanged: completeness answers are dropped whenever a task materializes (save()), is invalidated (reset()/invalidate()), or runs inside a build; the code/DAG-shape memos live for the traversal (the engine already forbids code changes mid-build, percodehash.freeze()). A bareTask.complete()outside any traversal is unmemoized, exactly as before. With cloud storage this also cuts the per-object existence API calls by the same factor. - BREAKING:
clsandderivejoinpathandflowsas reserved parameter names — declaringderive = oryxflow.Parameter(...)(orcls) on a task now raisesValueErrorat class definition. Both are arguments ofTask.clone()/Task.requires_grid(), so the argument shadows the parameter:self.clone(cls=Other)and@oryxflow.requires_each(Dep, derive={...})would bind to the argument and the parameter would never receive a value. Migration: rename the parameter (derive_features,model_cls). - BREAKING: fanning out over a name the dependency has no parameter for now raises
TypeError(@oryxflow.requires_each(RegionLoad, sector=[...])whereRegionLoadhas nosector). It used to produce one dependency key per value all pointing at the same task, becauseclone()builds its kwargs from the target'sget_params()and drops the rest — soinputLoadConcat()returned N copies of one branch's output, tagged as if they were different branches. The error lists the parameters the dependency does have. Migration: declare the parameter on the dependency, or fan out over one it has. - BREAKING: a task decorated with
@oryxflow.requires_each(Dep, x=[...])that also declares its ownx = oryxflow.Parameter(...)now raisesTypeErrorat class definition. The declaration used to survive, putting one branch's value into the combining task'stask_id— so you got one combining task per value, each combining all the branches, cached under different ids at N times the cost, with no warning. Migration: delete the declaration; the combining task is the point the branches converge into and must not carry the fanned parameter. - BREAKING: two dependencies resolving to the same key now raise
ValueErrorfromrequires()instead of one silently replacing the other (previously reachable when a fan-out value collided with a named dependency). Migration: name one of them —@oryxflow.requires_each({'chart': Chart}, region=REGIONS)or@oryxflow.requires({'input': ReportInput}). python_requiresraised to>=3.9— up from>=3.5, which never held: the package has used f-strings (3.6+) throughout for some time, andinstall_requiresalready imposes 3.9 in practice via pandas and pyarrow. PyPI version classifiers added to match. This corrects the metadata; it does not drop support for any interpreter the package actually ran on.- BREAKING:
oryxflow.utils.requires_grid(task_cls, param, values, **base)is now theTask.requires_grid(cls, **grid)method — same job, done properly. As a free function it had noself, so it could not carry the calling task's parameters down to the branches: every shared parameter had to be repeated in itsbasekwargs, and one left out was silently missing from the children (they got the default instead of the flow's value — a wrong result, not an error). The method clones per branch, so parameters propagate exactly as they do throughclone(). It also fans out over several parameters at once —self.requires_grid(ModelTrain, model=MODELS, horizon=[1, 5, 20])gives the cartesian product. Keys are the value itself for one parameter,name_valuepairs joined with_for several, and are whatinputLoad(task=...)selects on. Migration:requires_grid(ModelTrain, 'model', MODELS)becomesself.requires_grid(ModelTrain, model=MODELS)insiderequires(), and any parameter you were passing throughbasecan be deleted — it is carried automatically.
Fixed¶
- Decorating a task with two dependency decorators no longer raises
"<Task>: defines requires() AND is decorated with @requires"when the task defines norequires()at all. The check now distinguishes a hand-writtenrequires()(still an error — the decorator would silently replace it) from a decorator-generated one. inputLoadConcat()now warns when it would row-stack a shared dependency in with a fan-out's branches, which produces a union frame across unrelated schemas. Passtask='<group>'to concatenate just the branches, orflatten=Falsefor one frame per group.- BREAKING: declaring a Parameter named
pathorflowsnow raisesValueErrorat class definition instead of failing silently.pathis a keyword-only argument the engine uses for the flow's data directory, soMyTask(path='a.csv')never reached a Parameter of that name — it kept its default, meaning every value mapped to the same task, and that default was then used as the output directory (x.csv/MyTask/...). Migration: rename the parameter (file,filename). - BREAKING: decorating a task that defines its own
requires()with@oryxflow.requires/@oryxflow.requires_eachnow raisesTypeError. The decorator assignsrequiresafter the class body is evaluated, so the hand-written method was silently discarded and the task ran with whatever the decorator declared. Migration: keep one — drop the decorator and writerequires()(withself.requires_grid(...)for a fan-out), or delete the method. inputLoadConcat()/concat_iter()warn when a tag column would overwrite an existing column whose values differ from the tag — previously real per-row data (a date column, a category) was silently replaced by one scalar parameter value. Re-tagging with the value already present is unchanged and silent, since that is how each level of a multi-level aggregation legitimately rewrites the level below's tag columns. Silence it withtagkeys=[...]ortag=False.preview()/oryxflow.utils.print_tree()now show parameters for every task in the tree, not just the root. A positional-argument slip made the recursion passclip_paramsasshow_params, so every child rendered as[TaskName- (PENDING)]— in a fan-out over a parameter grid the branches were indistinguishable.show_params=Falsenow also reaches the children.- The
RuntimeErrorraised byoryxflow.run()/Workflow.run()on failure now names the failing task and its parameters instead of onlyException found running flow, check trace— e.g.Exception found running flow: ModelTrain(model=forest, seed=7): ValueError: training diverged. Up to three root-cause failures are listed. The original exception is still chained viafrom.
[26.7.26] - 2026-07-26¶
Changed¶
- BREAKING:
TaskAggregatoris now arequires()-based group node instead of a task that yields its members fromrun(). Because the group is a regular DAG node, it works withWorkflow/WorkflowMulti(previously every call raisedUnknownParameterException: ... unknown parameter flows),preview()expands it to show each member, and per-flowpath/env,reset_upstream()andFlowExportreach its members. The group still saves nothing of its own and is complete when every task it requires is complete. The old form now raises aRuntimeErrorat construction naming the fix. Migration: move the members fromyieldstatements inrun()intorequires()(or@oryxflow.requires) and leaverun()empty —class Agg(oryxflow.tasks.TaskAggregator): def run(self): yield T1(); yield T2()becomesclass Agg(oryxflow.tasks.TaskAggregator): def requires(self): return [T1(), T2()].
[26.7.21] - 2026-07-21¶
Security¶
- Releases are now published to PyPI via GitHub Actions Trusted Publishing (OIDC) instead of a
stored API token, and every uploaded file carries a PyPI-recorded attestation (PEP 740 /
Sigstore) proving it was built from this repository by CI. Verify on the PyPI file detail page
for this release. No install-side change —
pip install oryxflowis unaffected.
[26.7.12] - 2026-07-12¶
Added¶
- Automatic code invalidation, on by default (
settings.code_version_auto = True): every task derives its code identity from the AST hash of its own class plus the project-local symbols it transitively references (codehash.task_hashes,'<relpath>::<symbol>'granularity), so a real logic edit (in the task or a helper it calls) reruns the task and everything downstream on the nextrun(), overwriting in place — while editing an unrelated sibling task in the same file reruns nothing (one monolithictasks.pystays cheap). References to other Task classes are dependency wiring, never a code dependency (a pinned upstream's unbumped edit can't ripple throughrequires()mentions); unresolvable constructs degrade conservatively to whole-module granularity. No attribute to maintain, and comment/docstring/formatting edits never rerun (AST normalization). Existing caches are grandfathered on first contact (baseline stamped, zero reruns). Setsettings.code_version_auto = Falsefor explicit-only tracking. The functional API is covered automatically (auto is ambient, no per-task surface). Records live in<dirpath>/.oryxflow-code-status.jsonand travel with the data dir. Task.code_version(str or int, defaultNone): a per-task pin that suspends automatic tracking of that task's own logic — it recomputes only on a deliberate bump (the task and everything downstream), for expensive tasks where a refactor-triggered recompute must be a decision, or logic the hash can't see. Records are mode-aware (they store both the token and thesource_hashesas of the last materialization), and thecode_versionline itself is stripped by the AST normalization (typing it in / deleting / bumping it is a token change, never a source change), so pinning/unpinning unchanged code never recomputes ("just resumes"), an edit masked during a pinned-unbumped window is caught the moment the pin comes off, and pinning in the same edit as a logic change forces a rerun instead of blessing stale output.- Dependency propagation folds output identity (
output_id, fresh per actual materialization, preserved across re-stamps andaccept_code): downstream reruns exactly when an upstream rematerialized — pin toggles and accepts never ripple, and areset()+rerun upstream propagates downstream even across separate builds. - Staleness advisory for pinned tasks: code changed without a bump → cached output is reused and
the run warns via
StalenessWarning(aUserWarningsubclass, visible withoutenable_logging()), a loguru record, acode_warningevent, andRunResult.warnings. The printed/logged channels dedupe per process on the message — parameterized instances of one family produce identical text, and aWorkflowMultirun is one build per flow over shared upstreams, so per-task dedupe would still flood stdout — re-arming when the condition changes or the affected tasks rerun/are accepted;RunResult.warningslists each distinct message once per run (MultiRunResult.warningsdedupes across flows), and only the event stream records every occurrence. oryxflow.accept_code(task)/accept_code(): acknowledge an output-equivalent code change without rerunning. With a task instance it re-stamps the task and its entire upstream dep tree (post-order), stamping a fresh baseline record for outputs that have none yet (this is what clears theoutput predates current codemtime-guard warning after an upgrade);Workflow.accept_code(task=None)/WorkflowMulti.accept_code(task=None, flow=None)wrap it; called bare they cover every imported task family that resolves with the flow's parameters (a multi-final pipeline is fully blessed in one call, from a fresh process — no prior run needed), and a list of tasks is accepted everywhere (onWorkflowMultiprefer the flow method — the module-level bulk form doesn't know the flows' parameters). Prints a one-line summary of what it re-stamped (or that nothing was accepted). The tree walk is fault-isolated: a task whoserequires()/output()raises is skipped and reported instead of aborting the walk (a brokenrequires()also can't poison the node's own blessing). Never touchesoutput_id, so accepting never triggers downstream recomputes.TaskData.keep_versions(defaultFalse): withcode_versionset, outputs live under a readable.../<Task>/v<version>/segment so old versions survive bumps (explicit pins only; auto-tracked tasks overwrite in place).- Expensive-recompute guard (
settings.code_version_auto_expensive_s, default 600): an auto-tracked task whose last materialization (recorded asduration_s) took longer is held complete when its code changes and the run warns (StalenessWarning, all channels) with the three exits —reset()to recompute,accept_codeif output-equivalent, or pin withcode_version— so a refactor can't silently burn a long run.None/0disables the guard. - Records carry schema/interpreter tags (
state.RECORD_V,py): a record with a different/missingvor Python minor is treated as unverifiable — complete, then silently re-stamped (grandfather trust level,output_idpreserved) — never a mass rerun after an upgrade. build()mtime-revalidates code hashes at most once per module per build (codehash.freeze()/unfreeze()), keeping the auto-hash overhead on small DAGs low.- Event stream
oryxflow.events: every run appendsrun_started/task_ran/task_failed/run_finished/code_warning/code_accepted/task_logevents to.oryxflow/events.jsonl(stable head; earlier months offload toevents-YYYYMM.jsonl, immutable). Plain JSONL —tail/grep/jqwork; writes are async and never fail a run; disable withsettings.events = False. Query viaoryxflow.events.status()(session-start: pending warnings, last run per family, recent failures),events.runs(task_family=, flow=, last=),events.iter_events()— all return data and print nothing;events.print_status()prints the status summary (the session-start orientation call for scripts andpython -c). RunResult.run_id,RunResult.reasons({task_id: 'output missing' | 'code change (auto: <file>::<symbol>)' | 'code change (a -> b)' | 'upstream rerun'}),RunResult.warnings.MultiRunResultgains aggregate.ran/.complete/.failed/.reasons/.warningsacross flows.task_ranevents carry params, code fingerprint, source hashes,autoflag, git SHA/dirty, duration and the rerun reason;WorkflowMultistamps each per-flow build's events with its flow name.- Task-authored
self.logger.*(...)lines are captured astask_logevents during a build (works with logging disabled), so in-run scalars become queryable memory. - New settings:
settings.events,settings.eventspath,settings.state_filename.
Changed¶
settings.db(unused) renamed tosettings.state_filename(the per-data-dir record file name,.oryxflow-code-status.json).
[26.7.11] - 2026-07-11¶
Changed¶
- Documentation rewrite and PyPI packaging updates; no API changes.
[26.6.6] - 2026-06-06¶
Added¶
- Initial release of
oryxflow: the self-contained task engine (Task,requires/inherits, the parameter set,Workflow/WorkflowMulti, targets and task I/O formats), with no external workflow-engine dependency.