Add value_type= to core columns; HashedPassword replaces PasswordField - #131
Draft
davegaeddert wants to merge 9 commits into
Draft
davegaeddert wants to merge 9 commits into
davegaeddert wants to merge 9 commits into
Conversation
Brings in #83 (typed model construction via @dataclass_transform), which this branch builds on. plain-forms had not yet merged it. Conflict in test_db_expression_defaults.py resolved in favor of the plain-forms side: model_to_dict/construct_instance no longer exist.
#83 gave every model column a `Field[T]` annotation, which is what synthesizes the typed constructor. That made the `ColumnField[T]` overload on `model_field()` stop matching -- a column reference is typed by its annotation, so every `model_field(Model.column)` fell through to the `Any` overload and nothing downstream type-checked. Match the base `Field[T]` instead. Also narrows an optional foreign key in the example suite, which the same annotations made visible.
A column constructor can now be handed a Python type it round-trips through, so a package or an app owns a value's behavior without subclassing a field class -- which PEP 681 can't see, because only plain.postgres may declare field specifiers. The `ValueType` protocol is two methods: `from_db(raw) -> Self` and `to_db() -> raw`. Reads rebuild the value after the column's own converter, writes unwrap it, and anything that isn't an instance of the declared type is a TypeError naming the field -- one refusal covering instance writes, `QuerySet.update()`, and `filter()` alike. The column is unchanged: still text, still jsonb, and `value_type` stays out of `deconstruct()` so no migration file ever imports the type. The form layer refuses to derive a field from a value-typed column; only the package owning the type knows how to parse raw input into one.
The stub's `value_type=` overloads come first so they win when the kwarg is present, and they return the base `Field[X]` rather than the text-field subtype: the column stores text, but the value is opaque, so text-only condition methods must not leak onto it. (When the typed query API lands the condition set comes from X, not from str.) Everything #83 established still holds -- the field is required in the constructor unless the call passes `default=`, `allow_null=True` gives `Field[X | None]`, and the descriptor is symmetric, so assigning a raw primitive is a call-site type error as well as a write-time TypeError. The tests pin the static facts the way #83's do: a `ty: ignore` that would be reported as unused -- and fail the build -- if the fact ever changed.
A password is now a value type a core column carries -- `types.TextField(value_type=HashedPassword)` -- rather than a field subclass. Because the constructor is a core one, typed construction finally treats `password` as required; `PasswordField` could never be seen by PEP 681, so `User(email=...)` type-checked against a NOT NULL column. HashedPassword wraps the encoded hash: `from_raw()` hashes, `check()` verifies, `needs_rehash()` reports a stale hasher, and `__repr__` is opaque so a hash can't fall into a log line. It does not validate raw passwords -- `validate_raw_password()` (the three shipped rules, moved off the field) is what the form layer runs. The format sniffing goes away rather than moving. With a strict value type the question 'is this already hashed?' cannot come up: the only way to get a HashedPassword is to hash one.
The password form field is where a raw password stops. It validates and hashes, so the views lose their separate `get_password_errors()` pass -- a rule failure now attaches to the field that read the raw value. The set/change forms' `new_password1`/`new_password2` become `new_password` (a HashedPassword) and `confirm_password` (raw), matching the signup form's pairing. A confirmation can't be compared hash to hash -- two hashes of the same password have different salts -- so the check is `new_password.check(confirm_password)`. The reset token carries `str(user.password)` and its staleness check is now a HashedPassword comparison (constant time, via `__eq__`). plain.auth's session hash str()s the field explicitly, since it may hold a value type. Example and test-app models, their migrations, and every fixture that constructed a user with a raw string follow.
The annotated-model rule and the README both carried a caveat that a field type declared outside plain.postgres -- PasswordField, or one of your own -- is always optional in the constructor even when the column is NOT NULL. That hole is closed: the set of field classes is closed and `value_type=` is the extension axis instead, so the caveat and the `PasswordMixin` + `@dataclass_transform` workaround it pointed at are both deleted. A new 'Value types' section documents the protocol, the strict descriptor, and the fact that migrations never see the kwarg. Every README that showed `PasswordField` now shows the value-typed column. The stub's value_type overloads also drop `max_length` and `choices`: both are Python-side checks against the value, so neither means anything applied to an opaque one, and leaving them out makes the combination a declaration-site type error rather than a crash in full_clean(). Mirrors regenerated with `plain agent install`.
The README described a field class that no longer exists. It now leads with the value-typed column, documents HashedPassword's surface (from_raw / check / needs_rehash / str / opaque repr / constant-time ==), and states the two things that follow from it being an ordinary field declaration: password is required in the typed constructor, and the column is still text. Password validation leads with validate_raw_password(); the hashing section shows the check/needs_rehash rehash pair instead of the old setter callback (which wrote a raw string into the column and would now raise); the customization FAQ subclasses NewPasswordField rather than passing validators= to a field that's gone. The login template snippet was also on the dead forms API and now uses field_value/field_errors. The admin value template moves from PasswordField.html to HashedPassword.html -- plain.admin resolves by database-field-type name and then by the value's MRO, so the value type is where it belongs now -- and reads the encoded form through str() instead of treating the value as one. The rewritten migration files drop max_length: PasswordField's own deconstruct() stripped it, so TextField() is the exact equivalent and keeping it would generate a spurious AlterField.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branches off
plain-forms(#128). Base isplain-forms, so this diff also carries a merge oforigin/master—plain-formshad not yet picked up #83, which this change is built on.What this does
A core column constructor can now be handed a Python type it round-trips through:
That lets a package or an app own a value's behavior without subclassing a field class — the one extension axis typed construction can support, because PEP 681 only honors field specifiers
plain.postgresitself declares.plain-passwordsis the first customer:PasswordFieldis deleted, andpasswordbecomes a required constructor argument for the first time. UnderPasswordField,User(email=...)type-checked against aNOT NULLcolumn — the one known hole #83 documented. That caveat is now gone from the docs because the hole is gone.The protocol
Two methods, nothing else. Validation lives at whatever boundary constructs the value, not in the protocol — for a password that is the form field, the only place the raw input still exists.
What the hook guarantees
TypeErrornaming the field. One refusal inget_prep_valuecovers every path in:instance.update(),QuerySet.update(col="raw"), andfilter(col="raw").Field[X]both gets and sets anX. Assigning a primitive is a type error at the call site as well as aTypeErrorat write time. There is no lenient setter, so "is this already hashed?" can't be asked — today'spre_saveformat sniff and write-back cease to exist rather than moving.text/jsonb, andvalue_typestays out ofdeconstruct(), so no migration file imports the type and changing one generates no migration.Field[X], not the text-field subtype, so text-only condition methods don't leak onto an opaque value. (When the typed query API lands — [1b] Add typed where() with field-method conditions #84 — a value-typed field's condition set comes fromX, not fromstr.)model_field(Model.column)on a value-typed column raises rather than deriving aTextFieldthat would hand the model a raw string.plain-passwords
HashedPasswordwraps the encoded hash:from_raw()hashes,check(raw)verifies,needs_rehash()reports a stale hasher,str()is the encoded hash, andrepr()is deliberately opaque (<HashedPassword>) so a hash can't fall into a log line.validate_raw_password()— the three shipped rules, moved off the field — is what the newNewPasswordFieldform field runs before hashing.Upgrade instructions
1. Model annotation
plain.passwords.modelsandplain.passwords.typesare deleted. There is no compatibility shim.2. Migration files
Historical migrations reference
PasswordFieldby import path and will fail to load. Rewrite each one to the plain text column it always was:PasswordField.deconstruct()dropped its ownmax_length=128, sopostgres.TextField()is the exact equivalent — the column definition is unchanged, no new migration is generated, and no data moves. (Writingmax_length=128here would generate a spuriousAlterField, since the model no longer declares one.)3. Writing a password
A raw string no longer hashes itself on save — it raises.
Anywhere a password comes from user input, prefer routing it through the form layer (
NewPasswordField) so the raw value is validated before it's hashed.4. Reading a password
user.passwordis aHashedPassword, not astr. Anything that displays, serializes, or signs it needs the encoded form explicitly:Comparisons are better done with the value type than with strings:
5. Form fields
PasswordSetForm/PasswordChangeFormrenamednew_password1/new_password2tonew_password/confirm_password, matching the signup form's pairing.new_passwordcleans to aHashedPassword;confirm_passwordstays raw, because two hashes of the same password have different salts and can never be compared. Update any overridden templates.6. Removed helpers
get_password_errors(user, password, field=)— gone. The password form field validates the raw value itself, so the error attaches to the field automatically.set_user_password(user, password)now takes aHashedPassword, not a raw string.check_user_password(user, raw)is unchanged in signature and still rehashes on a stale hasher — it just usesHashedPassword.check()/.needs_rehash()to do it.