Skip to content

Commit ac0f2cb

Browse files
authored
chore(scopes): split and sync configuration schema (#822)
This changes split the configuration codes and sync it from the engine. This will allow to build a CI job to sync the ci/scopes/config/scopes.py file.
1 parent 0442aa2 commit ac0f2cb

File tree

5 files changed

+162
-89
lines changed

5 files changed

+162
-89
lines changed

mergify_cli/ci/scopes/cli.py

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
import click
88
import pydantic
9-
import yaml
109

1110
from mergify_cli import utils
1211
from mergify_cli.ci.scopes import base_detector
1312
from mergify_cli.ci.scopes import changed_files
13+
from mergify_cli.ci.scopes import config
1414
from mergify_cli.ci.scopes import exceptions
1515

1616

@@ -19,65 +19,11 @@
1919

2020

2121
SCOPE_PREFIX = "scope_"
22-
SCOPE_NAME_RE = r"^[A-Za-z0-9_-]+$"
23-
24-
25-
class ConfigInvalidError(exceptions.ScopesError):
26-
pass
27-
28-
29-
ScopeName = typing.Annotated[
30-
str,
31-
pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1),
32-
]
33-
34-
35-
class FileFilters(pydantic.BaseModel):
36-
include: tuple[str, ...] = pydantic.Field(default_factory=lambda: ("**/*",))
37-
exclude: tuple[str, ...] = pydantic.Field(default_factory=tuple)
38-
39-
40-
class SourceFiles(pydantic.BaseModel):
41-
files: dict[ScopeName, FileFilters]
42-
43-
44-
class SourceManual(pydantic.BaseModel):
45-
manual: None
46-
47-
48-
class ScopesConfig(pydantic.BaseModel):
49-
model_config = pydantic.ConfigDict(extra="forbid")
50-
51-
source: SourceFiles | SourceManual | None = None
52-
merge_queue_scope: str | None = "merge-queue"
53-
54-
55-
class Config(pydantic.BaseModel):
56-
model_config = pydantic.ConfigDict(extra="ignore")
57-
58-
scopes: ScopesConfig
59-
60-
@classmethod
61-
def from_dict(cls, data: dict[str, typing.Any] | typing.Any) -> Config: # noqa: ANN401
62-
try:
63-
return cls.model_validate(data)
64-
except pydantic.ValidationError as e:
65-
raise ConfigInvalidError(e)
66-
67-
@classmethod
68-
def from_yaml(cls, path: str) -> Config:
69-
with pathlib.Path(path).open(encoding="utf-8") as f:
70-
try:
71-
data = yaml.safe_load(f) or {}
72-
except yaml.YAMLError as e:
73-
raise ConfigInvalidError(e)
74-
75-
return cls.from_dict(data)
7622

7723

7824
def match_scopes(
7925
files: abc.Iterable[str],
80-
filters: dict[ScopeName, FileFilters],
26+
filters: dict[config.ScopeName, config.FileFilters],
8127
) -> tuple[set[str], dict[str, list[str]]]:
8228
scopes_hit: set[str] = set()
8329
per_scope: dict[str, list[str]] = {s: [] for s in filters}
@@ -142,7 +88,7 @@ def load_from_file(cls, filename: str) -> DetectedScope:
14288

14389

14490
def detect(config_path: str) -> DetectedScope:
145-
cfg = Config.from_yaml(config_path)
91+
cfg = config.Config.from_yaml(config_path)
14692
base = base_detector.detect()
14793

14894
scopes_hit: set[str]
@@ -153,11 +99,11 @@ def detect(config_path: str) -> DetectedScope:
15399
all_scopes = set()
154100
scopes_hit = set()
155101
per_scope = {}
156-
elif isinstance(source, SourceFiles):
102+
elif isinstance(source, config.SourceFiles):
157103
changed = changed_files.git_changed_files(base.ref)
158104
all_scopes = set(source.files.keys())
159105
scopes_hit, per_scope = match_scopes(changed, source.files)
160-
elif isinstance(source, SourceManual):
106+
elif isinstance(source, config.SourceManual):
161107
msg = "source `manual` has been set, scopes must be send with `scopes-send` or API"
162108
raise exceptions.ScopesError(msg)
163109
else:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from mergify_cli.ci.scopes.config.root import Config
2+
from mergify_cli.ci.scopes.config.root import ConfigInvalidError
3+
from mergify_cli.ci.scopes.config.scopes import FileFilters
4+
from mergify_cli.ci.scopes.config.scopes import ScopeName
5+
from mergify_cli.ci.scopes.config.scopes import Scopes
6+
from mergify_cli.ci.scopes.config.scopes import SourceFiles
7+
from mergify_cli.ci.scopes.config.scopes import SourceManual
8+
9+
10+
__all__ = [
11+
"Config",
12+
"ConfigInvalidError",
13+
"FileFilters",
14+
"ScopeName",
15+
"Scopes",
16+
"SourceFiles",
17+
"SourceManual",
18+
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pathlib
2+
import typing
3+
4+
import pydantic
5+
import yaml
6+
7+
from mergify_cli.ci.scopes import exceptions
8+
from mergify_cli.ci.scopes.config.scopes import Scopes
9+
10+
11+
class ConfigInvalidError(exceptions.ScopesError):
12+
pass
13+
14+
15+
class Config(pydantic.BaseModel):
16+
model_config = pydantic.ConfigDict(extra="ignore")
17+
18+
scopes: Scopes
19+
20+
@classmethod
21+
def from_dict(
22+
cls,
23+
data: dict[str, typing.Any] | typing.Any, # noqa: ANN401
24+
) -> typing.Self:
25+
try:
26+
return cls.model_validate(data)
27+
except pydantic.ValidationError as e:
28+
raise ConfigInvalidError(e)
29+
30+
@classmethod
31+
def from_yaml(cls, path: str) -> typing.Self:
32+
with pathlib.Path(path).open(encoding="utf-8") as f:
33+
try:
34+
data = yaml.safe_load(f) or {}
35+
except yaml.YAMLError as e:
36+
raise ConfigInvalidError(e)
37+
38+
return cls.from_dict(data)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
5+
import pydantic
6+
7+
8+
SCOPE_NAME_RE = r"^[A-Za-z0-9_-]+$"
9+
10+
11+
ScopeName = typing.Annotated[
12+
str,
13+
pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1),
14+
]
15+
16+
17+
class FileFilters(pydantic.BaseModel):
18+
include: tuple[str, ...] = pydantic.Field(
19+
default_factory=lambda: ("**/*",),
20+
description=(
21+
"Glob patterns of files to include for this scope. "
22+
"Empty means 'include everything' before exclusions. "
23+
"Examples: ('src/**/*.py', 'Makefile')"
24+
),
25+
)
26+
exclude: tuple[str, ...] = pydantic.Field(
27+
default_factory=tuple,
28+
description=(
29+
"Glob patterns of files to exclude from this scope. "
30+
"Evaluated after `include` and takes precedence. "
31+
"Examples: ('**/tests/**', '*.md')"
32+
),
33+
)
34+
35+
36+
class SourceFiles(pydantic.BaseModel):
37+
files: dict[ScopeName, FileFilters] = pydantic.Field(
38+
description=(
39+
"Mapping of scope name to its file filters. "
40+
"A file belongs to a scope if it matches the scope's `include` "
41+
"patterns and not its `exclude` patterns."
42+
),
43+
)
44+
45+
46+
class SourceManual(pydantic.BaseModel):
47+
manual: None = pydantic.Field(
48+
description="Scopes are manually sent via API or `mergify scopes-send`",
49+
)
50+
51+
52+
class Scopes(pydantic.BaseModel):
53+
model_config = pydantic.ConfigDict(extra="forbid")
54+
55+
source: SourceFiles | SourceManual | None = pydantic.Field(
56+
default=None,
57+
description=(
58+
"Where scopes come from. "
59+
"`files` uses file-pattern rules (`gha-mergify-ci-scopes` must have been setup on your pull request); "
60+
"`manual` uses scopes sent via API or `mergify scopes-send`; "
61+
"`None` disables scoping."
62+
),
63+
)
64+
merge_queue_scope: str | None = pydantic.Field(
65+
default="merge-queue",
66+
description=(
67+
"Optional scope name automatically applied to merge queue PRs. "
68+
"Set to `None` to disable."
69+
),
70+
)

mergify_cli/tests/ci/scopes/test_cli.py

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from mergify_cli.ci.scopes import base_detector
1010
from mergify_cli.ci.scopes import cli
11+
from mergify_cli.ci.scopes import config
1112

1213

1314
def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None:
@@ -32,8 +33,8 @@ def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None:
3233
),
3334
)
3435

35-
config = cli.Config.from_yaml(str(config_file))
36-
assert config.model_dump() == {
36+
cfg = config.Config.from_yaml(str(config_file))
37+
assert cfg.model_dump() == {
3738
"scopes": {
3839
"source": {
3940
"files": {
@@ -71,8 +72,8 @@ def test_from_yaml_valid(tmp_path: pathlib.Path) -> None:
7172
),
7273
)
7374

74-
config = cli.Config.from_yaml(str(config_file))
75-
assert config.model_dump() == {
75+
cfg = config.Config.from_yaml(str(config_file))
76+
assert cfg.model_dump() == {
7677
"scopes": {
7778
"merge_queue_scope": "merge-queue",
7879
"source": {
@@ -99,12 +100,12 @@ def test_from_yaml_invalid_config(tmp_path: pathlib.Path) -> None:
99100
)
100101

101102
# Bad name and missing dict
102-
with pytest.raises(cli.ConfigInvalidError, match="3 validation errors"):
103-
cli.Config.from_yaml(str(config_file))
103+
with pytest.raises(config.ConfigInvalidError, match="3 validation errors"):
104+
config.Config.from_yaml(str(config_file))
104105

105106

106107
def test_match_scopes_basic() -> None:
107-
config = cli.Config.from_dict(
108+
cfg = config.Config.from_dict(
108109
{
109110
"scopes": {
110111
"source": {
@@ -119,9 +120,9 @@ def test_match_scopes_basic() -> None:
119120
)
120121
files = ["api/models.py", "ui/components/Button.tsx", "README.md", "other.txt"]
121122

122-
assert config.scopes.source is not None
123-
assert isinstance(config.scopes.source, cli.SourceFiles)
124-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
123+
assert cfg.scopes.source is not None
124+
assert isinstance(cfg.scopes.source, config.SourceFiles)
125+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
125126

126127
assert scopes_hit == {"backend", "frontend", "docs"}
127128
assert per_scope == {
@@ -132,7 +133,7 @@ def test_match_scopes_basic() -> None:
132133

133134

134135
def test_match_scopes_no_matches() -> None:
135-
config = cli.Config.from_dict(
136+
cfg = config.Config.from_dict(
136137
{
137138
"scopes": {
138139
"source": {
@@ -146,16 +147,16 @@ def test_match_scopes_no_matches() -> None:
146147
)
147148
files = ["other.txt", "unrelated.cpp"]
148149

149-
assert config.scopes.source is not None
150-
assert isinstance(config.scopes.source, cli.SourceFiles)
151-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
150+
assert cfg.scopes.source is not None
151+
assert isinstance(cfg.scopes.source, config.SourceFiles)
152+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
152153

153154
assert scopes_hit == set()
154155
assert per_scope == {}
155156

156157

157158
def test_match_scopes_multiple_include_single_scope() -> None:
158-
config = cli.Config.from_dict(
159+
cfg = config.Config.from_dict(
159160
{
160161
"scopes": {
161162
"source": {
@@ -168,9 +169,9 @@ def test_match_scopes_multiple_include_single_scope() -> None:
168169
)
169170
files = ["api/models.py", "backend/services.py"]
170171

171-
assert config.scopes.source is not None
172-
assert isinstance(config.scopes.source, cli.SourceFiles)
173-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
172+
assert cfg.scopes.source is not None
173+
assert isinstance(cfg.scopes.source, config.SourceFiles)
174+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
174175

175176
assert scopes_hit == {"backend"}
176177
assert per_scope == {
@@ -179,7 +180,7 @@ def test_match_scopes_multiple_include_single_scope() -> None:
179180

180181

181182
def test_match_scopes_with_negation_include() -> None:
182-
config = cli.Config.from_dict(
183+
cfg = config.Config.from_dict(
183184
{
184185
"scopes": {
185186
"source": {
@@ -204,9 +205,9 @@ def test_match_scopes_with_negation_include() -> None:
204205
"ui/components.spec.js",
205206
]
206207

207-
assert config.scopes.source is not None
208-
assert isinstance(config.scopes.source, cli.SourceFiles)
209-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
208+
assert cfg.scopes.source is not None
209+
assert isinstance(cfg.scopes.source, config.SourceFiles)
210+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
210211

211212
assert scopes_hit == {"backend", "frontend"}
212213
assert per_scope == {
@@ -216,7 +217,7 @@ def test_match_scopes_with_negation_include() -> None:
216217

217218

218219
def test_match_scopes_negation_only() -> None:
219-
config = cli.Config.from_dict(
220+
cfg = config.Config.from_dict(
220221
{
221222
"scopes": {
222223
"source": {
@@ -232,9 +233,9 @@ def test_match_scopes_negation_only() -> None:
232233
)
233234
files = ["image.jpeg", "document.txt", "photo.png", "readme.md"]
234235

235-
assert config.scopes.source is not None
236-
assert isinstance(config.scopes.source, cli.SourceFiles)
237-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
236+
assert cfg.scopes.source is not None
237+
assert isinstance(cfg.scopes.source, config.SourceFiles)
238+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
238239

239240
assert scopes_hit == {"exclude_images"}
240241
assert per_scope == {
@@ -243,7 +244,7 @@ def test_match_scopes_negation_only() -> None:
243244

244245

245246
def test_match_scopes_mixed_with_complex_negation() -> None:
246-
config = cli.Config.from_dict(
247+
cfg = config.Config.from_dict(
247248
{
248249
"scopes": {
249250
"source": {
@@ -265,9 +266,9 @@ def test_match_scopes_mixed_with_complex_negation() -> None:
265266
"main.py",
266267
]
267268

268-
assert config.scopes.source is not None
269-
assert isinstance(config.scopes.source, cli.SourceFiles)
270-
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
269+
assert cfg.scopes.source is not None
270+
assert isinstance(cfg.scopes.source, config.SourceFiles)
271+
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)
271272

272273
assert scopes_hit == {"backend"}
273274
assert per_scope == {

0 commit comments

Comments
 (0)