Skip to content

Commit 523ad7d

Browse files
authored
Merge pull request #695 from BrianPugh/n-token
Re-introduce `Paramter.n_tokens` to control number of tokens consumed/provided to custom converters.
2 parents 8691f55 + a2deeac commit 523ad7d

7 files changed

Lines changed: 934 additions & 15 deletions

File tree

cyclopts/_convert.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import collections.abc
2+
import inspect
23
import json
34
import operator
45
import re
@@ -525,7 +526,20 @@ def _convert(
525526

526527
def converter_with_token(t_, value):
527528
assert cparam.converter
528-
return cparam.converter(t_, (value,))
529+
530+
# Resolve string converters to methods on the type
531+
resolved_converter = cparam.converter
532+
if isinstance(resolved_converter, str):
533+
resolved_converter = getattr(t_, resolved_converter)
534+
535+
# Detect bound methods (classmethods/instance methods)
536+
# Bound methods already have their first parameter bound
537+
if inspect.ismethod(resolved_converter):
538+
# Call with just tokens - cls/self already bound
539+
return resolved_converter((value,))
540+
else:
541+
# Regular function - pass type and tokens
542+
return resolved_converter(t_, (value,))
529543

530544
converter = converter_with_token
531545

@@ -813,13 +827,16 @@ def name_transform(s: str) -> str:
813827
raise NotImplementedError("Unreachable?")
814828

815829

816-
def token_count(type_: Any) -> tuple[int, bool]:
830+
def token_count(type_: Any, skip_converter_params: bool = False) -> tuple[int, bool]:
817831
"""The number of tokens after a keyword the parameter should consume.
818832
819833
Parameters
820834
----------
821835
type_: Type
822836
A type hint/annotation to infer token_count from if not explicitly specified.
837+
skip_converter_params: bool
838+
If True, don't extract converter parameters from __cyclopts__.
839+
Used to prevent infinite recursion when determining consume_all behavior.
823840
824841
Returns
825842
-------
@@ -829,7 +846,30 @@ def token_count(type_: Any) -> tuple[int, bool]:
829846
If this is ``True`` and positional, consume all remaining tokens.
830847
The returned number of tokens constitutes a single element of the iterable-to-be-parsed.
831848
"""
832-
type_ = resolve(type_)
849+
# Check for explicit n_tokens in Parameter annotation before resolving
850+
# This handles nested cases like tuple[Annotated[str, Parameter(n_tokens=2)], int]
851+
from cyclopts.parameter import get_parameters
852+
853+
resolved_type, parameters = get_parameters(type_, skip_converter_params=skip_converter_params)
854+
for param in parameters:
855+
if param.n_tokens is not None:
856+
if param.n_tokens == -1:
857+
return 1, True
858+
else:
859+
# Recursively determine consume_all from the type's natural structure.
860+
# Only recurse if the type has changed (e.g., Annotated wrapper was removed).
861+
# If resolved_type is the same as type_, recursing would cause infinite loop.
862+
if resolved_type is not type_:
863+
# Skip converter params to avoid infinite recursion when converter is decorated
864+
# with @Parameter(n_tokens=...) and attached to a class via @Parameter(converter=...).
865+
_, consume_all_from_type = token_count(resolved_type, skip_converter_params=True)
866+
else:
867+
# Type didn't change (e.g., class decorated with @Parameter(n_tokens=...))
868+
# Can't determine natural consume_all by recursing on same type
869+
consume_all_from_type = False
870+
return param.n_tokens, consume_all_from_type
871+
872+
type_ = resolved_type
833873
origin_type = get_origin(type_)
834874
# Normalize abstract origin types to concrete types early
835875
if origin_type in _abstract_to_concrete_type_mapping:

cyclopts/argument/_argument.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Argument class and related functionality."""
22

3+
import inspect
34
import json
45
import operator
56
import sys
@@ -556,10 +557,16 @@ def _convert(self, converter: Callable | None = None):
556557
from cyclopts.argument._collection import update_argument_collection
557558

558559
if self.parameter.converter:
559-
converter = self.parameter.converter
560+
# Resolve string converters to methods on the type
561+
if isinstance(self.parameter.converter, str):
562+
converter = getattr(self.hint, self.parameter.converter)
563+
else:
564+
converter = self.parameter.converter
560565
elif converter is None:
561566
converter = partial(convert, name_transform=self.parameter.name_transform)
562567

568+
assert converter is not None # Ensure converter is set at this point
569+
563570
def safe_converter(hint, tokens):
564571
if isinstance(tokens, dict):
565572
try:
@@ -568,7 +575,13 @@ def safe_converter(hint, tokens):
568575
raise CoercionError(msg=e.args[0] if e.args else None, argument=self, target_type=hint) from e
569576
else:
570577
try:
571-
return converter(hint, tokens)
578+
# Detect bound methods (classmethods/instance methods)
579+
if inspect.ismethod(converter):
580+
# Call with just tokens - cls/self already bound
581+
return converter(tokens) # pyright: ignore[reportCallIssue]
582+
else:
583+
# Regular function - pass type and tokens
584+
return converter(hint, tokens) # pyright: ignore[reportCallIssue]
572585
except (AssertionError, ValueError, TypeError) as e:
573586
token = tokens[0] if len(tokens) == 1 else None
574587
raise CoercionError(
@@ -831,6 +844,27 @@ def token_count(self, keys: tuple[str, ...] = ()):
831844
if self.parameter.count:
832845
return 0, False
833846

847+
# Check for explicit n_tokens override
848+
# This applies to values at any level: root values (keys=()) or nested values (keys=(...))
849+
# For example, **kwargs: Annotated[str, Parameter(n_tokens=2)] means each kwarg value needs 2 tokens
850+
if self.parameter.n_tokens is not None:
851+
if self.parameter.n_tokens == -1:
852+
return 1, True
853+
else:
854+
# Determine consume_all based on the hint at the requested level
855+
# by recursively calling token_count on the hint
856+
if len(keys) > 1:
857+
hint = self._default
858+
elif len(keys) == 1:
859+
hint = self._type_hint_for_key(keys[0])
860+
else:
861+
hint = self.hint
862+
863+
# Recursively call token_count to get the consume_all behavior
864+
# We ignore the token count from the recursive call and use our explicit n_tokens
865+
_, consume_all_from_type = token_count(hint)
866+
return self.parameter.n_tokens, consume_all_from_type
867+
834868
if len(keys) > 1:
835869
hint = self._default
836870
elif len(keys) == 1:

cyclopts/parameter.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
)
3535
from cyclopts.field_info import get_field_infos, signature_parameters
3636
from cyclopts.group import Group
37-
from cyclopts.token import Token
3837
from cyclopts.utils import (
3938
default_name_transform,
4039
frozen,
@@ -117,7 +116,8 @@ def main(foo: Annotated[int, Parameter(name="bar")]):
117116
converter=lambda x: cast(tuple[str, ...], to_tuple_converter(x)),
118117
)
119118

120-
converter: Callable[[type, Sequence[Token]], Any] | None = field(
119+
# Accepts regular converters (type, tokens) -> Any, bound methods (tokens) -> Any, or string references
120+
converter: Callable[..., Any] | str | None = field(
121121
default=None,
122122
kw_only=True,
123123
)
@@ -255,6 +255,11 @@ def main(foo: Annotated[int, Parameter(name="bar")]):
255255
kw_only=True,
256256
)
257257

258+
n_tokens: int | None = field(
259+
default=None,
260+
kw_only=True,
261+
)
262+
258263
# Populated by the record_attrs_init_args decorator.
259264
_provided_args: tuple[str, ...] = field(factory=tuple, init=False, eq=False)
260265

@@ -462,26 +467,71 @@ def validate_command(f: Callable):
462467
)
463468

464469

465-
def get_parameters(hint: T) -> tuple[T, list[Parameter]]:
470+
def get_parameters(hint: T, skip_converter_params: bool = False) -> tuple[T, list[Parameter]]:
466471
"""At root level, checks for cyclopts.Parameter annotations.
467472
468-
Includes checking the ``__cyclopts__`` attribute.
473+
Includes checking the ``__cyclopts__`` attribute on both the type and any converter functions.
474+
475+
Parameters
476+
----------
477+
hint
478+
Type hint to extract parameters from.
479+
skip_converter_params
480+
If True, skip extracting parameters from converter's __cyclopts__.
481+
Used to prevent infinite recursion in token_count.
469482
470483
Returns
471484
-------
472485
hint
473486
Annotation hint with :obj:`Annotated` and :obj:`Optional` resolved.
474487
list[Parameter]
475-
List of parameters discovered.
488+
List of parameters discovered, ordered by priority (lowest to highest):
489+
converter-decoration < type-decoration < annotation.
476490
"""
477-
parameters = []
478491
hint = resolve_optional(hint)
492+
493+
# Extract parameters from type's __cyclopts__ attribute
494+
type_cyclopts_config_params = []
479495
if cyclopts_config := getattr(hint, "__cyclopts__", None):
480-
parameters.extend(cyclopts_config.parameters)
496+
type_cyclopts_config_params.extend(cyclopts_config.parameters)
497+
498+
# Extract parameters from Annotated metadata
499+
annotated_params = []
481500
if is_annotated(hint):
482501
inner = get_args(hint)
483502
hint = inner[0]
484-
parameters.extend(x for x in inner[1:] if isinstance(x, Parameter))
503+
annotated_params.extend(x for x in inner[1:] if isinstance(x, Parameter))
504+
505+
# Check if any parameter has a converter with __cyclopts__ and extract its parameters
506+
converter_params = []
507+
if not skip_converter_params:
508+
for param in annotated_params + type_cyclopts_config_params:
509+
if param.converter:
510+
converter = param.converter
511+
512+
# Resolve string converters to methods on the type
513+
if isinstance(converter, str):
514+
converter = getattr(hint, converter)
515+
516+
# Check for __cyclopts__ on the converter
517+
if hasattr(converter, "__cyclopts__"):
518+
converter_params.extend(converter.__cyclopts__.parameters)
519+
break
520+
# For bound methods from classmethods/staticmethods, access the descriptor via __self__
521+
elif (
522+
hasattr(converter, "__self__")
523+
and hasattr(converter, "__name__")
524+
and hasattr(converter.__self__, "__dict__")
525+
):
526+
# Get the descriptor from the class's __dict__
527+
descriptor = converter.__self__.__dict__.get(converter.__name__)
528+
if descriptor and hasattr(descriptor, "__cyclopts__"):
529+
converter_params.extend(descriptor.__cyclopts__.parameters)
530+
break
531+
532+
# Return parameters in priority order (lowest to highest)
533+
# This allows Parameter.combine() to correctly prioritize later parameters
534+
parameters = converter_params + type_cyclopts_config_params + annotated_params
485535

486536
return hint, parameters
487537

docs/source/api.rst

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,9 +917,45 @@ API
917917
}
918918
919919
If not provided, defaults to Cyclopts's internal coercion engine.
920-
If a pydantic type-hint is provided, Cyclopts will disable it's internal coercion
920+
If a pydantic type-hint is provided, Cyclopts will disable its internal coercion
921921
engine (including this `converter` argument) and leave the coercion to pydantic.
922922

923+
The number of tokens passed to the converter is inferred from the type hint by default,
924+
but can be explicitly controlled with :attr:`~.Parameter.n_tokens`. This is useful when
925+
the type signature doesn't match the desired CLI token consumption. When loading complex
926+
objects with multiple fields, it may also be useful to combine with :attr:`~.Parameter.accepts_keys`.
927+
928+
**Decorating Converters:** Converter functions can be decorated with :class:`.Parameter` to define
929+
reusable conversion behavior:
930+
931+
.. code-block:: python
932+
933+
@Parameter(n_tokens=1, accepts_keys=False)
934+
def load_from_id(type_, tokens):
935+
"""Load object from database by ID."""
936+
return fetch_from_db(tokens[0].value)
937+
938+
@app.default
939+
def main(obj: Annotated[MyType, Parameter(converter=load_from_id)]):
940+
# Automatically inherits n_tokens=1 and accepts_keys=False
941+
pass
942+
943+
**Classmethod Support:** Converters can be classmethods. Use string references for class decoration
944+
or direct references in annotations. Classmethod signature should be ``(cls, tokens)`` instead of
945+
``(type_, tokens)``:
946+
947+
.. code-block:: python
948+
949+
@Parameter(converter="from_env")
950+
class Config:
951+
@Parameter(n_tokens=1, accepts_keys=False)
952+
@classmethod
953+
def from_env(cls, tokens):
954+
env = tokens[0].value
955+
configs = {"dev": ("localhost", 8080), "prod": ("api.example.com", 443)}
956+
return cls(*configs[env])
957+
958+
923959
.. attribute:: validator
924960
:type: Union[None, Callable, Iterable[Callable]]
925961
:value: None
@@ -1225,6 +1261,9 @@ API
12251261
$ my-program --image foo.jpg nature
12261262
image=Image(path='foo.jpg', label='nature')
12271263
1264+
The ``accepts_keys=False`` option is commonly used with :attr:`~.Parameter.converter` and
1265+
:attr:`~.Parameter.n_tokens`.
1266+
12281267
.. attribute:: consume_multiple
12291268
:type: Optional[bool]
12301269
:value: None
@@ -1355,6 +1394,63 @@ API
13551394
13561395
See :ref:`Coercion Rules` for more details.
13571396

1397+
.. attribute:: n_tokens
1398+
:type: Optional[int]
1399+
:value: None
1400+
1401+
Explicitly override the number of CLI tokens this parameter consumes.
1402+
1403+
By default, Cyclopts infers the token count from the parameter's type hint
1404+
(e.g., :obj:`int` consumes 1 token, ``tuple[int, int]`` consumes 2, :obj:`list` consumes all remaining).
1405+
This attribute allows you to override that inference, which is particularly useful when:
1406+
1407+
* Using custom converters that need a different token count than the type suggests.
1408+
* Loading complex types from a single token (e.g., loading from a file path).
1409+
* Implementing selection/lookup patterns where one token identifies an object.
1410+
1411+
Values:
1412+
1413+
* ``None`` (default): Infer token count from the type hint.
1414+
* non-negative integer: Consume exactly that many tokens.
1415+
* ``-1``: Consume all remaining tokens (similar to iterables).
1416+
1417+
For ``*args`` parameters, ``n_tokens`` specifies tokens **per element**.
1418+
For example, ``n_tokens=2`` with 6 tokens creates 3 elements.
1419+
1420+
.. code-block:: python
1421+
1422+
from cyclopts import App, Parameter
1423+
from typing import Annotated
1424+
1425+
class Config:
1426+
def __init__(self, host: str, port: int):
1427+
self.host = host
1428+
self.port = port
1429+
1430+
def load_config(type_, tokens):
1431+
# Load config from a file path (single token)
1432+
filepath = tokens[0].value
1433+
# ... load from file ...
1434+
return Config("example.com", 8080)
1435+
1436+
app = App()
1437+
1438+
@app.default
1439+
def main(
1440+
config: Annotated[
1441+
Config,
1442+
Parameter(n_tokens=1, converter=load_config, accepts_keys=False)
1443+
]
1444+
):
1445+
print(f"Connecting to {config.host}:{config.port}")
1446+
1447+
app()
1448+
1449+
.. code-block:: console
1450+
1451+
$ my-script --config prod.conf
1452+
Connecting to example.com:8080
1453+
13581454
.. automethod:: combine
13591455

13601456
.. automethod:: default

0 commit comments

Comments
 (0)