Skip to content

Replace Pydantic with custom validation logic, restore v0.1.5 config and registry algorithm#73

Merged
honnibal merged 29 commits into
mainfrom
no-pydantic-v2
Mar 20, 2026
Merged

Replace Pydantic with custom validation logic, restore v0.1.5 config and registry algorithm#73
honnibal merged 29 commits into
mainfrom
no-pydantic-v2

Conversation

@honnibal

Copy link
Copy Markdown
Member

Pydantic v2 is stricter than v1 and I've been unable to replicate the behaviours we had in the config.

I had also attempted to refactor the library to make it easier to port, but this seems to have also introduced regressions.

To finally resolve this I'm dropping support for Pydantic in favour of a relatively small amount of custom validation logic. This should finally unblock spaCy and Thinc.

honnibal added 29 commits March 20, 2026 14:42
Remove pydantic and pydantic-core dependencies entirely. Add
confection/_validation.py with Schema base class, dynamic schema
creation, and type validation supporting unions, generics, forward
references, Literal, generators, and constrained types (StrictBool,
PositiveInt, StrictFloat).

Key changes:
- New _validation.py module replaces all Pydantic functionality
- _registry.py uses custom Schema/create_schema/Field/ValidationError
- util.py Generator class uses metaclass for isinstance checks
- Removed test_pydantic_generators.py (Pydantic-specific tests)
- Updated all test imports from pydantic to confection._validation
- Removed pydantic from setup.cfg install_requires
Downstream libraries (spaCy, thinc) pass pydantic BaseModel subclasses
to registry.resolve(schema=...) and registry.fill(schema=...). The shim
transparently converts these to our Schema interface:

- Extracts fields from pydantic v1 (__fields__) or v2 (model_fields)
- Extracts config from v1 (inner class Config) or v2 (model_config dict)
- Handles v1 Extra enum for extra='forbid'/'allow'
- Delegates model_validate to the original pydantic class so that
  pydantic validators, strict types, and constraints keep working
- Recursively converts nested BaseModel field annotations
- Caches conversions for performance

Also fixes validate_type to accept constrained subtypes (e.g. pydantic's
StrictInt which inherits from int) by checking issubclass(annotation,
type(value)) as a fallback.
- resolve_type_hints: catch (NameError, AttributeError, TypeError)
  instead of bare Exception
- _is_pydantic_model: catch (ImportError, ModuleNotFoundError) on
  import, use else clause for issubclass so import errors and logic
  errors are never conflated
- _pydantic_model_validate: resolve the concrete pydantic
  ValidationError class(es) up front via _get_pydantic_validation_error()
  so the except clause catches only pydantic validation errors, not
  arbitrary exceptions
Start from confection v0.1.5 (last pydantic v1 release) and replace
pydantic with our validation module, keeping the registry code (_fill,
_make, etc.) intact:

- Replace pydantic imports with validation.py (Schema, Field, etc.)
- Replace srsly with stdlib json
- BaseModel -> Schema, __fields__ -> model_fields, parse_obj -> model_validate
- copy_model_field -> _override_field_to_any
- create_model -> create_schema
- Add ensure_schema at resolve/fill/_fill entry points
- Drop pydantic and srsly from install_requires

102/108 stock tests passing. Remaining 5 failures need test updates
for __fields__ -> model_fields and pydantic-specific assertions.
- test_make_promise_schema: __fields__ -> model_fields
- test_resolve_schema_coerced: expect raw values (no pydantic coercion)
- test_validation_custom_types: fix pydantic v1 validator calling —
  skip 3-arg validators needing config objects, use field shim for 2-arg
- test_config_reserved_aliases: reorder validator hooks before
  constrained-subtype fallback so StrictBool/PositiveInt validators run
- test_config_fill_without_resolve: reset model_validate to Schema's
  own after _override_field_to_any mutates fields
- test_frozen_struct_deepcopy: unfreeze frozen dict/list defaults so
  _update_from_parsed can write to them

111/111 stock v0.1.5 tests passing.
- _override_field_to_any: create a new Schema subclass instead of
  mutating the cached wrapper, so ensure_schema cache isn't corrupted
- Add _contains_promise: detect nested promises in dict values
- Override fields to Any when value contains nested promises and
  resolve=False (matches stock confection's behavior)
- Fix pydantic v1 validator calling: skip 3-arg validators needing
  config objects, use field shim for 2-arg constraint validators
- Reorder validator hooks before constrained-subtype fallback

111/111 confection tests, 21/21 spaCy serialize_config tests passing.
- test_pydantic_shim: 16/16 pass — tests ensure_schema backward compat
- test_config_values: 89/102 pass — Hypothesis property tests for config
  parsing. 13 failures are json serialization edge cases from srsly→json
  migration (whitespace handling, negative numeric strings), not pydantic
  related.

Total: 202 passed across all test files.
Cherry-pick the config parsing fixes from dbc8484 and a4e2d58:

- Simplify before_read: just warn about single quotes, no unquoting
  (the unquoting caused double-parsing of string values like '0')
- Add _coerce_for_string_context: coerce values in compound string
  interpolation (bare refs keep JSON type, compounds join as strings)
- Rewrite before_get: detect bare refs (len(L)==1) vs compound
- Fix _get_section_ref: guard against value == SECTION_PREFIX,
  use replace(prefix, '', 1) to avoid stripping too much
- Remove isdigit() hack in try_dump_json (no longer needed without
  the unquoting in before_read)
- Remove JSON_EXCEPTIONS constant (no longer used)

215/215 tests passing, 0 failures.
- Replace black, isort, flake8 with ruff in CI, requirements, config
- Remove mypy config from setup.cfg
- Add ruff config to pyproject.toml
- Fix lint issues: unused imports, unused vars, trailing whitespace,
  type comparison (== -> is), import sorting
- CI: single 'ruff check' + 'ruff format --check' step
pydantic v1 compat layer is broken on Python 3.14. Skip test_config.py
and test_pydantic_shim.py entirely on 3.14+. Fall back to our own
StrictBool in tests/util.py when pydantic is unavailable.
When validate_type encounters a TypeVar with a bound, validate against
the bound. For constrained TypeVars, try each constraint. This catches
type mismatches like passing ndarray where SeqT (bound to
Union[Ragged, Padded, List[...]]) is expected.
- FieldInfo.annotation: type as Any instead of None
- alias_gen: add callable() check for type narrowing
- sys.modules.get: guard against None module name
- NewType __supertype__: suppress reportFunctionMemberAccess
- Extra.value: suppress reportAttributeAccessIssue (guarded by hasattr)
- pydantic ValidationError.errors(): suppress (typed as Exception)
- SimpleFrozenDict.update: suppress override warning

7 remaining errors are pre-existing ConfigParser internals from stock.
Replace in-function try/except pydantic imports with module-level
imports that set _PydanticV1BaseModel, _PydanticV2BaseModel, etc.
to None when pydantic is not installed. Cleaner and avoids repeated
import attempts on every call.
3 remaining errors are pre-existing stock ConfigParser internals.
44 tests covering all type validation branches:
- None, Annotated, Union, Optional, pipe syntax
- Literal, NewType, TypeVar (unbound, bound, constrained)
- Plain types: bool, int, float, str, Path
- Generic types: Callable, List, Dict, Tuple (bare, fixed, variable),
  Set, FrozenSet, Sequence, Iterable, Mapping, Iterator, Type[X]
- Schema-as-dict validation
- Pydantic hooks (__get_pydantic_core_schema__, __get_validators__)
- Generator passthrough
- _validate_schema: extra forbid/allow, missing required, aliases
- ensure_schema passthrough
- model_dump, Schema.from_function

validation.py coverage: 66% -> 87%
Ruff splits combined imports into separate statements. Each from line
needs its own pyright: ignore[reportMissingImports] comment since
pydantic is not installed during CI lint.
@honnibal
honnibal merged commit 8b42753 into main Mar 20, 2026
16 checks passed
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