pyflowstep 0.4.0__tar.gz → 0.5.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.4.0
3
+ Version: 0.5.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
@@ -427,23 +427,23 @@ send_email("receipt") # only `template` is an argument
427
427
 
428
428
  It works the same with the plain `@step` and `@tap` decorators; no registry is required.
429
429
 
430
- ### Two ways to write it
430
+ ### Always the default value
431
431
 
432
- ```python
433
- from typing import Annotated
432
+ `Depends(...)` is written as the default value of the parameter, in steps and in providers. To name a dependency once and reuse it in many steps, keep the marker in a constant:
434
433
 
435
- # default value: quick, for a one-off
434
+ ```python
435
+ # inline: quick, for a one-off
436
436
  def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
437
437
 
438
438
 
439
- # Annotated: name the dependency once, reuse it in many steps
440
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
439
+ # a constant: name the dependency once, reuse it
440
+ MAILER = Depends(get_mailer)
441
441
 
442
- def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
443
- def send_invoice(order: Order, mailer: MailerDep) -> None: ...
442
+ def send_email(order: Order, template: str, mailer: Mailer = MAILER) -> None: ...
443
+ def send_invoice(order: Order, mailer: Mailer = MAILER) -> None: ...
444
444
  ```
445
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.
446
+ It is never written inside `Annotated`. Only a default value tells a type checker that the parameter is not passed, so `send_email("receipt")` type-checks; `Annotated[Mailer, Depends(get_mailer)]` raises `InvalidDependencyError` when the step is created.
447
447
 
448
448
  ### One object per flow run
449
449
 
@@ -468,16 +468,16 @@ def get_session() -> Iterator[Session]:
468
468
  session.close()
469
469
 
470
470
 
471
- type SessionDep = Annotated[Session, Depends(get_session)]
471
+ SESSION = Depends(get_session)
472
472
 
473
473
 
474
474
  @steps.tap()
475
- def save(order: Order, session: SessionDep) -> None:
475
+ def save(order: Order, session: Session = SESSION) -> None:
476
476
  session.add(order)
477
477
 
478
478
 
479
479
  @steps.tap()
480
- def audit(order: Order, action: str, session: SessionDep) -> None:
480
+ def audit(order: Order, action: str, session: Session = SESSION) -> None:
481
481
  session.add(AuditRow(order.id, action)) # the same session `save` used
482
482
 
483
483
 
@@ -520,7 +520,7 @@ Keys are the original providers and values the providers to call instead. Nested
520
520
  ### Rules
521
521
 
522
522
  - **Invisible to JSON.** A dependency is absent from the JSON schema, cannot be parsed, and passing one raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`) when the flow is built.
523
- - **Declarations are checked early**, when the step is created, with `InvalidDependencyError`: a provider that is not callable, is circular, or has a required parameter that is not a dependency; a dependency on the subject, or on a positional-only, `*args` or `**kwargs` parameter.
523
+ - **Declarations are checked early**, when the step is created, with `InvalidDependencyError`: a provider that is not callable, is circular, or has a required parameter that is not a dependency; a dependency on the subject or on a positional-only parameter; `Depends` written inside `Annotated`.
524
524
  - **Providers run late**, when the flow runs. An error inside a provider surfaces then, not at compile time.
525
525
  - A dependency is for an object the JSON must **not** choose. When the JSON should pick one by name (`"via": "email"`), use [`Parse`](#parsing-arguments) with a function that turns the name into the object.
526
526
 
@@ -533,6 +533,16 @@ extend-immutable-calls = ["pyflowstep.Depends"]
533
533
 
534
534
  See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow with a mailer, a per-run session and a test override.
535
535
 
536
+ ### Upgrading from 0.4
537
+
538
+ `Annotated[T, Depends(provider)]` is gone, for the same reason `Input[T]` went in 0.4: a type checker reported `send_email("receipt")` as missing its `mailer` argument. Move the marker to the default value, in steps and in providers:
539
+
540
+ | 0.4 | 0.5 |
541
+ | --------------------------------------------------------- | -------------------------------------------- |
542
+ | `mailer: Annotated[Mailer, Depends(get_mailer)]` | `mailer: Mailer = Depends(get_mailer)` |
543
+ | `type MailerDep = Annotated[Mailer, Depends(get_mailer)]` | `MAILER = Depends(get_mailer)` |
544
+ | `mailer: MailerDep` | `mailer: Mailer = MAILER` |
545
+
536
546
  ---
537
547
 
538
548
  ## Run inputs
@@ -590,7 +600,7 @@ flow(page, credentials=credentials)
590
600
  - **`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
601
  - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
592
602
  - **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.
603
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, or written inside `Annotated`. A provider cannot take an input.
594
604
 
595
605
  Using ruff? Add `Input` next to `Depends` for its `B008` rule:
596
606
 
@@ -765,7 +775,7 @@ Runnable examples live in [`examples/`](examples):
765
775
  def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
766
776
 
767
777
  @fulfilment_steps.tap()
768
- def save(order: Order, session: SessionDep) -> None: ...
778
+ def save(order: Order, session: Session = SESSION) -> None: ...
769
779
  ```
770
780
 
771
781
  ### Working with pyspecification and pyformula
@@ -404,23 +404,23 @@ send_email("receipt") # only `template` is an argument
404
404
 
405
405
  It works the same with the plain `@step` and `@tap` decorators; no registry is required.
406
406
 
407
- ### Two ways to write it
407
+ ### Always the default value
408
408
 
409
- ```python
410
- from typing import Annotated
409
+ `Depends(...)` is written as the default value of the parameter, in steps and in providers. To name a dependency once and reuse it in many steps, keep the marker in a constant:
411
410
 
412
- # default value: quick, for a one-off
411
+ ```python
412
+ # inline: quick, for a one-off
413
413
  def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
414
414
 
415
415
 
416
- # Annotated: name the dependency once, reuse it in many steps
417
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
416
+ # a constant: name the dependency once, reuse it
417
+ MAILER = Depends(get_mailer)
418
418
 
419
- def send_email(order: Order, template: str, mailer: MailerDep) -> None: ...
420
- def send_invoice(order: Order, mailer: MailerDep) -> None: ...
419
+ def send_email(order: Order, template: str, mailer: Mailer = MAILER) -> None: ...
420
+ def send_invoice(order: Order, mailer: Mailer = MAILER) -> None: ...
421
421
  ```
422
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.
423
+ It is never written inside `Annotated`. Only a default value tells a type checker that the parameter is not passed, so `send_email("receipt")` type-checks; `Annotated[Mailer, Depends(get_mailer)]` raises `InvalidDependencyError` when the step is created.
424
424
 
425
425
  ### One object per flow run
426
426
 
@@ -445,16 +445,16 @@ def get_session() -> Iterator[Session]:
445
445
  session.close()
446
446
 
447
447
 
448
- type SessionDep = Annotated[Session, Depends(get_session)]
448
+ SESSION = Depends(get_session)
449
449
 
450
450
 
451
451
  @steps.tap()
452
- def save(order: Order, session: SessionDep) -> None:
452
+ def save(order: Order, session: Session = SESSION) -> None:
453
453
  session.add(order)
454
454
 
455
455
 
456
456
  @steps.tap()
457
- def audit(order: Order, action: str, session: SessionDep) -> None:
457
+ def audit(order: Order, action: str, session: Session = SESSION) -> None:
458
458
  session.add(AuditRow(order.id, action)) # the same session `save` used
459
459
 
460
460
 
@@ -497,7 +497,7 @@ Keys are the original providers and values the providers to call instead. Nested
497
497
  ### Rules
498
498
 
499
499
  - **Invisible to JSON.** A dependency is absent from the JSON schema, cannot be parsed, and passing one raises `UnexpectedKeywordArgumentError` (or `TooManyArgumentsError`) when the flow is built.
500
- - **Declarations are checked early**, when the step is created, with `InvalidDependencyError`: a provider that is not callable, is circular, or has a required parameter that is not a dependency; a dependency on the subject, or on a positional-only, `*args` or `**kwargs` parameter.
500
+ - **Declarations are checked early**, when the step is created, with `InvalidDependencyError`: a provider that is not callable, is circular, or has a required parameter that is not a dependency; a dependency on the subject or on a positional-only parameter; `Depends` written inside `Annotated`.
501
501
  - **Providers run late**, when the flow runs. An error inside a provider surfaces then, not at compile time.
502
502
  - A dependency is for an object the JSON must **not** choose. When the JSON should pick one by name (`"via": "email"`), use [`Parse`](#parsing-arguments) with a function that turns the name into the object.
503
503
 
@@ -510,6 +510,16 @@ extend-immutable-calls = ["pyflowstep.Depends"]
510
510
 
511
511
  See [`examples/dependencies.py`](examples/dependencies.py) for a complete flow with a mailer, a per-run session and a test override.
512
512
 
513
+ ### Upgrading from 0.4
514
+
515
+ `Annotated[T, Depends(provider)]` is gone, for the same reason `Input[T]` went in 0.4: a type checker reported `send_email("receipt")` as missing its `mailer` argument. Move the marker to the default value, in steps and in providers:
516
+
517
+ | 0.4 | 0.5 |
518
+ | --------------------------------------------------------- | -------------------------------------------- |
519
+ | `mailer: Annotated[Mailer, Depends(get_mailer)]` | `mailer: Mailer = Depends(get_mailer)` |
520
+ | `type MailerDep = Annotated[Mailer, Depends(get_mailer)]` | `MAILER = Depends(get_mailer)` |
521
+ | `mailer: MailerDep` | `mailer: Mailer = MAILER` |
522
+
513
523
  ---
514
524
 
515
525
  ## Run inputs
@@ -567,7 +577,7 @@ flow(page, credentials=credentials)
567
577
  - **`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
578
  - **Extra inputs are ignored**, so one caller can run different flows, each using the inputs it needs.
569
579
  - **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.
580
+ - **Declarations are checked early**, when the step is created, with `InvalidInputError`: an input on the subject, on a positional-only parameter, or written inside `Annotated`. A provider cannot take an input.
571
581
 
572
582
  Using ruff? Add `Input` next to `Depends` for its `B008` rule:
573
583
 
@@ -742,7 +752,7 @@ Runnable examples live in [`examples/`](examples):
742
752
  def send_email(order: Order, template: str, mailer: Mailer = Depends(get_mailer)) -> None: ...
743
753
 
744
754
  @fulfilment_steps.tap()
745
- def save(order: Order, session: SessionDep) -> None: ...
755
+ def save(order: Order, session: Session = SESSION) -> None: ...
746
756
  ```
747
757
 
748
758
  ### Working with pyspecification and pyformula
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.4.0"
3
+ version = "0.5.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyflowstep"
3
- version = "0.4.0"
3
+ version = "0.5.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 = [
@@ -39,8 +39,6 @@ from .inputs import RunInput
39
39
 
40
40
  type Provider = Callable[..., Any]
41
41
 
42
- _UNSUPPORTED_KINDS = (Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD)
43
-
44
42
 
45
43
  @dataclass(frozen=True, slots=True)
46
44
  class Dependency:
@@ -52,13 +50,16 @@ class Dependency:
52
50
  def Depends(provider: Provider, /) -> Any: # noqa: N802
53
51
  """Mark a step (or provider) parameter as injected by calling `provider`.
54
52
 
55
- Use it as a default value, or inside `Annotated` to name a dependency once
56
- and reuse it:
53
+ Use it as the default value of the parameter. To name a dependency once and
54
+ reuse it, keep the marker in a constant:
57
55
 
58
56
  def send(order: Order, mailer: Mailer = Depends(get_mailer)) -> Order: ...
59
57
 
60
- type MailerDep = Annotated[Mailer, Depends(get_mailer)]
61
- def send(order: Order, mailer: MailerDep) -> Order: ...
58
+ MAILER = Depends(get_mailer)
59
+ def send(order: Order, mailer: Mailer = MAILER) -> Order: ...
60
+
61
+ It is never written inside `Annotated`: only a default value lets type
62
+ checkers see that the parameter is not passed, so `send()` type-checks.
62
63
 
63
64
  A provider is a function returning the object, or a generator function
64
65
  that yields it once and cleans up after the `yield`. Its own parameters may
@@ -90,15 +91,14 @@ def Depends(provider: Provider, /) -> Any: # noqa: N802
90
91
 
91
92
 
92
93
  def find_dependencies(fn: Callable[..., Any]) -> dict[str, Dependency]:
93
- """Return the parameters of `fn` marked with `Depends`, by name.
94
+ """Return the parameters of `fn` whose default is `Depends(...)`, by name.
94
95
 
95
96
  Example:
96
97
  ```python
97
98
  >>> def get_rate() -> float:
98
99
  ... return 0.25
99
- >>> from typing import Annotated
100
- >>> type Rate = Annotated[float, Depends(get_rate)]
101
- >>> def add_tax(price: float, label: str, rate: Rate, extra: float = Depends(get_rate)): ...
100
+ >>> RATE = Depends(get_rate)
101
+ >>> def add_tax(price: float, rate: float = RATE, extra: float = Depends(get_rate)): ...
102
102
  >>> list(find_dependencies(add_tax))
103
103
  ['rate', 'extra']
104
104
 
@@ -110,22 +110,24 @@ def find_dependencies(fn: Callable[..., Any]) -> dict[str, Dependency]:
110
110
  except ValueError: # builtins without a signature take no dependencies
111
111
  return {}
112
112
 
113
- annotations = resolved_annotations(fn)
114
- markers = (
115
- (name, _marker(parameter, annotations.get(name))) for name, parameter in parameters.items()
116
- )
117
- return {name: marker for name, marker in markers if marker is not None}
113
+ return {
114
+ name: parameter.default
115
+ for name, parameter in parameters.items()
116
+ if isinstance(parameter.default, Dependency)
117
+ }
118
118
 
119
119
 
120
120
  def step_dependencies(fn: Callable[..., Any], step_name: str) -> dict[str, Dependency]:
121
121
  """Return the dependencies of a step function after validating them.
122
122
 
123
123
  Raises:
124
- InvalidDependencyError: If the subject is marked, a dependency sits on a
125
- positional-only, `*args` or `**kwargs` parameter, or a provider is
124
+ InvalidDependencyError: If `Depends` is written inside `Annotated`, the
125
+ subject or a positional-only parameter is marked, or a provider is
126
126
  circular or has a required parameter that is not a dependency.
127
127
 
128
128
  """
129
+ _reject_annotated_markers(fn, f"Step '{step_name}'")
130
+
129
131
  dependencies = find_dependencies(fn)
130
132
  parameters = signature(fn).parameters
131
133
  subject = next(iter(parameters))
@@ -135,11 +137,8 @@ def step_dependencies(fn: Callable[..., Any], step_name: str) -> dict[str, Depen
135
137
  raise InvalidDependencyError(msg)
136
138
 
137
139
  for name, dependency in dependencies.items():
138
- if parameters[name].kind in _UNSUPPORTED_KINDS:
139
- msg = (
140
- f"Step '{step_name}' cannot inject {parameters[name].kind.description} "
141
- f"parameter '{name}'"
142
- )
140
+ if parameters[name].kind is Parameter.POSITIONAL_ONLY:
141
+ msg = f"Step '{step_name}' cannot inject positional-only parameter '{name}'"
143
142
  raise InvalidDependencyError(msg)
144
143
  _validate_provider(dependency.provider, ())
145
144
 
@@ -269,14 +268,17 @@ def _provider_dependencies(provider: Provider) -> Mapping[str, Dependency]:
269
268
  return MappingProxyType(find_dependencies(provider))
270
269
 
271
270
 
272
- def _marker(parameter: Parameter, annotation: Any) -> Dependency | None:
273
- if isinstance(parameter.default, Dependency):
274
- return parameter.default
275
-
276
- return next(
277
- (item for item in annotated_metadata(annotation) if isinstance(item, Dependency)),
278
- None,
279
- )
271
+ def _reject_annotated_markers(fn: Callable[..., Any], owner: str) -> None:
272
+ """Raise if a parameter of `fn` carries `Depends` inside `Annotated`."""
273
+ for name, annotation in resolved_annotations(fn).items():
274
+ for item in annotated_metadata(annotation):
275
+ if isinstance(item, Dependency):
276
+ msg = (
277
+ f"{owner} uses Depends inside Annotated for '{name}'; write it as the "
278
+ f"default value, `{name}: <type> = Depends({_name(item.provider)})`, so type "
279
+ "checkers see the parameter as optional"
280
+ )
281
+ raise InvalidDependencyError(msg)
280
282
 
281
283
 
282
284
  def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
@@ -290,6 +292,7 @@ def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
290
292
  except ValueError: # builtins without a signature are called with no arguments
291
293
  return
292
294
 
295
+ _reject_annotated_markers(provider, f"Provider '{_name(provider)}'")
293
296
  dependencies = find_dependencies(provider)
294
297
 
295
298
  for name, parameter in parameters.items():
@@ -300,11 +303,8 @@ def _validate_provider(provider: Provider, path: tuple[Provider, ...]) -> None:
300
303
  )
301
304
  raise InvalidDependencyError(msg)
302
305
 
303
- if name in dependencies and parameter.kind in _UNSUPPORTED_KINDS:
304
- msg = (
305
- f"Provider '{_name(provider)}' cannot inject {parameter.kind.description} "
306
- f"parameter '{name}'"
307
- )
306
+ if name in dependencies and parameter.kind is Parameter.POSITIONAL_ONLY:
307
+ msg = f"Provider '{_name(provider)}' cannot inject positional-only parameter '{name}'"
308
308
  raise InvalidDependencyError(msg)
309
309
 
310
310
  if name in dependencies:
@@ -45,16 +45,16 @@ class InvalidDependencyError(PyflowstepError, TypeError):
45
45
  """Raised when a `Depends(...)` declaration cannot work.
46
46
 
47
47
  For example a provider that is not callable, is circular, or has a required
48
- parameter that is not itself a dependency.
48
+ parameter that is not itself a dependency, or a marker written inside
49
+ `Annotated` instead of as the default value.
49
50
  """
50
51
 
51
52
 
52
53
  class InvalidInputError(PyflowstepError, TypeError):
53
54
  """Raised when an `Input()` declaration cannot work.
54
55
 
55
- For example an input on the subject, on a positional-only parameter, on a
56
- parameter that is also a dependency, or written inside `Annotated` instead
57
- of as the default value.
56
+ For example an input on the subject, on a positional-only parameter, or
57
+ written inside `Annotated` instead of as the default value.
58
58
  """
59
59
 
60
60
 
@@ -27,7 +27,7 @@ from inspect import BoundArguments, Parameter, Signature, signature
27
27
  from typing import Any, Concatenate
28
28
 
29
29
  from .dependencies import Dependency, resolve_dependencies, run_scope, step_dependencies
30
- from .exceptions import InvalidInputError, InvalidParserError, InvalidStepError, to_argument_error
30
+ from .exceptions import InvalidParserError, InvalidStepError, to_argument_error
31
31
  from .flow import Flow, action_name
32
32
  from .inputs import RunInput, mark_required_inputs, resolve_inputs, step_inputs
33
33
  from .parsers import find_parsers, parse_arguments
@@ -130,10 +130,6 @@ def _make_step[T, **P](fn: TapFn[T, P], *, passthrough: bool) -> StepFactory[T,
130
130
  parsers = find_parsers(fn, step_name)
131
131
  injected = dependencies.keys() | inputs.keys()
132
132
 
133
- if both := sorted(dependencies.keys() & inputs.keys()):
134
- msg = f"Step '{step_name}' cannot take {both} both as a dependency and as a run input"
135
- raise InvalidInputError(msg)
136
-
137
133
  if both := sorted(parsers.keys() & injected):
138
134
  msg = f"Step '{step_name}' cannot both parse and inject {both}: nothing is passed for them"
139
135
  raise InvalidParserError(msg)