Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-25
42 changes: 42 additions & 0 deletions openspec/changes/archive/2026-04-25-fix-type-conversion/design.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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`
15 changes: 15 additions & 0 deletions openspec/changes/archive/2026-04-25-fix-type-conversion/tasks.md
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions openspec/specs/argparse-type-mapping/spec.md
Original file line number Diff line number Diff line change
@@ -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`
5 changes: 4 additions & 1 deletion src/django_admin_runner/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions src/django_admin_runner/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from contextlib import contextmanager

from django import forms
from django.core.exceptions import ValidationError

_DEFAULT_EXCLUDED = frozenset(
{
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
47 changes: 46 additions & 1 deletion tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
18 changes: 18 additions & 0 deletions tests/testapp/management/commands/typed_command.py
Original file line number Diff line number Diff line change
@@ -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']}")
Loading