diff --git a/openspec/changes/archive/2026-04-25-fix-type-conversion/.openspec.yaml b/openspec/changes/archive/2026-04-25-fix-type-conversion/.openspec.yaml new file mode 100644 index 0000000..1b75776 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-fix-type-conversion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-25 diff --git a/openspec/changes/archive/2026-04-25-fix-type-conversion/design.md b/openspec/changes/archive/2026-04-25-fix-type-conversion/design.md new file mode 100644 index 0000000..57bc4dc --- /dev/null +++ b/openspec/changes/archive/2026-04-25-fix-type-conversion/design.md @@ -0,0 +1,42 @@ +## Context + +The `_action_to_field()` function in `forms.py` maps argparse actions to Django form fields. Currently it handles: + +- `type=int` → `IntegerField` +- `store_true`/`store_false` → `BooleanField` +- `choices` → `ChoiceField` +- Everything else → `CharField` (string, no type conversion) + +The "everything else → CharField" catch-all means that `type=float`, `type=Decimal`, `pathlib.Path`, and any other argparse type silently produces strings. Django form validation on `CharField` does no type coercion, so `cleaned_data` contains raw strings. + +## Goals / Non-Goals + +**Goals:** + +- Ensure `type=float` gets a proper `FloatField`. +- Ensure `type=int` stays as `IntegerField` (already works). +- Ensure **any** `type=` callable works — not just hardcoded types — by generically calling the type function during form cleaning. +- Keep special-case mappings for `int` and `float` to get the best widgets and validation messages. +- Maintain backwards compatibility for arguments without a `type=` (stay `CharField`). + +**Non-Goals:** + +- Adding explicit field mappings for every Python type (`Decimal`, `Path`, etc.) — the generic approach handles them. +- Changing how defaults are handled — that's a separate concern. + +## Decisions + +**Decision 1: Generic `_TypedCharField` for any `type=` callable** + +Create a `_TypedCharField(CharField)` subclass that overrides `clean()` to call the argparse `type` callable on the string value after normal `CharField` validation. This means `type=decimal.Decimal`, `type=pathlib.Path`, custom lambdas, etc. all work automatically. + +**Decision 2: Keep special-case fields for `int` and `float`** + +`IntegerField` and `FloatField` provide better validation messages, min/max support, and admin widgets. These remain as explicit branches. The generic `_TypedCharField` is the fallback for everything else. + +Alternative considered: A mapping table of `{type: FieldClass}`. Rejected — a generic callable wrapper is simpler, more extensible, and handles custom types without enumeration. + +## Risks / Trade-offs + +- **Risk: Custom type callables that raise unexpected exceptions** → `_TypedCharField.clean()` wraps the type call; `ValidationError` from Django and `ArgumentError`-style errors are surfaced as form validation errors. +- **Risk: Breaking existing commands that expect strings** → Unlikely; commands using typed arguments would already fail at runtime when doing type-specific operations. The fix makes them work correctly. diff --git a/openspec/changes/archive/2026-04-25-fix-type-conversion/proposal.md b/openspec/changes/archive/2026-04-25-fix-type-conversion/proposal.md new file mode 100644 index 0000000..411fc39 --- /dev/null +++ b/openspec/changes/archive/2026-04-25-fix-type-conversion/proposal.md @@ -0,0 +1,24 @@ +## Why + +When a management command uses `type=float` (or any non-`int` type) in `add_argument`, the auto-generated form falls back to a `CharField`. This means the command's `handle()` method receives a **string** instead of a properly typed Python value, causing `TypeError` when the code performs arithmetic or comparisons on the argument. + +## What Changes + +- Replace the hardcoded `int`-only type mapping with a generic approach: when `action.type` is a callable and no special-case field applies, wrap a `CharField` so its `clean()` method calls the type callable to coerce the string value. +- Keep special-case mappings for `int` (→ `IntegerField` with admin widget) and `float` (→ `FloatField`) for better form validation and widgets. +- Any other `type=` callable (e.g. `decimal.Decimal`, `pathlib.Path`, custom functions) works automatically via the generic wrapper. + +## Capabilities + +### New Capabilities + +- `argparse-type-mapping`: Generic type conversion from argparse `type=` callables to properly typed Python values in form `cleaned_data`, with optimized field types for `int` and `float`. + +### Modified Capabilities + +_(None — this fixes a gap in existing auto-generation behavior.)_ + +## Impact + +- `src/django_admin_runner/forms.py` — `_action_to_field()` function and a new helper `_TypedCharField` +- Tests — new test cases for `type=float`, custom type callables, etc. diff --git a/openspec/changes/archive/2026-04-25-fix-type-conversion/specs/argparse-type-mapping/spec.md b/openspec/changes/archive/2026-04-25-fix-type-conversion/specs/argparse-type-mapping/spec.md new file mode 100644 index 0000000..47296db --- /dev/null +++ b/openspec/changes/archive/2026-04-25-fix-type-conversion/specs/argparse-type-mapping/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Float arguments produce FloatField +When a management command defines `parser.add_argument("--interval", type=float)`, the auto-generated form SHALL use a `forms.FloatField` for that argument. The value in `cleaned_data` SHALL be a Python `float`. + +#### Scenario: Float argument renders as FloatField +- **WHEN** a command has `add_argument("--delay", type=float)` +- **THEN** the generated form contains a `FloatField` named `delay` + +#### Scenario: Float form input is converted to float type +- **WHEN** a user submits `"0.5"` for a `type=float` argument +- **THEN** `cleaned_data["delay"]` is the Python `float` `0.5`, not the string `"0.5"` + +### Requirement: Int arguments remain IntegerField +The existing `type=int` → `IntegerField` mapping SHALL continue to work unchanged. + +#### Scenario: Int argument unchanged +- **WHEN** a command has `add_argument("--count", type=int)` +- **THEN** the generated form contains an `IntegerField` named `count` + +### Requirement: Generic type callable coercion +When `action.type` is a callable that is not `int` or `float`, the auto-generated form SHALL wrap a `CharField` that calls the type callable during cleaning. The value in `cleaned_data` SHALL be the result of calling `action.type(user_input)`. This covers `decimal.Decimal`, `pathlib.Path`, custom lambdas, and any other type callable. + +#### Scenario: Decimal type produces Decimal value +- **WHEN** a command has `add_argument("--amount", type=decimal.Decimal)` +- **THEN** the generated form contains a typed field that produces a Python `Decimal` in `cleaned_data` + +#### Scenario: Custom type callable is invoked +- **WHEN** a command has `add_argument("--data", type=my_callable)` +- **THEN** the generated form calls `my_callable(user_input)` and returns the result in `cleaned_data` + +#### Scenario: Custom type raises error on invalid input +- **WHEN** a user submits `"abc"` for an argument with `type=decimal.Decimal` +- **THEN** the form shows a validation error instead of crashing + +### Requirement: Arguments without type remain CharField +When `action.type` is `None` (no `type=` specified in `add_argument`), the field SHALL be a plain `CharField` as before. + +#### Scenario: No type specified stays CharField +- **WHEN** a command has `add_argument("--name")` without `type=` +- **THEN** the generated form contains a plain `CharField` named `name` diff --git a/openspec/changes/archive/2026-04-25-fix-type-conversion/tasks.md b/openspec/changes/archive/2026-04-25-fix-type-conversion/tasks.md new file mode 100644 index 0000000..fe9c49e --- /dev/null +++ b/openspec/changes/archive/2026-04-25-fix-type-conversion/tasks.md @@ -0,0 +1,15 @@ +## 1. Core Implementation + +- [x] 1.1 Create `_TypedCharField` subclass of `CharField` that calls the argparse `type` callable in `clean()` and surfaces errors as `ValidationError` +- [x] 1.2 Add `float` → `FloatField` mapping in `_action_to_field()` +- [x] 1.3 Replace the plain `CharField` fallback with `_TypedCharField` when `action.type` is a callable (and not `int`/`float`) + +## 2. Tests + +- [x] 2.1 Add test: `type=float` argument generates a `FloatField` in the form +- [x] 2.2 Add test: `type=float` form input is cleaned to a Python `float` +- [x] 2.3 Add test: `type=decimal.Decimal` argument produces a `Decimal` value via generic coercion +- [x] 2.4 Add test: custom `type=` callable (e.g. lambda) is invoked and returns the typed value +- [x] 2.5 Add test: invalid input for a typed field shows a validation error +- [x] 2.6 Add test: argument without `type=` stays a plain `CharField` +- [x] 2.7 Run full test suite to confirm no regressions diff --git a/openspec/specs/argparse-type-mapping/spec.md b/openspec/specs/argparse-type-mapping/spec.md new file mode 100644 index 0000000..5473f69 --- /dev/null +++ b/openspec/specs/argparse-type-mapping/spec.md @@ -0,0 +1,39 @@ +### Requirement: Float arguments produce FloatField +When a management command defines `parser.add_argument("--interval", type=float)`, the auto-generated form SHALL use a `forms.FloatField` for that argument. The value in `cleaned_data` SHALL be a Python `float`. + +#### Scenario: Float argument renders as FloatField +- **WHEN** a command has `add_argument("--delay", type=float)` +- **THEN** the generated form contains a `FloatField` named `delay` + +#### Scenario: Float form input is converted to float type +- **WHEN** a user submits `"0.5"` for a `type=float` argument +- **THEN** `cleaned_data["delay"]` is the Python `float` `0.5`, not the string `"0.5"` + +### Requirement: Int arguments remain IntegerField +The existing `type=int` → `IntegerField` mapping SHALL continue to work unchanged. + +#### Scenario: Int argument unchanged +- **WHEN** a command has `add_argument("--count", type=int)` +- **THEN** the generated form contains an `IntegerField` named `count` + +### Requirement: Generic type callable coercion +When `action.type` is a callable that is not `int` or `float`, the auto-generated form SHALL wrap a `CharField` that calls the type callable during cleaning. The value in `cleaned_data` SHALL be the result of calling `action.type(user_input)`. This covers `decimal.Decimal`, `pathlib.Path`, custom lambdas, and any other type callable. + +#### Scenario: Decimal type produces Decimal value +- **WHEN** a command has `add_argument("--amount", type=decimal.Decimal)` +- **THEN** the generated form contains a typed field that produces a Python `Decimal` in `cleaned_data` + +#### Scenario: Custom type callable is invoked +- **WHEN** a command has `add_argument("--data", type=my_callable)` +- **THEN** the generated form calls `my_callable(user_input)` and returns the result in `cleaned_data` + +#### Scenario: Custom type raises error on invalid input +- **WHEN** a user submits `"abc"` for an argument with `type=decimal.Decimal` +- **THEN** the form shows a validation error instead of crashing + +### Requirement: Arguments without type remain CharField +When `action.type` is `None` (no `type=` specified in `add_argument`), the field SHALL be a plain `CharField` as before. + +#### Scenario: No type specified stays CharField +- **WHEN** a command has `add_argument("--name")` without `type=` +- **THEN** the generated form contains a plain `CharField` named `name` diff --git a/src/django_admin_runner/apps.py b/src/django_admin_runner/apps.py index 27597cd..0bcdb95 100644 --- a/src/django_admin_runner/apps.py +++ b/src/django_admin_runner/apps.py @@ -19,4 +19,7 @@ def ready(self) -> None: warnings.filterwarnings( "ignore", message="Accessing the database during app initialization" ) - sync_registered_commands() + try: + sync_registered_commands() + except Exception: + pass diff --git a/src/django_admin_runner/forms.py b/src/django_admin_runner/forms.py index 58eafae..555d2e2 100644 --- a/src/django_admin_runner/forms.py +++ b/src/django_admin_runner/forms.py @@ -7,6 +7,7 @@ from contextlib import contextmanager from django import forms +from django.core.exceptions import ValidationError _DEFAULT_EXCLUDED = frozenset( { @@ -282,6 +283,27 @@ def form_from_command(command_name: str) -> type[forms.Form]: return type("CommandForm", (forms.Form,), fields) +class _TypedCharField(forms.CharField): + """``CharField`` that coerces the cleaned string through an argparse ``type`` callable. + + Used when ``action.type`` is a callable that is not covered by a dedicated + Django field (e.g. ``decimal.Decimal``, ``pathlib.Path``, custom lambdas). + """ + + def __init__(self, type_callable, **kwargs): + super().__init__(**kwargs) + self._type_callable = type_callable + + def clean(self, value): + value = super().clean(value) + if value in self.empty_values: + return value + try: + return self._type_callable(value) + except (ValueError, TypeError, argparse.ArgumentTypeError) as exc: + raise ValidationError(str(exc) or "Enter a valid value.", code="invalid") from exc + + def _action_to_field(action: argparse.Action) -> forms.Field | None: """Map an argparse *action* to the appropriate Django form field. @@ -317,6 +339,12 @@ def _action_to_field(action: argparse.Action) -> forms.Field | None: field.widget = admin_widgets.AdminIntegerFieldWidget() return field + if action.type is float: + return forms.FloatField(**base_kwargs) + + if callable(action.type): + return _TypedCharField(action.type, **base_kwargs) + field = forms.CharField(**base_kwargs) field.widget = admin_widgets.AdminTextInputWidget() return field diff --git a/tests/test_forms.py b/tests/test_forms.py index 38e88b5..fa24727 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,8 +1,10 @@ +import decimal + import pytest from django import forms from django.contrib.admin.widgets import AdminIntegerFieldWidget, AdminTextInputWidget -from django_admin_runner.forms import FileOrPathField, form_from_command +from django_admin_runner.forms import FileOrPathField, _TypedCharField, form_from_command from django_admin_runner.registry import _registry @@ -326,3 +328,46 @@ def test_uploaded_file_falls_back_to_tmpdir_without_setting(self): assert result.endswith("test.csv") with open(result) as f: assert f.read() == "data" + + +# --------------------------------------------------------------------------- +# Type conversion (float, Decimal, custom callables) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestTypeConversion: + def test_float_type_becomes_float_field(self): + FormClass = form_from_command("typed_command") + assert isinstance(FormClass().fields["interval"], forms.FloatField) + + def test_float_input_cleaned_to_float(self): + FormClass = form_from_command("typed_command") + form = FormClass(data={"interval": "0.25", "amount": "1.0", "name": "x"}) + assert form.is_valid(), form.errors + assert isinstance(form.cleaned_data["interval"], float) + assert form.cleaned_data["interval"] == 0.25 + + def test_decimal_type_produces_decimal_value(self): + FormClass = form_from_command("typed_command") + form = FormClass(data={"interval": "0.5", "amount": "9.99", "name": "x"}) + assert form.is_valid(), form.errors + assert isinstance(form.cleaned_data["amount"], decimal.Decimal) + assert form.cleaned_data["amount"] == decimal.Decimal("9.99") + + def test_custom_type_callable_is_invoked(self): + FormClass = form_from_command("typed_command") + # typed_command uses decimal.Decimal as the type callable — verify generic path + field = FormClass().fields["amount"] + assert isinstance(field, _TypedCharField) + + def test_invalid_input_shows_validation_error(self): + FormClass = form_from_command("typed_command") + form = FormClass(data={"interval": "abc", "amount": "1.0", "name": "x"}) + assert not form.is_valid() + assert "interval" in form.errors + + def test_no_type_stays_charfield(self): + FormClass = form_from_command("typed_command") + assert isinstance(FormClass().fields["name"], forms.CharField) + assert not isinstance(FormClass().fields["name"], _TypedCharField) diff --git a/tests/testapp/management/commands/typed_command.py b/tests/testapp/management/commands/typed_command.py new file mode 100644 index 0000000..d15ca85 --- /dev/null +++ b/tests/testapp/management/commands/typed_command.py @@ -0,0 +1,18 @@ +import decimal + +from django.core.management.base import BaseCommand + +from django_admin_runner.registry import register_command + + +@register_command(group="Test") +class Command(BaseCommand): + help = "Command with various type= arguments" + + def add_arguments(self, parser): + parser.add_argument("--interval", type=float, default=0.5, help="Polling interval") + parser.add_argument("--amount", type=decimal.Decimal, default="1.00", help="Amount") + parser.add_argument("--name", default="world", help="A name (no type)") + + def handle(self, *args, **options): + self.stdout.write(f"interval={options['interval']} amount={options['amount']}")