Skip to content

Commit 7e04e68

Browse files
committed
Add link config validation
1 parent 5cfc6e9 commit 7e04e68

File tree

13 files changed

+287
-168
lines changed

13 files changed

+287
-168
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ repos:
3838
- "jinja2>=2.11.3" # 3.1.2 / 3.1.2
3939
- "packaging>=20" # 20 seems to be available with RHEL8
4040
- "pint>=0.16.1" # 0.16.1
41+
- "pydantic>=1.10, <2.0"
4142
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
4243
- "requests>=2.25.1" # 2.28.2 / 2.31.0
4344
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
@@ -86,6 +87,7 @@ repos:
8687
- "jinja2>=2.11.3" # 3.1.2 / 3.1.2
8788
- "packaging>=20" # 20 seems to be available with RHEL8
8889
- "pint>=0.16.1" # 0.16.1 / 0.19.x TODO: Pint 0.20 requires larger changes to tmt.hardware
90+
- "pydantic>=1.10, <2.0"
8991
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
9092
- "requests>=2.25.1" # 2.28.2 / 2.31.0
9193
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
@@ -158,6 +160,7 @@ repos:
158160
- "docutils>=0.16" # 0.16 is the current one available for RHEL9
159161
- "packaging>=20" # 20 seems to be available with RHEL8
160162
- "pint<0.20"
163+
- "pydantic>=1.10, <2.0"
161164
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
162165
# Help installation by reducing the set of inspected botocore release.
163166
# There is *a lot* of them, and hatch might fetch many of them.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [ # F39 / PyPI
3636
"jinja2>=2.11.3", # 3.1.2 / 3.1.2
3737
"packaging>=20", # 20 seems to be available with RHEL8
3838
"pint>=0.16.1", # 0.16.1
39+
"pydantic>=1.10, <2.0",
3940
"pygments>=2.7.4", # 2.7.4 is the current one available for RHEL9
4041
"requests>=2.25.1", # 2.28.2 / 2.31.0
4142
"ruamel.yaml>=0.16.6", # 0.17.32 / 0.17.32

tests/unit/test_config.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import queue
2+
import re
3+
import textwrap
4+
import threading
5+
import unittest
6+
import unittest.mock
7+
from unittest.mock import MagicMock
8+
9+
import fmf
10+
import pytest
11+
12+
import tmt.config
13+
from tmt.utils import Path
14+
15+
16+
@pytest.fixture
17+
def config_path(tmppath: Path, monkeypatch) -> Path:
18+
config_path = tmppath / 'config'
19+
config_path.mkdir()
20+
monkeypatch.setattr(tmt.config, 'effective_config_dir', MagicMock(return_value=config_path))
21+
return config_path
22+
23+
24+
def test_config(config_path: Path):
25+
""" Config smoke test """
26+
run = Path('/var/tmp/tmt/test')
27+
config1 = tmt.config.Config()
28+
config1.last_run = run
29+
config2 = tmt.config.Config()
30+
assert config2.last_run.resolve() == run.resolve()
31+
32+
33+
def test_last_run_race(tmppath: Path, monkeypatch):
34+
""" Race in last run symlink shouldn't be fatal """
35+
config_path = tmppath / 'config'
36+
config_path.mkdir()
37+
monkeypatch.setattr(tmt.config, 'effective_config_dir', MagicMock(return_value=config_path))
38+
mock_logger = unittest.mock.MagicMock()
39+
monkeypatch.setattr(tmt.utils.log, 'warning', mock_logger)
40+
config = tmt.config.Config()
41+
results = queue.Queue()
42+
threads = []
43+
44+
def create_last_run(config, counter):
45+
try:
46+
last_run_path = tmppath / f"run-{counter}"
47+
last_run_path.mkdir()
48+
val = config.last_run = last_run_path
49+
results.put(val)
50+
except Exception as err:
51+
results.put(err)
52+
53+
total = 20
54+
for i in range(total):
55+
threads.append(threading.Thread(target=create_last_run, args=(config, i)))
56+
for t in threads:
57+
t.start()
58+
for t in threads:
59+
t.join()
60+
61+
all_good = True
62+
for _ in threads:
63+
value = results.get()
64+
if isinstance(value, Exception):
65+
# Print exception for logging
66+
print(value)
67+
all_good = False
68+
assert all_good
69+
# Getting into race is not certain, do not assert
70+
# assert mock_logger.called
71+
assert config.last_run, "Some run was stored as last run"
72+
73+
74+
def test_link_config_invalid(config_path: Path):
75+
config_yaml = textwrap.dedent("""
76+
issue-tracker:
77+
- type: jiRA
78+
url: invalid_url
79+
tmt-web-url: https://
80+
unknown: value
81+
additional_key:
82+
foo: bar
83+
""").strip()
84+
fmf.Tree.init(path=config_path)
85+
(config_path / 'link.fmf').write_text(config_yaml)
86+
87+
with pytest.raises(tmt.utils.MetadataError) as error:
88+
_ = tmt.config.Config().link
89+
90+
cause = str(error.value.__cause__)
91+
assert '6 validation errors for LinkConfig' in cause
92+
assert re.search(r'type\s*value is not a valid enumeration member', cause)
93+
assert re.search(r'url\s*invalid or missing URL scheme', cause)
94+
assert re.search(r'tmt-web-url\s*URL host invalid', cause)
95+
assert re.search(r'unknown\s*extra fields not permitted', cause)
96+
assert re.search(r'token\s*field required', cause)
97+
assert re.search(r'additional_key\s*extra fields not permitted', cause)
98+
99+
100+
def test_link_config_valid(config_path: Path):
101+
config_yaml = textwrap.dedent("""
102+
issue-tracker:
103+
- type: jira
104+
url: https://issues.redhat.com
105+
tmt-web-url: https://tmt-web-url.com
106+
token: secret
107+
""").strip()
108+
fmf.Tree.init(path=config_path)
109+
(config_path / 'link.fmf').write_text(config_yaml)
110+
111+
link = tmt.config.Config().link
112+
113+
assert link.issue_tracker[0].type == 'jira'
114+
assert link.issue_tracker[0].url == 'https://issues.redhat.com'
115+
assert link.issue_tracker[0].tmt_web_url == 'https://tmt-web-url.com'
116+
assert link.issue_tracker[0].token == 'secret'
117+
118+
119+
def test_link_config_missing(config_path: Path):
120+
fmf.Tree.init(path=config_path)
121+
122+
assert tmt.config.Config().link is None
123+
124+
125+
def test_link_config_empty(config_path: Path):
126+
fmf.Tree.init(path=config_path)
127+
(config_path / 'link.fmf').touch()
128+
129+
with pytest.raises(tmt.utils.SpecificationError) as error:
130+
_ = tmt.config.Config().link
131+
132+
cause = str(error.value.__cause__)
133+
assert '1 validation error for LinkConfig' in cause
134+
assert re.search(r'issue-tracker\s*field required', cause)

tests/unit/test_utils.py

Lines changed: 7 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import pytest
1818

1919
import tmt
20+
import tmt.config
2021
import tmt.log
2122
import tmt.plugins
2223
import tmt.steps.discover
@@ -204,56 +205,6 @@ def test_inject_auth_git_url(monkeypatch) -> None:
204205
inject_auth_git_url('https://example.com/broken/something')
205206

206207

207-
def test_config():
208-
""" Config smoke test """
209-
run = Path('/var/tmp/tmt/test')
210-
config1 = tmt.utils.Config()
211-
config1.last_run = run
212-
config2 = tmt.utils.Config()
213-
assert config2.last_run.resolve() == run.resolve()
214-
215-
216-
def test_last_run_race(tmppath: Path, monkeypatch):
217-
""" Race in last run symlink shouldn't be fatal """
218-
config_path = tmppath / 'config'
219-
config_path.mkdir()
220-
monkeypatch.setattr(tmt.utils, 'effective_config_dir', MagicMock(return_value=config_path))
221-
mock_logger = unittest.mock.MagicMock()
222-
monkeypatch.setattr(tmt.utils.log, 'warning', mock_logger)
223-
config = tmt.utils.Config()
224-
results = queue.Queue()
225-
threads = []
226-
227-
def create_last_run(config, counter):
228-
try:
229-
last_run_path = tmppath / f"run-{counter}"
230-
last_run_path.mkdir()
231-
val = config.last_run = last_run_path
232-
results.put(val)
233-
except Exception as err:
234-
results.put(err)
235-
236-
total = 20
237-
for i in range(total):
238-
threads.append(threading.Thread(target=create_last_run, args=(config, i)))
239-
for t in threads:
240-
t.start()
241-
for t in threads:
242-
t.join()
243-
244-
all_good = True
245-
for _ in threads:
246-
value = results.get()
247-
if isinstance(value, Exception):
248-
# Print exception for logging
249-
print(value)
250-
all_good = False
251-
assert all_good
252-
# Getting into race is not certain, do not assert
253-
# assert mock_logger.called
254-
assert config.last_run, "Some run was stored as last run"
255-
256-
257208
def test_workdir_env_var(tmppath: Path, monkeypatch, root_logger):
258209
""" Test TMT_WORKDIR_ROOT environment variable """
259210
# Cannot use monkeypatch.context() as it is not present for CentOS Stream 8
@@ -1737,9 +1688,9 @@ def tearDown(self):
17371688
shutil.rmtree(self.tmp)
17381689

17391690
@unittest.mock.patch('jira.JIRA.add_simple_link')
1740-
@unittest.mock.patch('tmt.utils.Config')
1691+
@unittest.mock.patch('tmt.config.Config.fmf_tree', new_callable=unittest.mock.PropertyMock)
17411692
def test_jira_link_test_only(self, mock_config_tree, mock_add_simple_link) -> None:
1742-
mock_config_tree.return_value.fmf_tree = self.config_tree
1693+
mock_config_tree.return_value = self.config_tree
17431694
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
17441695
tmt.utils.jira.link(
17451696
tmt_objects=[test],
@@ -1752,9 +1703,9 @@ def test_jira_link_test_only(self, mock_config_tree, mock_add_simple_link) -> No
17521703
assert '&test-path=%2Ftests%2Funit%2Ftmp' in result['url']
17531704

17541705
@unittest.mock.patch('jira.JIRA.add_simple_link')
1755-
@unittest.mock.patch('tmt.utils.Config')
1706+
@unittest.mock.patch('tmt.config.Config.fmf_tree', new_callable=unittest.mock.PropertyMock)
17561707
def test_jira_link_test_plan_story(self, mock_config_tree, mock_add_simple_link) -> None:
1757-
mock_config_tree.return_value.fmf_tree = self.config_tree
1708+
mock_config_tree.return_value = self.config_tree
17581709
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
17591710
plan = tmt.Tree(logger=self.logger, path=self.tmp).plans(names=['tmp'])[0]
17601711
story = tmt.Tree(logger=self.logger, path=self.tmp).stories(names=['tmp'])[0]
@@ -1778,9 +1729,9 @@ def test_jira_link_test_plan_story(self, mock_config_tree, mock_add_simple_link)
17781729
assert '&story-path=%2Ftests%2Funit%2Ftmp' in result['url']
17791730

17801731
@unittest.mock.patch('jira.JIRA.add_simple_link')
1781-
@unittest.mock.patch('tmt.utils.Config')
1732+
@unittest.mock.patch('tmt.config.Config.fmf_tree', new_callable=unittest.mock.PropertyMock)
17821733
def test_create_link_relation(self, mock_config_tree, mock_add_simple_link) -> None:
1783-
mock_config_tree.return_value.fmf_tree = self.config_tree
1734+
mock_config_tree.return_value = self.config_tree
17841735
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
17851736
tmt.utils.jira.link(
17861737
tmt_objects=[test],

tmt/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import tmt.base
3838
import tmt.checks
39+
import tmt.config
3940
import tmt.convert
4041
import tmt.export
4142
import tmt.frameworks
@@ -3387,7 +3388,7 @@ def __init__(self,
33873388
logger: tmt.log.Logger) -> None:
33883389
""" Initialize tree, workdir and plans """
33893390
# Use the last run id if requested
3390-
self.config = tmt.utils.Config()
3391+
self.config = tmt.config.Config()
33913392

33923393
if cli_invocation is not None:
33933394
if cli_invocation.options.get('last'):

tmt/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import tmt
1818
import tmt.base
19+
import tmt.config
1920
import tmt.convert
2021
import tmt.export
2122
import tmt.identifier
@@ -2171,7 +2172,7 @@ def completion(**kwargs: Any) -> None:
21712172

21722173
def setup_completion(shell: str, install: bool, context: Context) -> None:
21732174
""" Setup completion based on the shell """
2174-
config = tmt.utils.Config()
2175+
config = tmt.config.Config()
21752176
# Fish gets installed into its special location where it is automatically
21762177
# loaded.
21772178
if shell == 'fish':

tmt/config/__init__.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import functools
2+
import os
3+
from contextlib import suppress
4+
from typing import Optional, cast
5+
6+
import fmf
7+
import fmf.utils
8+
from pydantic import ValidationError
9+
10+
import tmt.utils
11+
from tmt._compat.pathlib import Path
12+
from tmt.config.models.link import LinkConfig
13+
14+
# Config directory
15+
DEFAULT_CONFIG_DIR = Path('~/.config/tmt')
16+
17+
18+
def effective_config_dir() -> Path:
19+
"""
20+
Find out what the actual config directory is.
21+
22+
If ``TMT_CONFIG_DIR`` variable is set, it is used. Otherwise,
23+
:py:const:`DEFAULT_CONFIG_DIR` is picked.
24+
"""
25+
26+
if 'TMT_CONFIG_DIR' in os.environ:
27+
return Path(os.environ['TMT_CONFIG_DIR']).expanduser()
28+
29+
return DEFAULT_CONFIG_DIR.expanduser()
30+
31+
32+
class Config:
33+
""" User configuration """
34+
35+
def __init__(self) -> None:
36+
""" Initialize config directory path """
37+
self.path = effective_config_dir()
38+
self.logger = tmt.utils.log
39+
40+
try:
41+
self.path.mkdir(parents=True, exist_ok=True)
42+
except OSError as error:
43+
raise tmt.utils.GeneralError(
44+
f"Failed to create config '{self.path}'.") from error
45+
46+
@property
47+
def _last_run_symlink(self) -> Path:
48+
return self.path / 'last-run'
49+
50+
@property
51+
def last_run(self) -> Optional[Path]:
52+
""" Get the last run workdir path """
53+
return self._last_run_symlink.resolve() if self._last_run_symlink.is_symlink() else None
54+
55+
@last_run.setter
56+
def last_run(self, workdir: Path) -> None:
57+
""" Set the last run to the given run workdir """
58+
59+
with suppress(OSError):
60+
self._last_run_symlink.unlink()
61+
62+
try:
63+
self._last_run_symlink.symlink_to(workdir)
64+
except FileExistsError:
65+
# Race when tmt runs in parallel
66+
self.logger.warning(
67+
f"Unable to mark '{workdir}' as the last run, "
68+
"'tmt run --last' might not pick the right run directory.")
69+
except OSError as error:
70+
raise tmt.utils.GeneralError(
71+
f"Unable to save last run '{self.path}'.\n{error}")
72+
73+
@functools.cached_property
74+
def fmf_tree(self) -> fmf.Tree:
75+
""" Return the configuration tree """
76+
try:
77+
return fmf.Tree(self.path)
78+
except fmf.utils.RootError as error:
79+
raise tmt.utils.MetadataError(f"Config tree not found in '{self.path}'.") from error
80+
81+
@property
82+
def link(self) -> Optional[LinkConfig]:
83+
""" Return the link configuration, if present. """
84+
link_config = cast(Optional[fmf.Tree], self.fmf_tree.find('/link'))
85+
if not link_config:
86+
return None
87+
try:
88+
return LinkConfig.parse_obj(link_config.data)
89+
except ValidationError as error:
90+
raise tmt.utils.SpecificationError(
91+
f"Invalid link configuration in '{link_config.name}'.") from error

0 commit comments

Comments
 (0)