socx-cli 0.13.8__tar.gz → 0.13.9__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 (119) hide show
  1. {socx_cli-0.13.8 → socx_cli-0.13.9}/PKG-INFO +3 -1
  2. {socx_cli-0.13.8 → socx_cli-0.13.9}/pyproject.toml +4 -2
  3. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/__init__.py +2 -0
  4. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/callbacks.py +2 -0
  5. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/_config.py +5 -1
  6. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/converters.py +1 -2
  7. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/__init__.py +2 -0
  8. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/enums.py +4 -7
  9. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/__init__.py +2 -0
  10. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/plugin.py +59 -63
  11. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/types.py +10 -0
  12. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/io/console.py +2 -2
  13. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/io/log.py +14 -14
  14. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/regression.py +73 -40
  15. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/test.py +106 -93
  16. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/cli.yaml +1 -6
  17. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/logging.yaml +6 -6
  18. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/regression.yaml +51 -1
  19. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/settings.yaml +1 -0
  20. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/callbacks.py +1 -1
  21. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/cli.py +2 -0
  22. socx_cli-0.13.9/socx_plugins/regression/serve.py +29 -0
  23. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/tui.py +5 -2
  24. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/app.py +33 -40
  25. socx_cli-0.13.9/socx_tui/regression/details.py +323 -0
  26. socx_cli-0.13.9/socx_tui/regression/dialog.py +223 -0
  27. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/tree.py +15 -9
  28. socx_cli-0.13.9/socx_tui/regression/widget.py +701 -0
  29. socx_cli-0.13.9/socx_tui/static/tcss/regression/app.tcss +276 -0
  30. socx_cli-0.13.8/socx_tui/regression/details.py +0 -182
  31. socx_cli-0.13.8/socx_tui/regression/dialog.py +0 -182
  32. socx_cli-0.13.8/socx_tui/regression/widget.py +0 -498
  33. socx_cli-0.13.8/socx_tui/static/tcss/regression/app.tcss +0 -224
  34. {socx_cli-0.13.8 → socx_cli-0.13.9}/.gitignore +0 -0
  35. {socx_cli-0.13.8 → socx_cli-0.13.9}/LICENSE +0 -0
  36. {socx_cli-0.13.8 → socx_cli-0.13.9}/README.md +0 -0
  37. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/__main__.py +0 -0
  38. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/__init__.py +0 -0
  39. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/_cli.py +0 -0
  40. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/_jinja.py +0 -0
  41. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/cfg.py +0 -0
  42. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/cli.py +0 -0
  43. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/params.py +0 -0
  44. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/cli/types.py +0 -0
  45. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/__init__.py +0 -0
  46. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/_settings.py +0 -0
  47. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/encoders.py +0 -0
  48. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/formatters.py +0 -0
  49. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/serializers.py +0 -0
  50. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/config/validators.py +0 -0
  51. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/_paths.py +0 -0
  52. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/encoder.py +0 -0
  53. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/funcs.py +0 -0
  54. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/metadata.py +0 -0
  55. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/paths.py +0 -0
  56. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/git/__init__.py +0 -0
  57. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/git/git.py +0 -0
  58. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/schema/git/manifest.py +0 -0
  59. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/core/serializer.py +0 -0
  60. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/git/__init__.py +0 -0
  61. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/git/_git.py +0 -0
  62. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/git/_manifest.py +0 -0
  63. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/git/_ssh.py +0 -0
  64. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/io/__init__.py +0 -0
  65. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/io/decorators.py +0 -0
  66. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/__init__.py +0 -0
  67. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/mixins/__init__.py +0 -0
  68. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/mixins/proxy.py +0 -0
  69. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/mixins/uid.py +0 -0
  70. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/singleton/__init__.py +0 -0
  71. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/singleton/singleton.py +0 -0
  72. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/visitor/__init__.py +0 -0
  73. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/visitor/protocol.py +0 -0
  74. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/patterns/visitor/traversal.py +0 -0
  75. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/__init__.py +0 -0
  76. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/progress.py +0 -0
  77. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/status.py +0 -0
  78. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/validator.py +0 -0
  79. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/regression/visitor.py +0 -0
  80. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/console.yaml +0 -0
  81. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/git.yaml +0 -0
  82. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/plugins.yaml +0 -0
  83. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/settings/rich_click.yaml +0 -0
  84. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/static/sql/socx.sql +0 -0
  85. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/utils/__init__.py +0 -0
  86. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx/utils/decorators.py +0 -0
  87. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/config/__init__.py +0 -0
  88. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/config/_config.py +0 -0
  89. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/config/edit.py +0 -0
  90. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/__init__.py +0 -0
  91. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/arguments.py +0 -0
  92. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/callbacks.py +0 -0
  93. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/cli.py +0 -0
  94. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/manifest.py +0 -0
  95. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/renderables.py +0 -0
  96. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/summary.py +0 -0
  97. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/git/utils.py +0 -0
  98. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/plugin/__init__.py +0 -0
  99. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/plugin/example.py +0 -0
  100. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/plugin/schema.py +0 -0
  101. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/__init__.py +0 -0
  102. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/_run.py +0 -0
  103. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/regression/run.py +0 -0
  104. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/version/__init__.py +0 -0
  105. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_plugins/version/__main__.py +0 -0
  106. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/__init__.py +0 -0
  107. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/__init__.py +0 -0
  108. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/__main__.py +0 -0
  109. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/bindings/__init__.py +0 -0
  110. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/bindings/vim/__init__.py +0 -0
  111. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/bindings/vim/mode.py +0 -0
  112. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/bindings/vim/vim.py +0 -0
  113. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/containers.py +0 -0
  114. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/mixins/__init__.py +0 -0
  115. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/mixins/composable.py +0 -0
  116. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/mixins/configurable.py +0 -0
  117. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/preview.py +0 -0
  118. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/regression/table.py +0 -0
  119. {socx_cli-0.13.8 → socx_cli-0.13.9}/socx_tui/static/tcss/regression/preview.tcss +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socx-cli
3
- Version: 0.13.8
3
+ Version: 0.13.9
4
4
  Summary: System on chip verification and tooling infrastructure.
5
5
  Project-URL: Issues, https://github.com/sagikimhi/socx-cli/issues
6
6
  Project-URL: Homepage, https://sagikimhi.dev/socx-cli
@@ -29,6 +29,7 @@ Classifier: Topic :: Software Development
29
29
  Classifier: Topic :: Utilities
30
30
  Requires-Python: >=3.12
31
31
  Requires-Dist: anyio[trio]>=4.12.1
32
+ Requires-Dist: blinker>=1.9.0
32
33
  Requires-Dist: click
33
34
  Requires-Dist: copier~=9.11
34
35
  Requires-Dist: declare
@@ -53,6 +54,7 @@ Requires-Dist: sh~=2.2
53
54
  Requires-Dist: sqlmodel>=0.0.31
54
55
  Requires-Dist: textual
55
56
  Requires-Dist: textual-fspicker>=0.6.0
57
+ Requires-Dist: textual-serve>=1.1.3
56
58
  Requires-Dist: textual-speedups
57
59
  Requires-Dist: typer~=0.19
58
60
  Requires-Dist: uv
@@ -29,7 +29,7 @@ socx = 'socx.__main__:main'
29
29
  [project]
30
30
  name = "socx-cli"
31
31
  readme = "README.md"
32
- version = "0.13.8"
32
+ version = "0.13.9"
33
33
  license = "Apache-2.0"
34
34
  authors = [{ name = "Sagi Kimhi", email = "sagi.kim5@gmail.com" }]
35
35
  maintainers = [{ name = "Sagi Kimhi", email = "sagi.kim5@gmail.com" }]
@@ -83,6 +83,8 @@ dependencies = [
83
83
  "pydantic-settings>=2.12.0",
84
84
  "sqlmodel>=0.0.31",
85
85
  "anyio[trio]>=4.12.1",
86
+ "blinker>=1.9.0",
87
+ "textual-serve>=1.1.3",
86
88
  ]
87
89
 
88
90
  [project.urls]
@@ -129,7 +131,7 @@ docs = [
129
131
  "mkdocs-llmstxt>=0.4.0",
130
132
  "mkdocs-coverage",
131
133
  "mkdocs-glightbox",
132
- "mkdocstrings-python",
134
+ "mkdocstrings[python]>=0.18",
133
135
  "mkdocs-material[git, recommended, imaging]",
134
136
  "mkdocs-section-index",
135
137
  "griffe-typingdoc>=0.3.0",
@@ -90,6 +90,7 @@ __all__ = (
90
90
  # config
91
91
  "schema",
92
92
  "settings",
93
+ "Model",
93
94
  "Script",
94
95
  "NewPath",
95
96
  "FilePath",
@@ -139,6 +140,7 @@ __all__ = (
139
140
 
140
141
  from socx.core import enums as enums
141
142
  from socx.core import schema as schema
143
+ from socx.core import Model as Model
142
144
  from socx.core import Script as Script
143
145
  from socx.core import NewPath as NewPath
144
146
  from socx.core import FilePath as FilePath
@@ -46,10 +46,12 @@ def cwd_cb(ctx: Context, param: Parameter, value: Path) -> Path:
46
46
  def debug_cb(_: Context, param: Parameter, value: bool) -> bool:
47
47
  """Enable debug logging and persist the CLI switch to settings."""
48
48
  socx_logger = _get_logger()
49
+ settings[param.name] = value
49
50
  settings.cli.params[param.name] = value
50
51
  if value:
51
52
  set_level(Level.DEBUG, socx_logger)
52
53
  settings.cli.params["verbosity"] = get_level().name
54
+ settings.logging.handlers.console.level = Level.DEBUG.name
53
55
  return value
54
56
 
55
57
 
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from pathlib import Path
11
11
 
12
12
  from werkzeug.local import LocalProxy
13
+ from dynaconf.base import Settings as DynaSettings
13
14
 
14
15
  from socx.config import converters
15
16
  from socx.core import (
@@ -21,7 +22,10 @@ from socx.config._settings import Settings
21
22
  from socx.patterns.mixins.proxy import ProxyMixin
22
23
 
23
24
 
24
- class SettingsProxy(ProxyMixin[Settings], Settings): ...
25
+ class SettingsProxyBase(Settings, DynaSettings): ...
26
+
27
+
28
+ class SettingsProxy(ProxyMixin[SettingsProxyBase], SettingsProxyBase): ...
25
29
 
26
30
 
27
31
  logger = logging.getLogger(__name__)
@@ -25,7 +25,6 @@ from importlib import import_module
25
25
  from functools import cached_property, singledispatchmethod
26
26
  from collections.abc import Iterable, Callable
27
27
 
28
- import sh
29
28
  import rich_click as click
30
29
  import rich_click.patch as click_patch
31
30
  import rich_click.rich_click_theme as click_theme
@@ -679,7 +678,7 @@ class CommandConverter(
679
678
  )
680
679
 
681
680
  def is_shell_command(self, value: Any) -> bool:
682
- return isinstance(value, sh.Command)
681
+ return isinstance(value, BaseCommand)
683
682
 
684
683
  def is_click_command(self, value: Any) -> bool:
685
684
  return isinstance(value, click.Command)
@@ -7,6 +7,7 @@ __all__ = (
7
7
  "schema",
8
8
  "metadata",
9
9
  # types
10
+ "Model",
10
11
  "Script",
11
12
  "NewPath",
12
13
  "FilePath",
@@ -57,6 +58,7 @@ from . import paths as paths
57
58
  from . import schema as schema
58
59
  from . import metadata as metadata
59
60
 
61
+ from socx.core.schema import Model as Model
60
62
  from socx.core.schema import Script as Script
61
63
  from socx.core.schema import NewPath as NewPath
62
64
  from socx.core.schema import FilePath as FilePath
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import enum
4
4
  from typing import Self
5
5
  from pathlib import Path
6
- from functools import cache
7
6
 
8
7
 
9
8
  class AutoNumber(int, enum.ReprEnum):
@@ -18,22 +17,20 @@ class AutoNumber(int, enum.ReprEnum):
18
17
 
19
18
 
20
19
  class SettingsFormat(AutoNumber):
21
- Json = ".json"
22
- Yaml = ".yaml", ".yml"
20
+ Ini = ".ini", ".conf"
23
21
  Toml = ".toml"
24
- Ini = ".ini"
25
- Python = ".python"
22
+ Yaml = ".yaml", ".yml"
23
+ Json = ".json", ".jsonc", ".json5"
24
+ Python = ".py"
26
25
 
27
26
  def __init__(self, extension: str, *extensions: str) -> None:
28
27
  self.extensions = {extension, *extensions}
29
28
 
30
29
  @classmethod
31
- @cache
32
30
  def all_extensions(cls) -> set[str]:
33
31
  return {extension for member in cls for extension in member.extensions}
34
32
 
35
33
  @classmethod
36
- @cache
37
34
  def from_path(cls, path: str | Path) -> SettingsFormat:
38
35
  if isinstance(path, str):
39
36
  path = Path(path)
@@ -2,6 +2,7 @@ __all__ = (
2
2
  "git",
3
3
  "types",
4
4
  "plugin",
5
+ "Model",
5
6
  "Script",
6
7
  "NewPath",
7
8
  "FilePath",
@@ -15,6 +16,7 @@ from . import plugin as plugin
15
16
 
16
17
  from .git import git as git
17
18
 
19
+ from socx.core.schema.types import Model as Model
18
20
  from socx.core.schema.types import Script as Script
19
21
  from socx.core.schema.types import NewPath as NewPath
20
22
  from socx.core.schema.types import FilePath as FilePath
@@ -7,18 +7,14 @@ from pathlib import Path
7
7
 
8
8
  from box import SBox
9
9
  import rich_click as click
10
- from pydantic import Field, BaseModel, ConfigDict
10
+ from pydantic import Field
11
11
 
12
- from socx.core.schema.types import DirectoryPath, Script
12
+ from socx.core.schema.types import DirectoryPath, Script, Model
13
13
 
14
14
 
15
- class PluginModel(BaseModel):
15
+ class PluginModel(Model):
16
16
  """Metadata describing a plugin-backed CLI command."""
17
17
 
18
- name: str = Field(
19
- ..., pattern=r"[a-zA-Z0-9_-]+", description="Name of the plugin."
20
- )
21
-
22
18
  cwd: DirectoryPath = Field(
23
19
  default_factory=Path.cwd,
24
20
  description=dedent("""
@@ -29,83 +25,89 @@ class PluginModel(BaseModel):
29
25
 
30
26
  env: dict[str, str] = Field(
31
27
  default_factory=dict,
32
- description="""
33
- Environment variables that should be present when the
34
- command/script is invoked
35
- """.strip(),
28
+ description=dedent("""
29
+ Environment variables that should be present when the
30
+ command/script is invoked
31
+ """),
32
+ )
33
+
34
+ name: str = Field(
35
+ ..., pattern=r"[a-zA-Z0-9_-]+", description="Name of the plugin."
36
+ )
37
+
38
+ help: str = Field(
39
+ default="",
40
+ description=dedent("""
41
+ Description of what the plugin does to be printed during plugin
42
+ invocation if any of -h or --help flags were passed with the
43
+ command.
44
+ """),
45
+ )
46
+
47
+ enabled: bool = Field(
48
+ True,
49
+ description=dedent("""
50
+ Enable/disable the plugin. Disabled plugins are hidden and cannot be
51
+ invoked from the commandline. Defaults to True.
52
+ """),
36
53
  )
37
54
 
38
55
  timeout: float | None = Field(
39
- default=None,
56
+ None,
40
57
  ge=0,
41
- description="""
58
+ description=dedent("""
42
59
  An optional timeout in seconds for the plugin execution.
43
60
  If left unspecified, then plugin execution may last indefinitely.
44
- """,
45
- )
46
-
47
- enabled: bool = Field(
48
- default=True,
49
- description="""
50
- Whether or not the plugin should be enabled.
51
- If left unspecified, defaults to True.
52
- """,
61
+ """),
53
62
  )
54
63
 
55
64
  fresh_env: bool = Field(
56
- default=False,
57
- description="""
58
- Whether or not to execute the plugin in a fresh environment.
59
- A fresh environment is an environment with no environment
60
- variables defined other than those defined in the ``env`` field.
61
- A non-fresh environment will contain all environment variables of
62
- the current process, as well as any variables defined in the
63
- ``env`` field.
64
- If left unspecified, defaults to False.
65
- """,
65
+ False,
66
+ description=dedent("""
67
+ Whether or not to execute the plugin in a fresh environment.
68
+ A fresh environment is an environment with no environment
69
+ variables defined other than those defined in the ``env`` field.
70
+ A non-fresh environment will contain all environment variables of
71
+ the current process, as well as any variables defined in the
72
+ ``env`` field.
73
+ If left unspecified, defaults to False.
74
+ """),
66
75
  )
67
76
 
68
77
  script: Script = Field(
69
- default="",
70
- description="""
71
- A shell command or a path to an executable file to run on plugin
72
- invocation.
73
- """.strip(),
78
+ "",
79
+ description=dedent("""
80
+ A shell command or a path to an executable file to run on plugin
81
+ invocation.
82
+ """),
83
+ exclude_if=bool,
74
84
  )
75
85
 
76
86
  command: str | click.Command = Field(
77
87
  default="",
78
88
  pattern=r"(((((\w+)(.|/))*)(\w+))(:(\w+))?)?",
79
- description="""
80
- A path to a python module or symbol that will be called upon
81
- plugin invocation specified in the form of
82
- `<module_path/file_path>[:<symbol_name>]`.
83
- """.strip(),
84
- )
85
-
86
- help: str = Field(
87
- default="",
88
- description="""
89
- Description of what the plugin does to be printed during plugin
90
- invocation if any of -h or --help flags were passed with the
91
- command.
92
- """.strip(),
89
+ exclude_if=bool,
90
+ description=dedent("""
91
+ A path to a python module or symbol that will be called upon
92
+ plugin invocation specified in the form of
93
+ `<module_path/file_path>[:<symbol_name>]`.
94
+ """),
93
95
  )
94
96
 
95
97
  panel: str = Field(
96
98
  default="Plugins",
97
- description="""
99
+ description=dedent("""
98
100
  Custom panel name in which plugin help text will be displayed when
99
101
  CLI is invoked with the -h/--help flag.
100
- """.strip(),
102
+ """),
101
103
  )
102
104
 
103
105
  epilog: str = Field(
104
106
  default="",
105
- description="""
106
- Help string printed at the end of the help page after everything
107
- else.
108
- """.strip(),
107
+ description=dedent("""
108
+ Help string printed at the end of the help page after everything
109
+ else.
110
+ """),
109
111
  )
110
112
 
111
113
  aliases: tuple[str, ...] = Field(
@@ -118,12 +120,6 @@ class PluginModel(BaseModel):
118
120
  description="The short help to use for this command",
119
121
  )
120
122
 
121
- model_config = ConfigDict(
122
- extra="allow",
123
- from_attributes=True,
124
- arbitrary_types_allowed=True,
125
- )
126
-
127
123
  def is_script(self) -> bool:
128
124
  return bool(self.script)
129
125
 
@@ -13,6 +13,8 @@ from pydantic import (
13
13
  GetJsonSchemaHandler,
14
14
  BeforeValidator,
15
15
  PlainSerializer,
16
+ ConfigDict,
17
+ BaseModel,
16
18
  )
17
19
 
18
20
 
@@ -232,3 +234,11 @@ Script = Annotated[
232
234
  BeforeValidator(validate_script),
233
235
  PlainSerializer(str, str),
234
236
  ]
237
+
238
+
239
+ class Model(BaseModel):
240
+ model_config = ConfigDict(
241
+ extra="allow",
242
+ from_attributes=True,
243
+ arbitrary_types_allowed=True,
244
+ )
@@ -121,7 +121,7 @@ _console_cv = contextvars.ContextVar[Console]("console")
121
121
 
122
122
  _console_cv.set(_console)
123
123
 
124
- console: ConsoleProxy = LocalProxy( # type: ignore[assignment]
124
+ console: ConsoleProxy = LocalProxy(
125
125
  _console_cv,
126
126
  unbound_message="""
127
127
  Working outside of application context.
@@ -129,4 +129,4 @@ console: ConsoleProxy = LocalProxy( # type: ignore[assignment]
129
129
  Attempted to use functionality that expected a current application to
130
130
  be set. To solve this, set up an app context.
131
131
  """,
132
- )
132
+ ) # ty:ignore[invalid-assignment]
@@ -139,26 +139,26 @@ def _get_file_handler(
139
139
  tracebacks_suppress: Iterable[ModuleType] | None = None,
140
140
  tracebacks_show_locals: bool = True,
141
141
  ) -> logging.Handler:
142
- import atexit
143
142
 
144
143
  def close_if_open(file: IO) -> None:
145
144
  if not file.closed:
146
145
  file.close()
147
146
 
148
147
  mode = mode or "a"
149
- file = open(path, mode=mode) # noqa: SIM115
150
- atexit.register(close_if_open, file)
151
- handler = _get_console_handler(
152
- file=file,
153
- level=level,
154
- stderr=stderr,
155
- tab_size=tab_size,
156
- tracebacks=tracebacks,
157
- force_terminal=force_terminal,
158
- tracebacks_theme=tracebacks_theme,
159
- tracebacks_suppress=tracebacks_suppress,
160
- tracebacks_show_locals=tracebacks_show_locals,
161
- )
148
+ # file = open(path, mode=mode)
149
+ # atexit.register(close_if_open, file)
150
+ # handler = _get_console_handler(
151
+ # file=file,
152
+ # level=level,
153
+ # stderr=stderr,
154
+ # tab_size=tab_size,
155
+ # tracebacks=tracebacks,
156
+ # force_terminal=force_terminal,
157
+ # tracebacks_theme=tracebacks_theme,
158
+ # tracebacks_suppress=tracebacks_suppress,
159
+ # tracebacks_show_locals=tracebacks_show_locals,
160
+ # )
161
+ handler = logging.handlers.WatchedFileHandler(path, mode)
162
162
  handler.name = "file"
163
163
  handler.setFormatter(DEFAULT_CHILD_FORMATTER)
164
164
  return handler
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  import logging
7
- from typing import Any, Self
7
+ from typing import Any, Self, cast
8
8
  from pathlib import Path
9
9
  from collections import OrderedDict
10
10
  from collections.abc import Mapping, Callable, Iterable
@@ -22,14 +22,24 @@ from pydantic import (
22
22
  )
23
23
  from anyio.abc import TaskStatus
24
24
 
25
- from socx.config import settings
26
- from socx.core.schema import FilePath
25
+ from socx.config import settings, SymbolConverter
26
+ from socx.core.schema import NewPath, FilePath, DirectoryPath
27
27
  from socx.regression.test import Test, TestBase, TestResult, TestStatus
28
28
 
29
29
 
30
+ _sentinel = object()
31
+
32
+ _converter = SymbolConverter()
33
+
34
+ _filepath_adapter = TypeAdapter(FilePath)
35
+
36
+ _directory_adapter = TypeAdapter(NewPath | DirectoryPath)
37
+
30
38
  logger = logging.getLogger(__name__)
31
39
 
32
- _sentinel = object()
40
+ default_limiter = anyio.CapacityLimiter(
41
+ settings.regression.max_runs_in_parallel
42
+ )
33
43
 
34
44
 
35
45
  def _safe_dir_name(name: str, node_id: UUID4) -> str:
@@ -37,7 +47,7 @@ def _safe_dir_name(name: str, node_id: UUID4) -> str:
37
47
  return f"{slug or 'item'}-{node_id}"
38
48
 
39
49
 
40
- def _coerce_status(value: TestStatus | int | str) -> TestStatus:
50
+ def _coerce_status(value: int | str | TestStatus) -> TestStatus:
41
51
  if isinstance(value, TestStatus):
42
52
  return value
43
53
  if isinstance(value, int):
@@ -51,20 +61,15 @@ def _coerce_result(value: TestResult | str) -> TestResult:
51
61
  return TestResult(value)
52
62
 
53
63
 
54
- default_limiter = anyio.CapacityLimiter(
55
- settings.regression.max_runs_in_parallel
56
- )
57
-
58
-
59
64
  class Regression(TestBase):
60
65
  """Manage and execute a collection of tests with concurrency control."""
61
66
 
62
- test_map: OrderedDict[UUID4, SerializeAsAny[TestBase]] = Field(
63
- default_factory=OrderedDict, repr=True, title="Test Map"
64
- )
65
67
  limiter: anyio.CapacityLimiter = Field(
66
68
  default=default_limiter, exclude=True
67
69
  )
70
+ test_map: OrderedDict[UUID4, SerializeAsAny[TestBase]] = Field(
71
+ default_factory=OrderedDict, repr=True, title="Test Map"
72
+ )
68
73
 
69
74
  model_config = ConfigDict(
70
75
  title="Regression",
@@ -76,39 +81,54 @@ class Regression(TestBase):
76
81
  self,
77
82
  name: str,
78
83
  tests: list[TestBase] | None = None,
79
- test_map: dict[UUID4, TestBase] | None = None,
80
84
  limiter: anyio.CapacityLimiter | None = None,
85
+ test_map: dict[UUID4, TestBase] | None = None,
86
+ output_dir: NewPath | DirectoryPath | None = None,
81
87
  **kwargs: Any,
82
88
  ) -> None:
83
89
  super().__init__(name=name, **kwargs)
84
90
  test_map = test_map or {}
85
91
  tests = [*list(test_map.values()), *(tests or [])]
86
- self.test_map = OrderedDict({test.id: test for test in tests})
87
92
  self.limiter = limiter if limiter is not None else default_limiter
93
+ self.test_map = OrderedDict({test.id: test for test in tests})
94
+ self.output_dir = output_dir
95
+ if self.output_dir is not None:
96
+ self.assign_output_dir(self.output_dir)
88
97
 
89
98
  @classmethod
90
99
  @validate_call(config=ConfigDict(extra="allow"))
91
100
  def from_file(
92
101
  cls,
93
- path: str | Path,
102
+ path: FilePath,
94
103
  name: str | None = None,
95
- test_cls: type[TestBase] | None = None,
104
+ test_cls: str | type[Test] | None = None,
96
105
  **kwargs: Any,
97
106
  ) -> Self:
98
- return cls._from_file(path, name=name, test_cls=test_cls, **kwargs)
107
+ if test_cls is None or not test_cls:
108
+ test_cls = Test
109
+
110
+ if isinstance(test_cls, str):
111
+ test_cls: type[Test] = _converter(test_cls)
112
+
113
+ return cls._from_file(
114
+ path, name=name, test_cls=cast(type[Test], test_cls), **kwargs
115
+ )
99
116
 
100
117
  @classmethod
101
118
  @validate_call(config=ConfigDict(extra="allow"))
102
119
  def load(
103
120
  cls,
104
- path: str | Path,
121
+ path: FilePath,
105
122
  name: str | None = None,
106
- test_cls: type[Test] | None = None,
123
+ test_cls: str | type[Test] | None = None,
107
124
  **kwargs: Any,
108
125
  ) -> Self:
109
- path = TypeAdapter(FilePath).validate_python(path)
126
+ path = Path(path)
110
127
  data = cls._read_data(path) | kwargs
111
128
 
129
+ if isinstance(test_cls, str):
130
+ test_cls: type[Test] = _converter(test_cls)
131
+
112
132
  if cls._looks_like_state(data):
113
133
  return cls._from_state_data(
114
134
  data,
@@ -195,8 +215,9 @@ class Regression(TestBase):
195
215
  async with self.mutex:
196
216
  is_running = self.is_running()
197
217
  should_resume = self.is_suspended()
218
+ should_terminate = self._termination_requested
198
219
 
199
- if self.is_idle():
220
+ if self.is_idle() and not should_terminate:
200
221
  self._status = TestStatus.Pending
201
222
 
202
223
  if should_resume:
@@ -204,6 +225,11 @@ class Regression(TestBase):
204
225
  task_status.started()
205
226
  return
206
227
 
228
+ if should_terminate:
229
+ await self.stop()
230
+ task_status.started()
231
+ return
232
+
207
233
  if is_running:
208
234
  task_status.started()
209
235
  return
@@ -265,13 +291,15 @@ class Regression(TestBase):
265
291
  async def stop(self) -> None:
266
292
  """Terminate active work within the regression."""
267
293
  async with self.mutex:
268
- is_running = self.is_running()
269
- should_resume = self.is_suspended()
270
-
271
- self._termination_requested = True
272
-
273
- if not is_running and not should_resume:
274
- return
294
+ if any(
295
+ test.status
296
+ not in {
297
+ TestStatus.Finished,
298
+ TestStatus.Terminated,
299
+ }
300
+ for test in self.tests
301
+ ):
302
+ self._termination_requested = True
275
303
 
276
304
  async with anyio.create_task_group() as tg:
277
305
  for test in self.tests:
@@ -310,7 +338,7 @@ class Regression(TestBase):
310
338
  self._do_reset()
311
339
 
312
340
  def assign_output_dir(self, output_dir: Path) -> Path:
313
- self.output_dir = output_dir
341
+ self.output_dir = Path(output_dir)
314
342
  output_dir.mkdir(parents=True, exist_ok=True)
315
343
 
316
344
  for child in self.tests:
@@ -398,12 +426,9 @@ class Regression(TestBase):
398
426
  def _do_reset(self) -> None:
399
427
  elapsed = self.elapsed_time
400
428
  started = self.started_time
401
-
402
429
  super()._do_reset()
403
-
404
- if any(not test.is_idle() for test in self.tests):
405
- self._elapsed_time = elapsed
406
- self._started_time = started
430
+ self._elapsed_time = elapsed
431
+ self._started_time = started
407
432
 
408
433
  def _serialize_state(self, root_output_dir: Path) -> box.Box:
409
434
  return self._serialize_node(self, root_output_dir)
@@ -465,17 +490,25 @@ class Regression(TestBase):
465
490
  @validate_call(config=ConfigDict(extra="allow"))
466
491
  def _from_file(
467
492
  cls,
468
- path: str | Path,
493
+ path: FilePath,
469
494
  name: str | None = None,
470
- test_cls: type[TestBase] | None = None,
495
+ test_cls: str | type[Test] | None = None,
471
496
  **kwargs: Any,
472
497
  ) -> Self:
473
498
  """Construct a regression from a test configuration file."""
474
499
  from box import Box
475
500
 
476
- path = TypeAdapter(FilePath).validate_python(path)
477
- name = name or path.stem
478
- test_cls = test_cls or Test
501
+ path = _filepath_adapter.validate_python(path)
502
+
503
+ if not bool(name):
504
+ name = path.stem
505
+
506
+ if not bool(test_cls):
507
+ test_cls = Test
508
+
509
+ if isinstance(test_cls, str):
510
+ test_cls = _converter(test_cls)
511
+
479
512
  data = cls._read_data(path)
480
513
 
481
514
  settings.update(Box({name: data}), merge=False)