Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add link config validation #3339

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ repos:
- "jinja2>=2.11.3" # 3.1.2 / 3.1.2
- "packaging>=20" # 20 seems to be available with RHEL8
- "pint>=0.16.1" # 0.16.1
- "pydantic>=1.10.14"
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
- "requests>=2.25.1" # 2.28.2 / 2.31.0
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
Expand Down Expand Up @@ -86,6 +87,7 @@ repos:
- "jinja2>=2.11.3" # 3.1.2 / 3.1.2
- "packaging>=20" # 20 seems to be available with RHEL8
- "pint>=0.16.1" # 0.16.1 / 0.19.x TODO: Pint 0.20 requires larger changes to tmt.hardware
- "pydantic>=1.10.14"
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
- "requests>=2.25.1" # 2.28.2 / 2.31.0
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
Expand Down Expand Up @@ -158,6 +160,7 @@ repos:
- "docutils>=0.16" # 0.16 is the current one available for RHEL9
- "packaging>=20" # 20 seems to be available with RHEL8
- "pint<0.20"
- "pydantic>=1.10.14"
- "pygments>=2.7.4" # 2.7.4 is the current one available for RHEL9
# Help installation by reducing the set of inspected botocore release.
# There is *a lot* of them, and hatch might fetch many of them.
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [ # F39 / PyPI
"jinja2>=2.11.3", # 3.1.2 / 3.1.2
"packaging>=20", # 20 seems to be available with RHEL8
"pint>=0.16.1", # 0.16.1
"pydantic>=1.10.14",
"pygments>=2.7.4", # 2.7.4 is the current one available for RHEL9
"requests>=2.25.1", # 2.28.2 / 2.31.0
"ruamel.yaml>=0.16.6", # 0.17.32 / 0.17.32
Expand Down Expand Up @@ -242,6 +243,7 @@ ignore = [
"docs/**",
"examples/**",
"tests/**",
"tmt/_compat/pydantic.py",
"tmt/export/*.py",
"tmt/plugins/*.py",
"tmt/steps/*.py",
Expand Down Expand Up @@ -413,11 +415,15 @@ builtins-ignorelist = ["help", "format", "input", "filter", "copyright", "max"]
"typing_extensions.Self".msg = "Use tmt._compat.typing.Self instead."
"pathlib.Path".msg = "Use tmt._compat.pathlib.Path instead."
"pathlib.PosixPath".msg = "Use tmt._compat.pathlib.Path instead."
"pydantic".msg = "Use tmt._compat.pydantic instead."
"warnings.deprecated".msg = "Use tmt._compat.warnings.deprecated instead."
"os.path".msg = "Use tmt._compat.pathlib.Path and pathlib instead."
# Banning builtins is not yet supported: https://github.com/astral-sh/ruff/issues/10079
# "builtins.open".msg = "Use Path.{write_text,append_text,read_text,write_bytes,read_bytes} instead."

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = ["tmt.config.models.BaseConfig"]

[tool.ruff.lint.isort]
known-first-party = ["tmt"]

Expand Down
134 changes: 134 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import queue
import re
import textwrap
import threading
import unittest
import unittest.mock
from unittest.mock import MagicMock

import fmf
import pytest

import tmt.config
from tmt.utils import Path


@pytest.fixture
def config_path(tmppath: Path, monkeypatch) -> Path:
config_path = tmppath / 'config'
config_path.mkdir()
monkeypatch.setattr(tmt.config, 'effective_config_dir', MagicMock(return_value=config_path))
return config_path


def test_config(config_path: Path):
""" Config smoke test """
run = Path('/var/tmp/tmt/test')
config1 = tmt.config.Config()
config1.last_run = run
config2 = tmt.config.Config()
assert config2.last_run.resolve() == run.resolve()


def test_last_run_race(tmppath: Path, monkeypatch):
""" Race in last run symlink shouldn't be fatal """
config_path = tmppath / 'config'
config_path.mkdir()
monkeypatch.setattr(tmt.config, 'effective_config_dir', MagicMock(return_value=config_path))
mock_logger = unittest.mock.MagicMock()
monkeypatch.setattr(tmt.utils.log, 'warning', mock_logger)
config = tmt.config.Config()
results = queue.Queue()
threads = []

def create_last_run(config, counter):
try:
last_run_path = tmppath / f"run-{counter}"
last_run_path.mkdir()
val = config.last_run = last_run_path
results.put(val)
except Exception as err:
results.put(err)

total = 20
for i in range(total):
threads.append(threading.Thread(target=create_last_run, args=(config, i)))
for t in threads:
t.start()
for t in threads:
t.join()

all_good = True
for _ in threads:
value = results.get()
if isinstance(value, Exception):
# Print exception for logging
print(value)
all_good = False
assert all_good
# Getting into race is not certain, do not assert
# assert mock_logger.called
assert config.last_run, "Some run was stored as last run"


def test_link_config_invalid(config_path: Path):
config_yaml = textwrap.dedent("""
issue-tracker:
- type: jiRA
url: invalid_url
tmt-web-url: https://
unknown: value
additional_key:
foo: bar
""").strip()
fmf.Tree.init(path=config_path)
(config_path / 'link.fmf').write_text(config_yaml)

with pytest.raises(tmt.utils.MetadataError) as error:
_ = tmt.config.Config().link

cause = str(error.value.__cause__)
assert '6 validation errors for LinkConfig' in cause
assert re.search(r'type\s*value is not a valid enumeration member', cause)
assert re.search(r'url\s*invalid or missing URL scheme', cause)
assert re.search(r'tmt-web-url\s*URL host invalid', cause)
assert re.search(r'unknown\s*extra fields not permitted', cause)
assert re.search(r'token\s*field required', cause)
assert re.search(r'additional_key\s*extra fields not permitted', cause)


def test_link_config_valid(config_path: Path):
config_yaml = textwrap.dedent("""
issue-tracker:
- type: jira
url: https://issues.redhat.com
tmt-web-url: https://tmt-web-url.com
token: secret
""").strip()
fmf.Tree.init(path=config_path)
(config_path / 'link.fmf').write_text(config_yaml)

link = tmt.config.Config().link

assert link.issue_tracker[0].type == 'jira'
assert link.issue_tracker[0].url == 'https://issues.redhat.com'
assert link.issue_tracker[0].tmt_web_url == 'https://tmt-web-url.com'
assert link.issue_tracker[0].token == 'secret'


def test_link_config_missing(config_path: Path):
fmf.Tree.init(path=config_path)

assert tmt.config.Config().link is None


def test_link_config_empty(config_path: Path):
fmf.Tree.init(path=config_path)
(config_path / 'link.fmf').touch()

with pytest.raises(tmt.utils.SpecificationError) as error:
_ = tmt.config.Config().link

cause = str(error.value.__cause__)
assert '1 validation error for LinkConfig' in cause
assert re.search(r'issue-tracker\s*field required', cause)
63 changes: 7 additions & 56 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import pytest

import tmt
import tmt.config
import tmt.log
import tmt.plugins
import tmt.steps.discover
Expand Down Expand Up @@ -204,56 +205,6 @@ def test_inject_auth_git_url(monkeypatch) -> None:
inject_auth_git_url('https://example.com/broken/something')


def test_config():
""" Config smoke test """
run = Path('/var/tmp/tmt/test')
config1 = tmt.utils.Config()
config1.last_run = run
config2 = tmt.utils.Config()
assert config2.last_run.resolve() == run.resolve()


def test_last_run_race(tmppath: Path, monkeypatch):
""" Race in last run symlink shouldn't be fatal """
config_path = tmppath / 'config'
config_path.mkdir()
monkeypatch.setattr(tmt.utils, 'effective_config_dir', MagicMock(return_value=config_path))
mock_logger = unittest.mock.MagicMock()
monkeypatch.setattr(tmt.utils.log, 'warning', mock_logger)
config = tmt.utils.Config()
results = queue.Queue()
threads = []

def create_last_run(config, counter):
try:
last_run_path = tmppath / f"run-{counter}"
last_run_path.mkdir()
val = config.last_run = last_run_path
results.put(val)
except Exception as err:
results.put(err)

total = 20
for i in range(total):
threads.append(threading.Thread(target=create_last_run, args=(config, i)))
for t in threads:
t.start()
for t in threads:
t.join()

all_good = True
for _ in threads:
value = results.get()
if isinstance(value, Exception):
# Print exception for logging
print(value)
all_good = False
assert all_good
# Getting into race is not certain, do not assert
# assert mock_logger.called
assert config.last_run, "Some run was stored as last run"


def test_workdir_env_var(tmppath: Path, monkeypatch, root_logger):
""" Test TMT_WORKDIR_ROOT environment variable """
# Cannot use monkeypatch.context() as it is not present for CentOS Stream 8
Expand Down Expand Up @@ -1737,9 +1688,9 @@ def tearDown(self):
shutil.rmtree(self.tmp)

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

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

@unittest.mock.patch('jira.JIRA.add_simple_link')
@unittest.mock.patch('tmt.utils.Config')
@unittest.mock.patch('tmt.config.Config.fmf_tree', new_callable=unittest.mock.PropertyMock)
def test_create_link_relation(self, mock_config_tree, mock_add_simple_link) -> None:
mock_config_tree.return_value.fmf_tree = self.config_tree
mock_config_tree.return_value = self.config_tree
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
tmt.utils.jira.link(
tmt_objects=[test],
Expand Down
26 changes: 26 additions & 0 deletions tmt/_compat/pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# mypy: disable-error-code="assignment"
from __future__ import annotations

import pydantic

if pydantic.__version__.startswith('1.'):
from pydantic import (
BaseModel,
Extra,
HttpUrl,
ValidationError,
)
else:
from pydantic.v1 import (
BaseModel,
Extra,
HttpUrl,
ValidationError,
)

__all__ = [
"BaseModel",
"Extra",
"HttpUrl",
"ValidationError",
]
3 changes: 2 additions & 1 deletion tmt/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

import tmt.base
import tmt.checks
import tmt.config
import tmt.convert
import tmt.export
import tmt.frameworks
Expand Down Expand Up @@ -3387,7 +3388,7 @@ def __init__(self,
logger: tmt.log.Logger) -> None:
""" Initialize tree, workdir and plans """
# Use the last run id if requested
self.config = tmt.utils.Config()
self.config = tmt.config.Config()

if cli_invocation is not None:
if cli_invocation.options.get('last'):
Expand Down
3 changes: 2 additions & 1 deletion tmt/cli/_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import tmt
import tmt.base
import tmt.cli
import tmt.config
import tmt.convert
import tmt.export
import tmt.identifier
Expand Down Expand Up @@ -1927,7 +1928,7 @@ def completion(**kwargs: Any) -> None:

def setup_completion(shell: str, install: bool, context: Context) -> None:
""" Setup completion based on the shell """
config = tmt.utils.Config()
config = tmt.config.Config()
# Fish gets installed into its special location where it is automatically
# loaded.
if shell == 'fish':
Expand Down
Loading