Skip to content

fix(bfabric): correct owner typing in entities/core and the None defects it hid - #600

Merged
leoschwarz merged 5 commits into
mainfrom
fix/entities-core-owner-typing
Aug 14, 2026
Merged

fix(bfabric): correct owner typing in entities/core and the None defects it hid#600
leoschwarz merged 5 commits into
mainfrom
fix/entities-core-owner-typing

Conversation

@leoschwarz

Copy link
Copy Markdown
Member
  • Fix created_by / modified_by to raise a ValueError naming the login when it cannot be resolved to a user, instead of returning None and failing later as an opaque AttributeError.
  • Fix class-level access on a HasMany descriptor (e.g. Workunit.resources) to raise AttributeError: 'resource' is only accessible on an instance, matching HasOne, instead of 'NoneType' object has no attribute 'refs'.
  • Change HasMany's bfabric_field to a required keyword argument.
  • The UserCreatedMixin properties also raise if their underlying field is not a string, or if the entity has no client. This moves the exception type on WorkunitDefinition building and Workunit.store_output_folder from AttributeError to ValueError — unreachable while the field is populated, which it always is in B-Fabric.

🤖 Prepared with assistance from Claude Opus 5 via Claude Code.

HasOne was made generic over its return type in #533, but HasMany's
__get__ still typed its owner with a bare, unbounded TypeVar. An
unbounded T has no `refs` member and `obj` was declared `| None`
without a guard, so that one line produced reportAttributeAccessIssue
plus reportOptionalMemberAccess, and the resulting Unknown `items`
cascaded into five more baselined errors.

Mirror the shape has_one.py already uses: owner typed as Entity, an
instance-only guard, and a single cast at the proxy call to narrow
refs.get's loose Entity | list[Entity] | None. The guard also closes a
runtime wart — class-level access (Workunit.resources) raised
"'NoneType' object has no attribute 'refs'" instead of a clear message.

Making bfabric_field required is the second-order fix: References.get
takes str, and that mismatch was invisible only because refs.get
resolved to Unknown. All eight call sites already pass it explicitly.
A caller that omitted it was already broken, though not loudly: with
optional=False it raised "Missing field: None", and with optional=True
References.get(None) found no ref_info and the descriptor silently
yielded an empty proxy. Both now fail at construction instead.

Note this does not improve inference for consumers: __get__ already
declared its return as _HasManyProxy[E], so `workunit.resources` and
friends typed correctly before this change and the Unknown never
escaped the function body. The 7 removed baseline entries are all
within has_many.py.
…cts it hid

Three members in entities/core typed their owner with something lacking
the attributes their body accessed, yielding Unknown — which then hid
real defects downstream. has_many (887057a) was the first; this covers
the other two and the bugs they were masking.

users.py: `self._users = []` was unannotated, so the cache was
list[Unknown] and everything reading it decayed. That concealed
get_by_id declaring `-> User | None` while calling read_id's *string*
overload, which returns Entity. It now passes the User class and picks
up the typed overload (entity_reader.py:146), matching what
get_by_login already did with query_one.

user_created_mixin.py: `self: EntityProtocol` gave the four properties
an owner with only _client and data_dict — not bfabric_instance, and
not the mixin's own _users. Declaring the host-supplied members on the
mixin under TYPE_CHECKING types `self` as the mixin itself, so it
carries both. Two latent bugs surfaced once the Unknown cleared:

  - `self._client.reader` was unguarded though _client is Bfabric|None,
    reachable via Entity(data_dict=..., bfabric_instance=...) with no
    client. Now raises.
  - created_by/modified_by declared `-> User` but get_by_login returns
    User | None. workunit_definition.py:76 does created_by.id
    unguarded, so an unresolvable login gave "'NoneType' object has no
    attribute 'id'" on a path that feeds app-runner. Kept `-> User` and
    raise a ValueError naming the login: the field is always populated
    in B-Fabric, so None is an error, not a value. Consumers are
    untouched, since the declared `-> User` already shielded them.

Declaring data_dict honestly as ApiResponseObjectType rather than
EntityProtocol's looser dict[str, Any] also removed the 6 reportAny
this was going to leave behind, using the isinstance/ValueError
narrowing entity.py already uses for id and classname.

An attempt to model the owner as a Protocol extending EntityProtocol
was abandoned: Workunit failed to satisfy it, breaking
`workunit.created_by` outright, and it drew three reportPrivateUsage
warnings. EntityProtocol now has no consumers.

Baseline: 835 -> 787, all 48 removals within the four touched files,
zero additions.
The preceding commit stopped annotating UserCreatedMixin's `self` with
it, and that was its only consumer: mixins/__init__.py is empty, so it
was never re-exported, and nothing in the docs or tests referenced it.
Its `data_dict` was also declared `dict[str, Any]`, looser than
Entity's actual ApiResponseObjectType, so it would have been a
misleading thing to keep around as a template for new mixins.

Removes 1 further baseline entry (a reportExplicitAny on that
`dict[str, Any]`).
The isinstance/ValueError guard on data_dict fields was scope creep:
it existed to satisfy the type checker, not any caller. cast is the
house style for exactly this narrowing (has_one.py:31, workunit.py:67),
and it restores main's behaviour for created_at/modified_at, so
Workunit.store_output_folder no longer changes its failure mode at all.
The one behaviour change left is the intended one, created_by/
modified_by raising on an unresolvable login.

Also compacts the TYPE_CHECKING stub block, which black accepts without
blank lines between consecutive stub defs.

65 -> 58 lines; the diff against main goes from +40/-11 to +34/-12.
Note the property-stub form is load-bearing, not verbosity: plain
attribute annotations (data_dict: ApiResponseObjectType) draw 18
reportIncompatibleVariableOverride errors, because every host class
inherits these as properties from Entity.
@leoschwarz
leoschwarz marked this pull request as ready for review August 14, 2026 08:14
@leoschwarz
leoschwarz merged commit 4030012 into main Aug 14, 2026
46 of 48 checks passed
@leoschwarz
leoschwarz deleted the fix/entities-core-owner-typing branch August 14, 2026 08:14
leoschwarz added a commit that referenced this pull request Aug 14, 2026
The merge of main brought #600's stricter ApiResponseDataType, which made
Path(workunit.application.executable["program"]) a type error. Cast at the
call site as the sibling WrapAppYamlTemplate already does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant