engin 0.1.0b5__tar.gz → 0.1.0rc1__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.
Files changed (107) hide show
  1. {engin-0.1.0b5 → engin-0.1.0rc1}/CHANGELOG.md +3 -4
  2. {engin-0.1.0b5 → engin-0.1.0rc1}/PKG-INFO +2 -1
  3. {engin-0.1.0b5 → engin-0.1.0rc1}/pyproject.toml +2 -2
  4. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_cli/__init__.py +2 -0
  5. engin-0.1.0rc1/src/engin/_cli/_check.py +71 -0
  6. engin-0.1.0rc1/src/engin/_cli/_common.py +121 -0
  7. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_cli/_graph.py +6 -2
  8. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_cli/_inspect.py +4 -5
  9. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_engin.py +3 -1
  10. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/extensions/asgi.py +2 -0
  11. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_fastapi.py +1 -0
  12. engin-0.1.0rc1/tests/cli/test_check.py +95 -0
  13. engin-0.1.0rc1/tests/cli/test_get_engin_instance.py +111 -0
  14. {engin-0.1.0b5 → engin-0.1.0rc1}/uv.lock +75 -62
  15. engin-0.1.0b5/src/engin/_cli/_common.py +0 -51
  16. {engin-0.1.0b5 → engin-0.1.0rc1}/.github/workflows/benchmark.yaml +0 -0
  17. {engin-0.1.0b5 → engin-0.1.0rc1}/.github/workflows/check.yaml +0 -0
  18. {engin-0.1.0b5 → engin-0.1.0rc1}/.github/workflows/publish.yaml +0 -0
  19. {engin-0.1.0b5 → engin-0.1.0rc1}/.gitignore +0 -0
  20. {engin-0.1.0b5 → engin-0.1.0rc1}/.readthedocs.yaml +0 -0
  21. {engin-0.1.0b5 → engin-0.1.0rc1}/LICENSE +0 -0
  22. {engin-0.1.0b5 → engin-0.1.0rc1}/README.md +0 -0
  23. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/blocks.md +0 -0
  24. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/engin.md +0 -0
  25. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/invocations.md +0 -0
  26. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/lifecycle.md +0 -0
  27. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/providers.md +0 -0
  28. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/concepts/supervisor.md +0 -0
  29. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/index.md +0 -0
  30. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/integrations/fastapi-graph.png +0 -0
  31. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/integrations/fastapi.md +0 -0
  32. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/js/readthedocs.js +0 -0
  33. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/overrides/main.html +0 -0
  34. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/reference.md +0 -0
  35. {engin-0.1.0b5 → engin-0.1.0rc1}/docs/tutorial.md +0 -0
  36. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/__init__.py +0 -0
  37. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/__init__.py +0 -0
  38. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/app.py +0 -0
  39. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/__init__.py +0 -0
  40. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/db/__init__.py +0 -0
  41. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  42. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/db/adapaters/memory.py +0 -0
  43. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/db/block.py +0 -0
  44. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/db/ports.py +0 -0
  45. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/starlette/__init__.py +0 -0
  46. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/common/starlette/endpoint.py +0 -0
  47. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/__init__.py +0 -0
  48. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/__init__.py +0 -0
  49. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/api/__init__.py +0 -0
  50. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/api/get.py +0 -0
  51. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/api/post.py +0 -0
  52. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/block.py +0 -0
  53. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/features/cats/domain.py +0 -0
  54. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/asgi/main.py +0 -0
  55. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/__init__.py +0 -0
  56. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/app.py +0 -0
  57. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/main.py +0 -0
  58. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/__init__.py +0 -0
  59. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/__init__.py +0 -0
  60. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  61. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  62. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/api.py +0 -0
  63. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/block.py +0 -0
  64. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/domain.py +0 -0
  65. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/fastapi/routes/cats/ports.py +0 -0
  66. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/simple/__init__.py +0 -0
  67. {engin-0.1.0b5 → engin-0.1.0rc1}/examples/simple/main.py +0 -0
  68. {engin-0.1.0b5 → engin-0.1.0rc1}/mkdocs.yaml +0 -0
  69. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/__init__.py +0 -0
  70. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_assembler.py +0 -0
  71. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_block.py +0 -0
  72. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_cli/_graph.html +0 -0
  73. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_dependency.py +0 -0
  74. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_graph.py +0 -0
  75. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_introspect.py +0 -0
  76. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_lifecycle.py +0 -0
  77. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_option.py +0 -0
  78. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_supervisor.py +0 -0
  79. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/_type_utils.py +0 -0
  80. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/exceptions.py +0 -0
  81. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/extensions/__init__.py +0 -0
  82. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/extensions/fastapi.py +0 -0
  83. {engin-0.1.0b5 → engin-0.1.0rc1}/src/engin/py.typed +0 -0
  84. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/__init__.py +0 -0
  85. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/__init__.py +0 -0
  86. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_engin_signal_handling.py +0 -0
  87. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_error_in_invocation.py +0 -0
  88. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_error_in_lifecycle_shutdown.py +0 -0
  89. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_error_in_lifecycle_startup.py +0 -0
  90. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_error_in_provider.py +0 -0
  91. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/acceptance/test_error_in_supervisor_task.py +0 -0
  92. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/benchmarks/__init__.py +0 -0
  93. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/benchmarks/conftest.py +0 -0
  94. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/benchmarks/test_bench_assembler.py +0 -0
  95. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/cli/__init__.py +0 -0
  96. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/cli/test_graph.py +0 -0
  97. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/cli/test_inspect.py +0 -0
  98. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/conftest.py +0 -0
  99. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/deps.py +0 -0
  100. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_assembler.py +0 -0
  101. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_block.py +0 -0
  102. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_dependencies.py +0 -0
  103. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_engin.py +0 -0
  104. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_graph.py +0 -0
  105. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_lifecycle.py +0 -0
  106. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_supervisor.py +0 -0
  107. {engin-0.1.0b5 → engin-0.1.0rc1}/tests/test_type_id.py +0 -0
@@ -10,10 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
  ### Added
11
11
 
12
12
  - Supervisor class which can safely supervise long running tasks.
13
-
14
- ### Changed
15
-
16
- - `ASGIEngin.run()` now raises an error to prevent incorrect usage.
13
+ - A new cli option `engin check` that validates whether you have any missing providers.
14
+ - Support for specifying `default-instance` in your `pyproject.toml` under `[tool.engin]`
15
+ which is used as a default value for the `app` parameter when using the cli.
17
16
 
18
17
 
19
18
  ## [0.0.20] - 2025-06-18
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.1.0b5
3
+ Version: 0.1.0rc1
4
4
  Summary: An async-first modular application framework
5
5
  Project-URL: Homepage, https://github.com/invokermain/engin
6
6
  Project-URL: Documentation, https://engin.readthedocs.io/en/latest/
@@ -13,6 +13,7 @@ Requires-Python: >=3.10
13
13
  Requires-Dist: anyio>=4
14
14
  Requires-Dist: exceptiongroup>=1
15
15
  Provides-Extra: cli
16
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'cli'
16
17
  Requires-Dist: typer>=0.15; extra == 'cli'
17
18
  Description-Content-Type: text/markdown
18
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.1.0b5"
3
+ version = "0.1.0rc1"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ dependencies = [
13
13
  ]
14
14
 
15
15
  [project.optional-dependencies]
16
- cli = ["typer>=0.15"]
16
+ cli = ["typer>=0.15", "tomli>=2.0; python_version < '3.11'"]
17
17
 
18
18
  [project.scripts]
19
19
  engin = "engin._cli:app"
@@ -9,6 +9,7 @@ except ImportError:
9
9
  " `cli` extra, e.g. pip install engin[cli]"
10
10
  ) from None
11
11
 
12
+ from engin._cli._check import cli as check_cli
12
13
  from engin._cli._graph import cli as graph_cli
13
14
  from engin._cli._inspect import cli as inspect_cli
14
15
 
@@ -20,5 +21,6 @@ sys.path.insert(0, "")
20
21
 
21
22
  app = typer.Typer()
22
23
 
24
+ app.add_typer(check_cli)
23
25
  app.add_typer(graph_cli)
24
26
  app.add_typer(inspect_cli)
@@ -0,0 +1,71 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from engin._cli._common import COMMON_HELP, get_engin_instance
7
+
8
+ cli = typer.Typer()
9
+
10
+
11
+ @cli.command(name="check")
12
+ def check_dependencies(
13
+ app: Annotated[
14
+ str | None,
15
+ typer.Argument(help=COMMON_HELP["app"]),
16
+ ] = None,
17
+ ) -> None:
18
+ """
19
+ Validates that all dependencies are satisfied for the given engin instance.
20
+
21
+ This command checks that all providers required by invocations and other providers
22
+ are available. It's intended for use in CI to catch missing dependencies.
23
+
24
+ Examples:
25
+
26
+ 1. `engin check`
27
+
28
+ Returns:
29
+ Exit code 0 if all dependencies are satisfied.
30
+ Exit code 1 if there are missing providers.
31
+ """
32
+ _, _, instance = get_engin_instance(app)
33
+
34
+ console = Console()
35
+ assembler = instance.assembler
36
+ missing_providers = set()
37
+
38
+ # Check dependencies for all invocations
39
+ for invocation in instance._invocations:
40
+ for param_type_id in invocation.parameter_type_ids:
41
+ try:
42
+ assembler._resolve_providers(param_type_id, set())
43
+ except LookupError:
44
+ missing_providers.add(param_type_id)
45
+
46
+ # Check dependencies for all providers
47
+ for provider in assembler.providers:
48
+ for param_type_id in provider.parameter_type_ids:
49
+ try:
50
+ assembler._resolve_providers(param_type_id, set())
51
+ except LookupError:
52
+ missing_providers.add(param_type_id)
53
+
54
+ if missing_providers:
55
+ sorted_missing = sorted(str(type_id) for type_id in missing_providers)
56
+
57
+ console.print("❌ Missing providers found:", style="red bold")
58
+ for missing_type in sorted_missing:
59
+ console.print(f" • {missing_type}", style="red")
60
+
61
+ available_providers = sorted(
62
+ str(provider.return_type_id) for provider in assembler.providers
63
+ )
64
+ console.print("\nAvailable providers:", style="yellow")
65
+ for available_type in available_providers:
66
+ console.print(f" • {available_type}", style="yellow")
67
+
68
+ raise typer.Exit(code=1)
69
+ else:
70
+ console.print("✅ All dependencies are satisfied!", style="green bold")
71
+ raise typer.Exit(code=0)
@@ -0,0 +1,121 @@
1
+ import importlib
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Never
5
+
6
+ import typer
7
+ from rich import print
8
+ from rich.panel import Panel
9
+
10
+ from engin import Engin
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else:
15
+ import tomli as tomllib
16
+
17
+
18
+ def print_error(msg: str) -> Never:
19
+ print(
20
+ Panel(
21
+ title="Error",
22
+ renderable=msg,
23
+ title_align="left",
24
+ border_style="red",
25
+ highlight=True,
26
+ )
27
+ )
28
+ raise typer.Exit(code=1)
29
+
30
+
31
+ COMMON_HELP = {
32
+ "app": (
33
+ "The import path of your Engin instance, in the form 'package:application'"
34
+ ", e.g. 'app.main:engin'. If not provided, will try to use the `default-instance`"
35
+ " value specified in your pyproject.toml"
36
+ )
37
+ }
38
+
39
+
40
+ def _find_pyproject_toml() -> Path | None:
41
+ """Find pyproject.toml file starting from current directory and walking up."""
42
+ current_path = Path.cwd()
43
+
44
+ for path in [current_path, *current_path.parents]:
45
+ pyproject_path = path / "pyproject.toml"
46
+ if pyproject_path.exists():
47
+ return pyproject_path
48
+
49
+ return None
50
+
51
+
52
+ def _get_default_engin_from_pyproject() -> str | None:
53
+ """Get the default engin instance from pyproject.toml."""
54
+ pyproject_path = _find_pyproject_toml()
55
+ if not pyproject_path:
56
+ return None
57
+
58
+ try:
59
+ with Path(pyproject_path).open("rb") as f:
60
+ data = tomllib.load(f)
61
+
62
+ tool_section = data.get("tool", {})
63
+ engin_section = tool_section.get("engin", {})
64
+ instance = engin_section.get("default-instance")
65
+
66
+ if instance is None:
67
+ return None
68
+
69
+ if not isinstance(instance, str):
70
+ print_error("value of `default-instance` is not a string")
71
+
72
+ return instance
73
+
74
+ except (OSError, tomllib.TOMLDecodeError):
75
+ print_error("invalid toml detected")
76
+
77
+
78
+ NO_APP_FOUND_ERROR = (
79
+ "App path not specified and no default instance specified in pyproject.toml"
80
+ )
81
+
82
+
83
+ def get_engin_instance(app: str | None = None) -> tuple[str, str, Engin]:
84
+ """
85
+ Get an Engin instance either from the provided value or from pyproject.toml.
86
+
87
+ Args:
88
+ app: Optional string in format 'module:attribute'. If not provided will lookup in
89
+ pyproject.toml.
90
+
91
+ Returns:
92
+ Tuple of (module_name, engin_name, engin_instance)
93
+
94
+ Raises:
95
+ typer.Exit: If no app is provided and no default instance is specified in the user's
96
+ pyproject.toml.
97
+ """
98
+ if app is None:
99
+ app = _get_default_engin_from_pyproject()
100
+ if app is None:
101
+ print_error(NO_APP_FOUND_ERROR)
102
+
103
+ try:
104
+ module_name, engin_name = app.split(":", maxsplit=1)
105
+ except ValueError:
106
+ print_error("Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'")
107
+
108
+ try:
109
+ module = importlib.import_module(module_name)
110
+ except ModuleNotFoundError:
111
+ print_error(f"Unable to find module '{module_name}'")
112
+
113
+ try:
114
+ instance = getattr(module, engin_name)
115
+ except AttributeError:
116
+ print_error(f"Module '{module_name}' has no attribute '{engin_name}'")
117
+
118
+ if not isinstance(instance, Engin):
119
+ print_error(f"'{app}' is not an Engin instance")
120
+
121
+ return module_name, engin_name, instance
@@ -28,12 +28,16 @@ _APP_ORIGIN = ""
28
28
  @cli.command(name="graph")
29
29
  def serve_graph(
30
30
  app: Annotated[
31
- str,
31
+ str | None,
32
32
  typer.Argument(help=COMMON_HELP["app"]),
33
- ],
33
+ ] = None,
34
34
  ) -> None:
35
35
  """
36
36
  Creates a visualisation of your application's dependencies.
37
+
38
+ Examples:
39
+
40
+ 1. `engin graph`
37
41
  """
38
42
  module_name, _, instance = get_engin_instance(app)
39
43
 
@@ -19,9 +19,9 @@ _CLI_HELP = {
19
19
  @cli.command(name="inspect")
20
20
  def serve_graph(
21
21
  app: Annotated[
22
- str,
22
+ str | None,
23
23
  typer.Argument(help=COMMON_HELP["app"]),
24
- ],
24
+ ] = None,
25
25
  type_: Annotated[
26
26
  str | None,
27
27
  typer.Option("--type", help=_CLI_HELP["type"]),
@@ -39,9 +39,8 @@ def serve_graph(
39
39
 
40
40
  Examples:
41
41
 
42
- 1. `engin inspect examples.simple.main:engin --module httpx`
43
-
44
- 2. `engin inspect examples.simple.main:engin --type AsyncClient`
42
+ 1. `engin inspect --module httpx`
43
+ 2. `engin inspect --type AsyncClient`
45
44
  """
46
45
  module_name, _, instance = get_engin_instance(app)
47
46
 
@@ -90,6 +90,7 @@ class Engin:
90
90
  """
91
91
 
92
92
  _LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle), Provide(Supervisor)]
93
+ _STOP_ON_SINGAL: ClassVar[bool] = True
93
94
 
94
95
  def __init__(self, *options: Option) -> None:
95
96
  """
@@ -172,7 +173,8 @@ class Engin:
172
173
  self._start_complete_event.set()
173
174
 
174
175
  async with create_task_group() as tg:
175
- tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
176
+ if self._STOP_ON_SINGAL:
177
+ tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
176
178
 
177
179
  try:
178
180
  async with supervisor:
@@ -21,6 +21,8 @@ class ASGIType(Protocol):
21
21
 
22
22
 
23
23
  class ASGIEngin(Engin, ASGIType):
24
+ _STOP_ON_SINGAL = False # web server implementation is responsible for this
25
+
24
26
  _asgi_type: ClassVar[type[ASGIType]] = ASGIType # type: ignore[type-abstract]
25
27
  _asgi_app: ASGIType
26
28
 
@@ -54,6 +54,7 @@ def test_fastapi():
54
54
  with starlette.testclient.TestClient(engin) as client:
55
55
  result = client.get("http://127.0.0.1:8000/")
56
56
 
57
+ assert result.status_code == 200
57
58
  assert result.json() == "hello world"
58
59
 
59
60
 
@@ -0,0 +1,95 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from engin import Engin, Invoke, Provide
4
+ from engin._cli._check import cli
5
+ from tests.deps import ABlock, make_str
6
+
7
+ satisfied_engin = Engin(ABlock)
8
+
9
+
10
+ def needs_missing_dependency(missing_type: int) -> None:
11
+ pass
12
+
13
+
14
+ unsatisfied_engin = Engin(
15
+ Provide(make_str),
16
+ Invoke(needs_missing_dependency),
17
+ )
18
+
19
+
20
+ def needs_complex_type(custom_type: dict[str, int]) -> None:
21
+ pass
22
+
23
+
24
+ complex_unsatisfied_engin = Engin(
25
+ ABlock,
26
+ Invoke(needs_complex_type),
27
+ )
28
+
29
+ runner = CliRunner()
30
+
31
+
32
+ def test_check_all_dependencies_satisfied():
33
+ result = runner.invoke(
34
+ app=cli,
35
+ args=["tests.cli.test_check:satisfied_engin"],
36
+ )
37
+ assert result.exit_code == 0
38
+ assert "✅ All dependencies are satisfied!" in result.output
39
+
40
+
41
+ def test_check_missing_dependencies():
42
+ result = runner.invoke(
43
+ app=cli,
44
+ args=["tests.cli.test_check:unsatisfied_engin"],
45
+ )
46
+ assert result.exit_code == 1
47
+ assert "❌ Missing providers found:" in result.output
48
+ assert "int" in result.output
49
+ assert "Available providers:" in result.output
50
+
51
+
52
+ def test_check_complex_missing_dependencies():
53
+ result = runner.invoke(
54
+ app=cli,
55
+ args=["tests.cli.test_check:complex_unsatisfied_engin"],
56
+ )
57
+ assert result.exit_code == 1
58
+ assert "❌ Missing providers found:" in result.output
59
+ assert "dict" in result.output
60
+
61
+
62
+ def test_check_invalid_app_path():
63
+ result = runner.invoke(
64
+ app=cli,
65
+ args=["tests.cli.nonexistent:engin"],
66
+ )
67
+ assert result.exit_code == 1
68
+ assert "Unable to find module" in result.output
69
+
70
+
71
+ def test_check_invalid_app_attribute():
72
+ result = runner.invoke(
73
+ app=cli,
74
+ args=["tests.cli.test_check:nonexistent_engin"],
75
+ )
76
+ assert result.exit_code == 1
77
+ assert "has no attribute" in result.output
78
+
79
+
80
+ def test_check_invalid_app_format():
81
+ result = runner.invoke(
82
+ app=cli,
83
+ args=["invalid_format"],
84
+ )
85
+ assert result.exit_code == 1
86
+ assert "Expected an argument of the form 'module:attribute'" in result.output
87
+
88
+
89
+ def test_check_not_engin_instance():
90
+ result = runner.invoke(
91
+ app=cli,
92
+ args=["tests.cli.test_check:runner"], # CliRunner is not an Engin
93
+ )
94
+ assert result.exit_code == 1
95
+ assert "is not an Engin instance" in result.output
@@ -0,0 +1,111 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ from click.exceptions import Exit
7
+
8
+ from engin import Engin
9
+ from engin._cli._common import get_engin_instance
10
+
11
+ instance_a = Engin()
12
+ instance_b = Engin()
13
+
14
+
15
+ def test_get_engin_instance_with_default_from_pyproject():
16
+ pyproject_content = """
17
+ [tool.engin]
18
+ default-instance = "tests.cli.test_get_engin_instance:instance_a"
19
+ """
20
+
21
+ with tempfile.TemporaryDirectory() as temp_dir:
22
+ temp_path = Path(temp_dir)
23
+ pyproject_path = temp_path / "pyproject.toml"
24
+ pyproject_path.write_text(pyproject_content)
25
+
26
+ with patch("pathlib.Path.cwd", return_value=temp_path):
27
+ _, _, found_instance = get_engin_instance()
28
+ assert found_instance == instance_a
29
+
30
+
31
+ def test_get_engin_instance_when_app_overrides_default():
32
+ pyproject_content = """
33
+ [tool.engin]
34
+ default-instance = "tests.cli.test_check:instance_a"
35
+ """
36
+
37
+ with tempfile.TemporaryDirectory() as temp_dir:
38
+ temp_path = Path(temp_dir)
39
+ pyproject_path = temp_path / "pyproject.toml"
40
+ pyproject_path.write_text(pyproject_content)
41
+
42
+ with patch("pathlib.Path.cwd", return_value=temp_path):
43
+ _, _, found_instance = get_engin_instance(
44
+ "tests.cli.test_get_engin_instance:instance_b"
45
+ )
46
+ assert found_instance == instance_b
47
+
48
+
49
+ def test_get_engin_instance_with_no_default_no_app():
50
+ pyproject_content = """
51
+ [project]
52
+ name = "test"
53
+ """
54
+
55
+ with tempfile.TemporaryDirectory() as temp_dir:
56
+ temp_path = Path(temp_dir)
57
+ pyproject_path = temp_path / "pyproject.toml"
58
+ pyproject_path.write_text(pyproject_content)
59
+
60
+ with (
61
+ patch("pathlib.Path.cwd", return_value=temp_path),
62
+ pytest.raises(Exit),
63
+ ):
64
+ get_engin_instance()
65
+
66
+
67
+ def test_get_engin_instance_with_no_default_and_no_pyproject_toml():
68
+ with tempfile.TemporaryDirectory() as temp_dir:
69
+ temp_path = Path(temp_dir)
70
+
71
+ with (
72
+ patch("pathlib.Path.cwd", return_value=temp_path),
73
+ pytest.raises(Exit),
74
+ ):
75
+ get_engin_instance()
76
+
77
+
78
+ def test_check_with_no_default_and_invalid_toml():
79
+ pyproject_content = """
80
+ []]
81
+ name -- test
82
+ """
83
+
84
+ with tempfile.TemporaryDirectory() as temp_dir:
85
+ temp_path = Path(temp_dir)
86
+ pyproject_path = temp_path / "pyproject.toml"
87
+ pyproject_path.write_text(pyproject_content)
88
+
89
+ with (
90
+ patch("pathlib.Path.cwd", return_value=temp_path),
91
+ pytest.raises(Exit),
92
+ ):
93
+ get_engin_instance()
94
+
95
+
96
+ def test_check_with_no_default_and_invalid_value_type():
97
+ pyproject_content = """
98
+ [tool.engin]
99
+ default-instance = 3.1
100
+ """
101
+
102
+ with tempfile.TemporaryDirectory() as temp_dir:
103
+ temp_path = Path(temp_dir)
104
+ pyproject_path = temp_path / "pyproject.toml"
105
+ pyproject_path.write_text(pyproject_content)
106
+
107
+ with (
108
+ patch("pathlib.Path.cwd", return_value=temp_path),
109
+ pytest.raises(Exit),
110
+ ):
111
+ get_engin_instance()