First release candidate for 0.2.0. Changes since 0.1.4.5.
BREAKING
--------
* Recipe classes in p4pillon.pvrecipe are no longer flavor-agnostic:
build_pv() raises TypeError. Import PVScalarRecipe,
PVScalarArrayRecipe or PVEnumRecipe from p4pillon.thread.pvrecipe
or p4pillon.asyncio.pvrecipe instead (previously they silently
built raw-flavored PVs with no executor or locking).
* p4pillon.asyncio PVs must call open()/post() on their event loop;
off-loop calls raise. Use post_deferred() from other threads.
* CalcRule.init_rule() now matches BaseRule: init_rule(newpvstate)
returning a RulesFlow, not init_rule(value, **kwargs) returning
None.
* The "test" and "dist" extras are now dependency-groups, plus a
"dev" group containing both. Use "uv sync --group dev" instead of
"pip install p4pillon[test]".
ADDED
-----
* IOC-style RECORD.FIELD sub-PVs. Serve the fields common to every
EPICS record (dbCommon.dbd) plus RTYP, DTYP, NAME and DESC (and
ADEL/MDEL for numeric scalars) as independent read-only channels -
e.g. EXAMPLE:PV.SCAN - so a plain Python server looks like an IOC
to tools expecting record.FIELD access, without adding those
fields to the base PV's structure. Defaults follow EPICS Base:
RTYP inferred from value type, DTYP "Soft Channel", DESC mirroring
display.description. String fields also get a $ long-string alias.
Three entry points: StaticRecordProvider (eager, sub-PVs visible
in pvlist), IOCMimicProvider (lazy, built on client connect), and
IOCMimicServer (drop-in Server giving any plain {name: pv} dict
the same treatment).
- RTYP inference: int64 to int64in (not longin, whose 32-bit VAL
would truncate), bool to bi, string to stringin, array to
waveform, NTEnum to mbbi; always the input side of a record
pair (ai, not ao).
- NT types with no matching record (NTTable, NTNDArray, ...) are
served on their own with no sub-PVs, as a real IOC serves a
Q:group, rather than being rejected. An explicit RTYP opts them
into record treatment.
- IOCMimicProvider re-reads display.description on each client
connect, via a weak reference to the base PV, so DESC tracks
later changes for new connections. set_desc_record overrides it
explicitly.
- Each field channel's timeStamp is taken from the base PV's own,
mirroring an IOC where RECORD.FIELD reports the record's process
time; where the base PV has none to give, the field is stamped
with the time it was built. It is never left at the 0s 0ns that
wrap() produces, which a client renders as 1970-01-01. The eager
path (StaticRecordProvider) snapshots once at add(); the lazy
path re-reads per connect, then holds it static for the life of
the channel - also what a real IOC does.
* A client's first update can now carry the complete structure.
pvAccess transmits only the fields a value has marked as changed,
and a SharedPV's mask is the union of everything marked since
open() - so NTScalar(...).wrap(v), which marks only "value",
leaves alarm.*, display.* and valueAlarm.* never reaching a client
at all. p4p and pvxs clients hide this by zero-filling what they
did not receive; the Java org.epics.pva client behind
Phoebus/CS-Studio and the EPICS Archiver Appliance leaves an
untransmitted string as null, which is enough to make the archiver
throw an NPE while saving a channel's metadata. A real IOC (QSRV)
sends the whole structure and signals "unset" in-band.
- New tri-state p4pillon.server.raw.InitialUpdate - COMPLETE,
AS_POSTED, DEFAULT - set per PV with a keyword-only
initial_update= on any p4pillon SharedPV or SharedNT.
- DEFAULT defers to whatever serves the PV. StaticRecordProvider,
IOCMimicProvider and IOCMimicServer resolve it to COMPLETE,
since they exist to mimic an IOC; everywhere else it falls back
to AS_POSTED, so nothing changes for existing code unless you
ask. An explicit COMPLETE or AS_POSTED always outranks the
server's choice.
- If you serve PVs through the record-field providers, the bytes
on the wire change: clients now receive alarm, display and
valueAlarm on their first update where previously they received
only "value". Pass initial_update=InitialUpdate.AS_POSTED to
keep the old behaviour.
- p4pillon.server.raw.apply_initial_update(pv, mode) is the seam a
provider calls, exposed for providers you write yourself. It
works retroactively - a PV built with initial= is already open
with its mask fixed - and accepts a plain p4p SharedPV, which
just cannot carry the setting across a close()/open().
- p4pillon.utils.mark_all(): mark every field of a Value as
changed, in place. The primitive underneath, and still the right
tool for a hand-built Value or a post() that must carry a whole
substructure.
* Handler-dispatch serialization: a per-PV re-entrant lock
serializes open()/post()/close() hooks against executor-side
put/rpc handlers (thread flavor); CompositeHandler has its own
lock for one handler shared across several PVs.
* post_deferred() on both flavors - marshals a post onto the asyncio
loop, or onto the PV's work queue in the thread flavor - so a
handler can safely post to another PV regardless of flavor. It
returns a concurrent.futures.Future.
* BaseRule.applies_to(nttype): the setup-time test for whether a
Rule attaches to a PV of a given Type, as distinct from the
per-operation is_applicable(value). BaseRule.run_last orders a
Rule after every other handler, including user handlers;
TimestampRule sets it, so the stored value carries the time it
was finalised.
* p4pillon.utils.as_raw(): the underlying raw Value of an
NT-wrapped value, tolerating unwrapped values.
* IOCMimicProvider.keys(): the base PV names, mirroring
StaticProvider.keys.
* Docs: docs/guide/record-fields.md (record-field providers, the
full value-type to RTYP table, eager/lazy trade-offs) and
docs/design/dispatch_fix.md.
CHANGED
-------
* CalcRule no longer evaluates calc expressions with eval() (#58).
Expressions run under simpleeval's SimpleEval and see only "pv"
(monitored variable values) and "m" (public attributes of math),
so a calc string cannot execute arbitrary Python. Adds a runtime
dependency on simpleeval.
* overwrite_marked()/overwrite_unmarked() are substantially faster,
using p4p's changedSet(expand=True) instead of recursing over
every leaf in Python. Behaviour and the "fields" argument are
unchanged.
* pyproject.toml modernised: PEP 639 license = "BSD-3-Clause" and
the deprecated license classifier removed, URLs pointing at
ISISNeutronMuon/p4pillon and the published docs, testpaths set,
coverage scoped to the p4pillon package.
* Linting moves to ruff 0.16.1 with many more rule sets enabled
(bugbear, async, simplify, and RUF, ARG, N, RET, PERF, LOG, PT,
TC, T20, S, TRY, EM), so previously accepted code may now fail
lint. Test deps bumped: pytest>=9.1.1, coverage>7.15.2,
pyyaml>=6.0.3 (#61). CI prints the ruff version it ran.
FIXED
-----
* A field provider no longer prints "TypeError: makeChannel(...)
must return SharedPV, not NoneType" on every base PV connection.
pvxs offers channel creation to every source in (order, name)
sequence regardless of which one claimed the search, and a field
provider - serving only <name>.<FIELD> - has to decline each base
PV name it is offered; p4p's only way to express that is None from
makeChannel, which it complains about on stderr. Field providers
built by IOCMimicProvider and IOCMimicServer are now sorted behind
every provider serving base PVs, so they are never offered them. A
DynamicProvider you build yourself is still yours to order.
* IOCMimicServer keeps every provider it depends on alive.
p4p.server.Server does that only for a StaticProvider it builds
from a bare dict, not for the field providers p4pillon builds nor
for an IOCMimicProvider it takes apart - so a caller dropping
their provider left the base PVs working (the C++ Server holds
those) while every sub-PV silently stopped resolving.
* IOCMimicProvider.set_desc_record no longer stops the fields'
timeStamps tracking the base PV. It previously dropped the
registry's weak reference to the base PV outright, switching off
DESC tracking and timestamp tracking together; it now opts out of
DESC alone.
* p4pillon.server.asyncio.SharedPV was re-exporting p4p's thread
SharedPV; it now derives from p4p.server.asyncio.SharedPV.
* Handler hooks are added by ordinary inheritance
(HandlerHooksMixin, SharedNTMixin) instead of monkey-patching
p4p.server.raw.SharedPV, which silently did nothing if p4p was
imported first.
* open()/post() no longer wrap values twice.
* Server.__init__ uses self._context, so the thread and asyncio
Server subclasses no longer mutate the shared base _context.
* timeStamp is no longer falsely reported as changed:
CompositeHandler.put posts the raw client Value rather than
op.value(), whose re-wrap re-marked timeStamp even when the
client touched only "value". With the changed-set trustworthy,
TimestampRule now preserves a caller-supplied timeStamp instead
of always overwriting it.
* TimestampRule declared its supported types as "nttype" rather
than "nttypes", so the attribute the rule machinery reads was
never set. Regression tests now cover the declaration of every
BaseRule subclass.
* ValueAlarmRule:
- Turning off valueAlarm.active clears the alarm the rule raised,
instead of leaving it stale.
- An alarm severity supplied with an update is respected rather
than overwritten by the limit verdict. The two are maximised as
EPICS Base does (recGblSetSevrVMsg): highest severity wins, and
on a tie the first contributor keeps alarm.message. An alarm
left from a previous update no longer suppresses the limit
check.
- NaN is reported as undefined - INVALID_ALARM, message "UDF",
status UNDEFINED_STATUS - matching Base, checked before and
independently of the limits. alarm.status is otherwise left to
whatever raised it; the rule clears only the UNDEFINED_STATUS
it set itself.
- Alarm fields are written only when the alarm state moves. The
previous unconditional write marked the field changed on every
update, which on the array path re-posted an unchanged alarm to
every monitoring client.
- Malformed limit names no longer raise SyntaxError; the four
limits are a fixed table.
* ScalarToArrayWrapperRule handles arrays that change length:
elements past the end of the previous array get the wrapped
rule's init_rule (there is no previous element to compare
against) rather than a post_rule with a None old state, and
indices dropped by a shrunken array are not checked. Empty arrays
(which p4p returns as None, not an empty sequence) no longer
raise TypeError, and element values are converted to Python
built-ins so p4p accepts them back into scalar fields.