pyflowstep 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyflowstep
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON.
5
5
  Keywords: flow,pipeline,composition,functional,fluent-interface,json,json-schema,workflow,dsl
6
6
  Author: aaltatan
@@ -32,6 +32,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
32
32
  - step arguments are validated and parsed when a flow is **built**, not halfway through running it
33
33
  - raw JSON values become typed arguments with a `Parse` marker next to the parameter
34
34
  - steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
35
+ - the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: User = Input()`
35
36
  - registry-based registration keeps steps organized and discoverable
36
37
  - flow definitions can be compiled from dictionaries or JSON
37
38
  - every registry can describe its flow language as a JSON Schema
@@ -181,7 +182,7 @@ pipeline # Flow(add >> multiply >> add)
181
182
  pipeline(1) # 31
182
183
  ```
183
184
 
184
- `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Two markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, and [`Depends`](#dependencies) to inject an object nobody passes.
185
+ `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Three markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, [`Depends`](#dependencies) to inject an object nobody passes, and [`Input`](#run-inputs) to receive a value from whoever runs the flow.
185
186
 
186
187
  Arguments are bound against the function signature **immediately**, so mistakes fail fast:
187
188
 
@@ -442,6 +443,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
442
443
  def send_invoice(order: Order, mailer: MailerDep) -> None: ...
443
444
  ```
444
445
 
446
+ Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
447
+
445
448
  ### One object per flow run
446
449
 
447
450
  A flow run is one scope, like one request in a web framework. A provider is called **at most once per run**, and every step of that run receives the same object. The next run starts fresh.
@@ -532,6 +535,89 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
532
535
 
533
536
  ---
534
537
 
538
+ ## Run inputs
539
+
540
+ Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
541
+
542
+ ```python
543
+ from pyflowstep import Input, StepsRegistry
544
+
545
+ steps = StepsRegistry[Page]()
546
+
547
+
548
+ @steps.tap()
549
+ def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
550
+ page.navigate(url)
551
+ page.fill("#user", credentials.username)
552
+ page.fill("#password", credentials.password)
553
+
554
+
555
+ @steps.tap()
556
+ def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
557
+ page.fill(selector, voucher.year)
558
+
559
+
560
+ flow = login("https://example.com/login") >> fill_year("#year") # inputs are not arguments
561
+
562
+ flow(page, credentials=credentials, voucher=voucher) # they are given to the run
563
+ ```
564
+
565
+ ```json
566
+ [
567
+ {"name": "login", "args": ["https://example.com/login"]},
568
+ {"name": "fill_year", "args": ["#year"]}
569
+ ]
570
+ ```
571
+
572
+ The name of the input is the name of the parameter, and every step that declares it receives the same value.
573
+
574
+ `Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
575
+
576
+ ### Checked before anything runs
577
+
578
+ A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
579
+
580
+ ```python
581
+ flow.inputs # frozenset({'credentials', 'voucher'})
582
+
583
+ flow(page, credentials=credentials)
584
+ # MissingInputError: missing run input 'voucher' for step 'fill_year'; run the flow as flow(subject, voucher=...)
585
+ ```
586
+
587
+ ### Rules
588
+
589
+ - **Invisible to JSON**, like a dependency: an input is absent from the JSON schema, cannot be parsed, and passing one while building the flow raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`).
590
+ - **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
591
+ - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
592
+ - **Nested flows see the inputs of the run they join.** A flow called from inside a step can be given inputs of its own, `inner(page, voucher=other)`; they are laid over the outer ones for that call only.
593
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
594
+
595
+ Using ruff? Add `Input` next to `Depends` for its `B008` rule:
596
+
597
+ ```toml
598
+ [tool.ruff.lint.flake8-bugbear]
599
+ extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
600
+ ```
601
+
602
+ ### Upgrading from 0.3
603
+
604
+ `Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
605
+
606
+ | 0.3 | 0.4 |
607
+ | ---------------------------------- | ---------------------------------------- |
608
+ | `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
609
+ | `note: Input[str] = ""` | `note: str = Input(default="")` |
610
+
611
+ ### `Input` or `Depends`?
612
+
613
+ | The object… | Use |
614
+ | ------------------------------------------------------------ | ------------------------------------- |
615
+ | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
616
+ | can be built by a function, the same way for every caller (a mailer, a session) | `Depends(provider)` |
617
+ | is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
618
+
619
+ ---
620
+
535
621
  ## Compiling flows from JSON
536
622
 
537
623
  `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
@@ -738,13 +824,15 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
738
824
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
739
825
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
740
826
  | `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
827
+ | `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
828
+ | `Flow.inputs` | property | The names of the inputs a flow requires |
741
829
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
742
830
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
743
831
  | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
744
832
  | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
745
833
  | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
746
834
 
747
- All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError` and `InvalidDependencyError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
835
+ All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError`, `InvalidDependencyError`, `InvalidInputError` and `MissingInputError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
748
836
 
749
837
  ---
750
838
 
@@ -9,6 +9,7 @@ A lightweight, typed Python library for composing functions into readable, reusa
9
9
  - step arguments are validated and parsed when a flow is **built**, not halfway through running it
10
10
  - raw JSON values become typed arguments with a `Parse` marker next to the parameter
11
11
  - steps get external objects (a mailer, a database session) through FastAPI-style `Depends`
12
+ - the caller hands per-run values to a flow by name: `flow(page, user=user)` fills every `user: User = Input()`
12
13
  - registry-based registration keeps steps organized and discoverable
13
14
  - flow definitions can be compiled from dictionaries or JSON
14
15
  - every registry can describe its flow language as a JSON Schema
@@ -158,7 +159,7 @@ pipeline # Flow(add >> multiply >> add)
158
159
  pipeline(1) # 31
159
160
  ```
160
161
 
161
- `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Two markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, and [`Depends`](#dependencies) to inject an object nobody passes.
162
+ `step` and `tap` do one thing: turn a function into a flow factory. Naming a step belongs to the [registry](#registry). Three markers can sit on a parameter: [`Parse`](#parsing-arguments) to convert the value passed for it, [`Depends`](#dependencies) to inject an object nobody passes, and [`Input`](#run-inputs) to receive a value from whoever runs the flow.
162
163
 
163
164
  Arguments are bound against the function signature **immediately**, so mistakes fail fast:
164
165
 
@@ -419,6 +420,8 @@ def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
419
420
  def send_invoice(order: Order, mailer: MailerDep) -> None: ...
420
421
  ```
421
422
 
423
+ Both forms behave the same when the flow runs. Type checkers differ: with the `Annotated` form they still see `mailer` as a required argument and report `send_email("receipt")` as missing it. Use the default-value form for steps you call from Python; the `Annotated` form suits steps that are only used from JSON.
424
+
422
425
  ### One object per flow run
423
426
 
424
427
  A flow run is one scope, like one request in a web framework. A provider is called **at most once per run**, and every step of that run receives the same object. The next run starts fresh.
@@ -509,6 +512,89 @@ See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow w
509
512
 
510
513
  ---
511
514
 
515
+ ## Run inputs
516
+
517
+ Some values exist only where the flow is run: the logged-in user, the record being processed, credentials read from a prompt. No provider can build them and JSON must not hold them. Give the parameter the default `Input()` and the caller supplies it, by name, when it runs the flow:
518
+
519
+ ```python
520
+ from pyflowstep import Input, StepsRegistry
521
+
522
+ steps = StepsRegistry[Page]()
523
+
524
+
525
+ @steps.tap()
526
+ def login(page: Page, url: str, credentials: Credentials = Input()) -> None:
527
+ page.navigate(url)
528
+ page.fill("#user", credentials.username)
529
+ page.fill("#password", credentials.password)
530
+
531
+
532
+ @steps.tap()
533
+ def fill_year(page: Page, selector: str, voucher: Voucher = Input()) -> None:
534
+ page.fill(selector, voucher.year)
535
+
536
+
537
+ flow = login("https://example.com/login") >> fill_year("#year") # inputs are not arguments
538
+
539
+ flow(page, credentials=credentials, voucher=voucher) # they are given to the run
540
+ ```
541
+
542
+ ```json
543
+ [
544
+ {"name": "login", "args": ["https://example.com/login"]},
545
+ {"name": "fill_year", "args": ["#year"]}
546
+ ]
547
+ ```
548
+
549
+ The name of the input is the name of the parameter, and every step that declares it receives the same value.
550
+
551
+ `Input()` is written as the default value, never inside `Annotated`, so that type checkers see the parameter as optional and accept `fill_year("#year")`.
552
+
553
+ ### Checked before anything runs
554
+
555
+ A flow knows the inputs its steps require, and checks them before the first step runs. A forgotten input never fails halfway through:
556
+
557
+ ```python
558
+ flow.inputs # frozenset({'credentials', 'voucher'})
559
+
560
+ flow(page, credentials=credentials)
561
+ # MissingInputError: missing run input 'voucher' for step 'fill_year'; run the flow as flow(subject, voucher=...)
562
+ ```
563
+
564
+ ### Rules
565
+
566
+ - **Invisible to JSON**, like a dependency: an input is absent from the JSON schema, cannot be parsed, and passing one while building the flow raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`).
567
+ - **`Input(default=...)` makes the input optional**: with `note: str = Input(default="")`, `""` is used when the caller passes no `note`. Optional inputs are not listed in `flow.inputs`.
568
+ - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
569
+ - **Nested flows see the inputs of the run they join.** A flow called from inside a step can be given inputs of its own, `inner(page, voucher=other)`; they are laid over the outer ones for that call only.
570
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, on a parameter that is also a dependency, or written inside `Annotated`. A provider cannot take an input.
571
+
572
+ Using ruff? Add `Input` next to `Depends` for its `B008` rule:
573
+
574
+ ```toml
575
+ [tool.ruff.lint.flake8-bugbear]
576
+ extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.Input"]
577
+ ```
578
+
579
+ ### Upgrading from 0.3
580
+
581
+ `Input[T]` is gone, because a type checker reported `fill_year("#year")` as missing its `voucher` argument. Move the marker to the default value:
582
+
583
+ | 0.3 | 0.4 |
584
+ | ---------------------------------- | ---------------------------------------- |
585
+ | `voucher: Input[Voucher]` | `voucher: Voucher = Input()` |
586
+ | `note: Input[str] = ""` | `note: str = Input(default="")` |
587
+
588
+ ### `Input` or `Depends`?
589
+
590
+ | The object… | Use |
591
+ | ------------------------------------------------------------ | ------------------------------------- |
592
+ | is written in the flow definition (a selector, a limit) | a plain argument, with `Parse` if needed |
593
+ | can be built by a function, the same way for every caller (a mailer, a session) | `Depends(provider)` |
594
+ | is only known to whoever runs the flow (a user, a record, credentials) | `Input()` |
595
+
596
+ ---
597
+
512
598
  ## Compiling flows from JSON
513
599
 
514
600
  `FlowCompiler` turns a flow definition — a list of step dictionaries — into a `Flow`.
@@ -715,13 +801,15 @@ checkout = note_if(is_large(Decimal(100)), "needs approval") >> apply_tax(round(
715
801
  | `Parse(fn)` | marker | Apply `fn` to the value passed for a parameter, see [Parsing arguments](#parsing-arguments) |
716
802
  | `Depends(provider)` | marker | Inject a parameter by calling `provider`, see [Dependencies](#dependencies) |
717
803
  | `override_dependencies(mapping)` | context manager | Replace providers inside a `with` block, for tests |
804
+ | `Input(default=...)` | marker | Receive a parameter from the caller of the flow, `flow(subject, name=value)`, see [Run inputs](#run-inputs) |
805
+ | `Flow.inputs` | property | The names of the inputs a flow requires |
718
806
  | `FlowCompiler[T](steps)` | class | `compile(definition)` turns parsed step dicts into a flow |
719
807
  | `validate_step_dict(item, path)` | function | Validate one step dictionary |
720
808
  | `get_json_schema(annotation)` | function | JSON Schema of a type annotation |
721
809
  | `get_step_json_schema(name, step)` | function | JSON Schema of one step dictionary |
722
810
  | `get_flow_json_schema(steps)` | function | JSON Schema of a whole flow definition |
723
811
 
724
- All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError` and `InvalidDependencyError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
812
+ All exceptions derive from `PyflowstepError`; argument errors, `InvalidParserError`, `InvalidDependencyError`, `InvalidInputError` and `MissingInputError` also derive from `TypeError`, definition errors from `ValueError`, and `StepDoesNotExistError` from `LookupError`.
725
813
 
726
814
  ---
727
815
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-only"
@@ -101,6 +101,8 @@ ignore = [
101
101
  extend-immutable-calls = [
102
102
  "pyflowstep.Depends",
103
103
  "pyflowstep.dependencies.Depends",
104
+ "pyflowstep.Input",
105
+ "pyflowstep.inputs.Input",
104
106
  ]
105
107
 
106
108
  [tool.ruff.lint.per-file-ignores]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "A lightweight, typed Python library for composing functions into readable, reusable flows that can be defined as JSON."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -80,8 +80,13 @@ ignore = [
80
80
  ]
81
81
 
82
82
  [tool.ruff.lint.flake8-bugbear]
83
- # `x: T = Depends(provider)` is a marker, not a mutable default
84
- extend-immutable-calls = ["pyflowstep.Depends", "pyflowstep.dependencies.Depends"]
83
+ # `x: T = Depends(provider)` and `x: T = Input()` are markers, not mutable defaults
84
+ extend-immutable-calls = [
85
+ "pyflowstep.Depends",
86
+ "pyflowstep.dependencies.Depends",
87
+ "pyflowstep.Input",
88
+ "pyflowstep.inputs.Input",
89
+ ]
85
90
 
86
91
  [tool.ruff.lint.per-file-ignores]
87
92
  "tests/**" = ["S101", "PLR2004", "ANN", "ARG", "INP001", "E501"]
@@ -27,10 +27,12 @@ from .exceptions import (
27
27
  ArgumentError,
28
28
  InvalidDependencyError,
29
29
  InvalidFlowDefinitionError,
30
+ InvalidInputError,
30
31
  InvalidParserError,
31
32
  InvalidStepError,
32
33
  InvalidStepNameError,
33
34
  MissingArgumentError,
35
+ MissingInputError,
34
36
  MultipleValuesArgumentError,
35
37
  ParseArgumentError,
36
38
  PositionalOnlyArgumentError,
@@ -41,6 +43,7 @@ from .exceptions import (
41
43
  UnexpectedKeywordArgumentError,
42
44
  )
43
45
  from .flow import Action, Flow, compose
46
+ from .inputs import Input
44
47
  from .json_schema import get_flow_json_schema, get_json_schema, get_step_json_schema
45
48
  from .parsers import Parse
46
49
  from .registry import StepsRegistry
@@ -53,12 +56,15 @@ __all__ = [
53
56
  "Flow",
54
57
  "FlowCompiler",
55
58
  "FlowDefinition",
59
+ "Input",
56
60
  "InvalidDependencyError",
57
61
  "InvalidFlowDefinitionError",
62
+ "InvalidInputError",
58
63
  "InvalidParserError",
59
64
  "InvalidStepError",
60
65
  "InvalidStepNameError",
61
66
  "MissingArgumentError",
67
+ "MissingInputError",
62
68
  "MultipleValuesArgumentError",
63
69
  "Parse",
64
70
  "ParseArgumentError",
@@ -1,59 +1,90 @@
1
- """Read the annotations that carry markers such as `Parse` and `Depends`."""
2
-
3
- from collections.abc import Callable
4
- from functools import partial
5
- from inspect import get_annotations, isroutine
6
- from typing import Annotated, Any, TypeAliasType, get_args, get_origin
7
-
8
-
9
- def resolved_annotations(fn: Callable[..., Any]) -> dict[str, Any]:
10
- """Return the annotations describing a call to `fn`, with strings evaluated when possible.
11
-
12
- For a class this is its `__init__`, for a callable object its `__call__`,
13
- and for a `functools.partial` the function it wraps.
14
-
15
- Example:
16
- ```python
17
- >>> def wait(page, selector: "str", timeout: float = 5.0): ...
18
- >>> resolved_annotations(wait)
19
- {'selector': <class 'str'>, 'timeout': <class 'float'>}
20
-
21
- ```
22
-
23
- """
24
- target = _annotated_function(fn)
25
-
26
- try:
27
- return get_annotations(target, eval_str=True)
28
- except NameError:
29
- return get_annotations(target)
30
-
31
-
32
- def annotated_metadata(annotation: Any) -> tuple[Any, ...]:
33
- """Return the extra arguments of `Annotated[...]`, looking through `type` aliases.
34
-
35
- Example:
36
- ```python
37
- >>> type Port = Annotated[int, "tcp", 8080]
38
- >>> annotated_metadata(Port)
39
- ('tcp', 8080)
40
- >>> annotated_metadata(int)
41
- ()
42
-
43
- ```
44
-
45
- """
46
- while isinstance(annotation, TypeAliasType):
47
- annotation = annotation.__value__
48
-
49
- return get_args(annotation)[1:] if get_origin(annotation) is Annotated else ()
50
-
51
-
52
- def _annotated_function(fn: Callable[..., Any]) -> Callable[..., Any]:
53
- if isinstance(fn, partial):
54
- return _annotated_function(fn.func)
55
-
56
- if isinstance(fn, type):
57
- return fn.__init__
58
-
59
- return fn if isroutine(fn) else type(fn).__call__
1
+ """Read the annotations that carry markers such as `Parse` and `Depends`."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import partial
5
+ from inspect import get_annotations, isroutine
6
+ from typing import Annotated, Any, TypeAliasType, get_args, get_origin
7
+
8
+
9
+ def resolved_annotations(fn: Callable[..., Any]) -> dict[str, Any]:
10
+ """Return the annotations describing a call to `fn`, with strings evaluated when possible.
11
+
12
+ For a class this is its `__init__`, for a callable object its `__call__`,
13
+ and for a `functools.partial` the function it wraps.
14
+
15
+ Example:
16
+ ```python
17
+ >>> def wait(page, selector: "str", timeout: float = 5.0): ...
18
+ >>> resolved_annotations(wait)
19
+ {'selector': <class 'str'>, 'timeout': <class 'float'>}
20
+
21
+ ```
22
+
23
+ """
24
+ target = _annotated_function(fn)
25
+
26
+ try:
27
+ return get_annotations(target, eval_str=True)
28
+ except NameError:
29
+ return get_annotations(target)
30
+
31
+
32
+ def annotated_metadata(annotation: Any) -> tuple[Any, ...]:
33
+ """Return the extra arguments of `Annotated[...]`, looking through `type` aliases.
34
+
35
+ Example:
36
+ ```python
37
+ >>> type Port = Annotated[int, "tcp", 8080]
38
+ >>> annotated_metadata(Port)
39
+ ('tcp', 8080)
40
+ >>> annotated_metadata(int)
41
+ ()
42
+ >>> type Tagged[T] = Annotated[T, "tag"]
43
+ >>> annotated_metadata(Tagged[int])
44
+ ('tag',)
45
+ >>> annotated_metadata(Annotated[Tagged[Port], "outer"])
46
+ ('tcp', 8080, 'tag', 'outer')
47
+ >>> type Fixed[T] = Annotated[int, "fixed"]
48
+ >>> annotated_metadata(Fixed[str])
49
+ ('fixed',)
50
+
51
+ ```
52
+
53
+ """
54
+ annotation = _alias_value(annotation)
55
+
56
+ if get_origin(annotation) is not Annotated:
57
+ return ()
58
+
59
+ inner, *metadata = get_args(annotation)
60
+ return (*annotated_metadata(inner), *metadata)
61
+
62
+
63
+ def _alias_value(annotation: Any) -> Any:
64
+ """Return what a `type` alias stands for, also when it is subscripted (`Alias[int]`)."""
65
+ while True:
66
+ origin = get_origin(annotation)
67
+
68
+ if isinstance(annotation, TypeAliasType):
69
+ annotation = annotation.__value__
70
+ elif isinstance(origin, TypeAliasType):
71
+ annotation = _substituted(origin.__value__, get_args(annotation))
72
+ else:
73
+ return annotation
74
+
75
+
76
+ def _substituted(value: Any, arguments: tuple[Any, ...]) -> Any:
77
+ try:
78
+ return value[arguments]
79
+ except TypeError: # the alias does not use its type parameters
80
+ return value
81
+
82
+
83
+ def _annotated_function(fn: Callable[..., Any]) -> Callable[..., Any]:
84
+ if isinstance(fn, partial):
85
+ return _annotated_function(fn.func)
86
+
87
+ if isinstance(fn, type):
88
+ return fn.__init__
89
+
90
+ return fn if isroutine(fn) else type(fn).__call__