diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..db12211
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,19 @@
+name: pre-commit
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ pre-commit:
+ runs-on: ubuntu-latest
+ env:
+ SKIP: no-commit-to-branch
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+ - uses: pre-commit/action@v3.0.1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..3150fc4
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,83 @@
+name: Run tests
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ python-version: ["3.12"] # Can be extended with future Python versions
+
+ steps:
+
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Set up Podman and Buildah
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y podman buildah
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: '${{ matrix.python-version }}'
+ cache: 'pip'
+
+ - name: Install pytest
+ run: |
+ python -m pip install pytest hatch
+ python -m pip install .
+
+
+ - name: Build the web image
+ run: |
+ buildah bud -t tmt-web:latest --build-arg PYTHON_VERSION=${{ matrix.python-version }} .
+
+ - name: Create Podman pod
+ run: |
+ podman pod create --name tmt-web-pod --infra-image=registry.k8s.io/pause:3.9 -p 8000:8000 -p 6379:6379
+ # Exposing redis port as well for test_api.py::TestCelery::test_basic_test_request
+
+ - name: Start Redis container
+ run: |
+ podman run -d --pod tmt-web-pod --name redis redis:latest
+
+ - name: Start Celery container
+ run: |
+ podman run -d --pod tmt-web-pod --name celery \
+ -e REDIS_URL=redis://localhost:6379 \
+ -e API_HOSTNAME=http://localhost:8000 \
+ tmt-web:latest celery --app=tmt_web.api.service worker --loglevel=INFO
+
+ - name: Start Web container
+ run: |
+ podman run -d --pod tmt-web-pod --name web \
+ -e REDIS_URL=redis://localhost:6379 \
+ -e API_HOSTNAME=http://localhost:8000 \
+ tmt-web:latest uvicorn tmt_web.api:app --reload --host 0.0.0.0 --port 8000
+
+ - name: Wait for services to be ready
+ run: |
+ for i in {1..30}; do
+ if curl -s http://localhost:8000/health; then
+ break
+ fi
+ sleep 4
+ done
+
+ - name: Run tests
+ run: |
+ python -m pytest
+
+ - name: Cleanup
+ if: always()
+ run: |
+ podman pod stop tmt-web-pod
+ podman pod rm tmt-web-pod
diff --git a/.gitignore b/.gitignore
index 99452ee..1d6f257 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,10 @@
.pytest_cache
-.tmp
+.repos
venv
+.venv
.idea
.vscode
-__pycache__
\ No newline at end of file
+dist
+.ruff_cache
+.mypy_cache
+__pycache__
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..4cfe3b5
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,35 @@
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: "v4.6.0"
+ hooks:
+ - id: check-merge-conflict
+ - id: check-symlinks
+ - id: check-toml
+ - id: destroyed-symlinks
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: mixed-line-ending
+ - id: no-commit-to-branch
+ args: [--branch, main]
+ - id: trailing-whitespace
+ args: [--markdown-linebreak-ext=md]
+
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: "v1.11.1"
+ hooks:
+ - id: mypy
+ language_version: "3.12"
+ additional_dependencies:
+ - 'tmt'
+ - 'pydantic'
+ - 'celery-types'
+
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: v0.6.1
+ hooks:
+ - id: ruff
+ args:
+ - '--fix'
+ - '--show-fixes'
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 0000000..9cd306c
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,13 @@
+ARG PYTHON_VERSION=3.12
+FROM python:${PYTHON_VERSION}
+
+RUN mkdir /app
+WORKDIR /app
+COPY README.md LICENSE pyproject.toml src/ ./
+
+RUN SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0.dev0 pip install .
+
+COPY /entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/README.md b/README.md
index fd016ac..b767632 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,81 @@
-# web
-Web app for checking tmt tests and plans
-# Run instructions
-1. Create a virtual environment
-2. Install the requirements
-3. Use the `start_api.sh` script to start the api
+# tmt web
+
+Web application for checking tmt tests, plans and stories.
+
+## Run instructions
+
+To run the service locally for development purposes, use the following command:
+
+```bash
+podman-compose up --build
+```
+
+Add `-d` for the service to run in the background.
+
## Tests
-To run the tests, use the pytest command
-# API
-API for checking tmt tests and plans metadata
-## Version
-The API version is defined by prefix in url, e.g.
-`/v0.1/status`
-## Endpoints
-* `/` - returns ID of the created Celery task with additional metadata in JSON and callback url for `/status` endpoint,
-returns the same in HTML format if `format` is set to `html`
- * `test-url` - URL of the repo test is located in - accepts a `string`
- * `test-ref` - Ref of the repository the test is located in - accepts a `string`,
- defaults to default branch of the repo
+To run the tests, use the `pytest` command (assuming the service is running).
+
+Alternatively, if you have `hatch` installed, `hatch run test:run` command will
+rebuild, start the service and run the tests.
+
+## Environment variables
+
+- `REDIS_URL` - *optional*, passed to Celery on initialization as a `broker` and
+ `backend` argument, default value is: `redis://localhost:6379`
+- `CLONE_DIR_PATH` - *optional*, specifies the path where the repositories will
+ be cloned, default value is: `./.repos/`
+- `USE_CELERY` - *optional*, specifies if the app should use Celery, set to
+ `false` for running without Celery
+- `API_HOSTNAME` - *required*, specifies the hostname of the API, used for
+ creating the callback URL to the service
+
+## API
+
+The API version is defined by prefix in url, e.g. `/v0.1/status`.
+
+If we want to display metadata for both tests and plans, we can combine
+the `test-*` and `plan-*` options together, they are not mutually
+exclusive.
+
+`test-url` and `test-name`, or `plan-url` and `plan-name` are required.
+
+### `/`
+
+Returns ID of the created Celery task with additional metadata in JSON
+and callback url for `/status` endpoint, returns the same in HTML format
+if `format` is set to `html`.
+
+ * `test-url` - URL of the repo test is located in - accepts a `string`
+ * `test-ref` - Ref of the repository the test is located in - accepts
+ a `string`, defaults to default branch of the repo
* `test-path` - Points to directory where `fmf` tree is stored
* `test-name` - Name of the test - accepts a `string`
+
* `plan-url` - URL of the repo plan is located in - accepts a `string`
-
- * `plan-ref` - Ref of the repository the plan is located in - accepts a `string`,
- defaults to default branch of the repo
+ * `plan-ref` - Ref of the repository the plan is located in - accepts
+ a `string`, defaults to default branch of the repo
* `plan-path` - Points to directory where `fmf` tree is stored
* `plan-name` - Name of the plan - accepts a `string`
- * `format` - Format of the output - accepts a `string`, default is `json`, other options are `xml`, `html`
- (serves as a basic human-readable output format)
+
+ * `format` - Format of the output - accepts a `string`, default is
+ `json`, other options are `xml`, `html` (serves as a basic
+ human-readable output format)
* `id` - Unique ID of the tmt object
-* `/status` - returns a status of the tmt object being processed by the backend
+
+### `/status`
+
+Returns a status of the tmt object being processed by the backend.
+
* `task_id` - ID of the task - accepts a `string`
-* `/status/html` - returns a status of the tmt object being processed by the backend in a simple HTML formatting
+
+### `/status/html`
+
+Returns a status of the tmt object being processed by the backend in a
+simple HTML formatting.
+
* `task_id` - ID of the task - accepts a `string`
-* `/health` - returns a health status of the service
-If we want to display metadata for both tests and plans, we can combine the `test-*`
-and `plan-*` options together, they are not mutually exclusive.
+### `/health`
-`test-url` and `test-name`, or `plan-url` and `plan-name` are required.
+Returns a health status of the service.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..d8ab219
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,29 @@
+services:
+ web:
+ container_name: uvicorn
+ build:
+ context: .
+ dockerfile: ./Containerfile
+ command: uvicorn src.api:app --reload --host 0.0.0.0 --port 8000
+ environment:
+ - REDIS_URL=redis://redis:6379
+ - API_HOSTNAME=http://localhost:8000
+ ports:
+ - 8000:8000
+ redis:
+ container_name: redis
+ image: redis:latest
+ ports:
+ - 6379:6379
+
+ celery:
+ container_name: celery
+ build:
+ context: .
+ dockerfile: ./Containerfile
+ command: celery --app=src.api.service worker --loglevel=INFO
+ environment:
+ - REDIS_URL=redis://redis:6379
+ - API_HOSTNAME=http://localhost:8000
+ depends_on:
+ - redis
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 0000000..6e66c43
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+
+# Name of container to start
+APP=$1
+
+[ -z "$APP" ] && { error "No api to run passed to entrypoint script"; exit 1; }
+
+case $APP in
+ uvicorn)
+ COMMAND="uvicorn tmt_web.api:app --reload --host 0.0.0.0 --port 8000"
+ ;;
+ celery)
+ COMMAND="celery --app=tmt_web.api.service worker --loglevel=INFO"
+ ;;
+ *)
+ echo "Unknown app '$APP'"
+ exit 1
+ ;;
+esac
+
+$COMMAND &
+PID=$!
+
+wait $PID
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f8c9d91
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,142 @@
+[build-system]
+requires = ["hatchling", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "tmt-web"
+dynamic = ["version"]
+description = 'Web app for checking tmt tests, plans and stories'
+readme = "README.md"
+requires-python = ">=3.12"
+license = "MIT"
+keywords = []
+authors = [
+ { name = "Petr Splichal", email = "psplicha@redhat.com" },
+ { name = "Tomas Koscielniak", email = "tkosciel@redhat.com" }
+]
+classifiers = [
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.12",
+ "Framework :: FastAPI",
+ "Framework :: Celery",
+ "Topic :: Software Development :: Testing",
+ "Operating System :: POSIX :: Linux",
+]
+dependencies = [
+ "tmt~=1.36",
+ "fastapi~=0.115",
+ "httpx~=0.27",
+ "uvicorn~=0.30",
+ "celery[redis]~=5.4",
+]
+
+[project.urls]
+Source = "https://github.com/teemtee/web teemtee/tmt-web"
+
+[tool.hatch.version]
+source = "vcs"
+raw-options.version_scheme = "release-branch-semver"
+
+[tool.hatch.envs.dev]
+extra-dependencies = [
+ "pre-commit",
+ "mypy~=1.11.2",
+ "ruff~=0.6",
+ "hatch",
+ "podman-compose",
+ "pytest",
+]
+post-install-commands = [
+ "pre-commit install",
+]
+
+[tool.hatch.envs.test]
+extra-dependencies = [
+ "podman-compose",
+ "pytest",
+]
+
+[tool.hatch.envs.test.scripts]
+run = "podman-compose down && podman-compose up -d --build && pytest"
+check = "mypy --install-types --non-interactive src/tmt_web"
+
+[tool.ruff]
+# Based on teemtee/tmt/pyproject.toml
+line-length = 100
+target-version = "py312"
+lint.select = [
+ "F", # pyflakes
+ "E", # pycodestyle error
+ "W", # pycodestyle warning
+ "I", # isort
+ "N", # pep8-naming
+ #"D", # pydocstyle TODO
+ "UP", # pyupgrade
+ "YTT", # flake8-2020
+ "ASYNC", # flake8-async
+ "S", # flake8-bandit
+ "B", # flake8-bugbear
+ "A", # flake8-builtins
+ "COM", # flake8-commas
+ "C4", # flake8-comprehensions
+ "DTZ", # flake8-datetimez
+ "T10", # flake8-debugger
+ "EXE", # flake8-executable
+ "ISC", # flake8-implicit-str-concat
+ "ICN", # flake8-import-conventions
+ "LOG", # flake8-logging
+ "G", # flake8-logging-format
+ "PIE", # flake8-pie
+ "PYI", # flake8-pyi
+ "PT", # flake8-pytest-style
+ "Q003", # avoidable-escaped-quote
+ "Q004", # unnecessary-escaped-quote
+ "RSE", # flake8-raise
+ "RET", # flake8-return
+ "SIM", # flake8-simplify
+ "TID", # flake8-tidy-imports
+ "INT", # flake8-gettext
+ "PTH", # flake8-use-pathlib
+ "PGH", # pygrep-hooks
+ "PLC", # pylint-convention
+ "PLE", # pylint-error
+ "PLR", # pylint-refactor
+ "FLY", # flynt
+ "FAST", # FastAPI (in preview)
+ "PERF", # Perflint
+ "RUF", # ruff
+ ]
+lint.ignore = [
+ #"E501", # TODO line lenght
+ "PLR0913", # Too many arguments
+ "RET505", # Unnecessary 'else' after 'return'
+ "COM812", # Trailing comma missing
+]
+
+[tool.ruff.lint.per-file-ignores]
+# Less strict security checks in tests
+"tests/*" = [
+ "S101", # Assert usage
+ "PLR2004", # Magic value
+ "E501", # Line length
+ ]
+"src/tmt_web/generators/html_generator.py" = [
+ "W291", # Trailing whitespace
+ "E501", # Line length
+]
+
+[tool.mypy]
+plugins = [
+ "pydantic.mypy"
+]
+
+follow_imports = "silent"
+warn_redundant_casts = true
+warn_unused_ignores = true
+disallow_any_generics = true
+check_untyped_defs = true
+no_implicit_reexport = true
+
+python_version = "3.12"
+files = ["src/", "tests/"]
diff --git a/src/api.py b/src/api.py
deleted file mode 100644
index 842dd1e..0000000
--- a/src/api.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from flask import Flask, request
-from src import service
-
-app = Flask(__name__)
-
-
-# Sample url: https://tmt.org/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke
-# or for plans: https://tmt.org/?plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic
-@app.route("/", methods=["GET"])
-def find_test():
- test_url = request.args.get("test-url", default=None)
- test_name = request.args.get("test-name", default=None)
- test_ref = request.args.get("test-ref", default="main")
- if (test_url is None and test_name is not None) or (test_url is not None and test_name is None):
- return "Invalid arguments!"
- plan_url = request.args.get("plan-url", default=None)
- plan_name = request.args.get("plan-name", default=None)
- plan_ref = request.args.get("plan-ref", default="main")
- if (plan_url is None and plan_name is not None) or (plan_url is not None and plan_name is None):
- return "Invalid arguments!"
- html_page = service.main(test_url, test_name, test_ref, plan_url, plan_name, plan_ref)
- return html_page
-
-
-if __name__ == "__main__":
- app.run(debug=False)
diff --git a/src/html_generator.py b/src/html_generator.py
deleted file mode 100644
index 3989ce3..0000000
--- a/src/html_generator.py
+++ /dev/null
@@ -1,97 +0,0 @@
-import tmt
-from tmt import Test, Logger, Plan
-
-
-def generate_test_html_page(test: Test, logger: Logger) -> str:
- """
- This function generates an HTML file with the input data for a test
- :param test: Test object
- :param logger: tmt.Logger instance
- :return:
- """
- logger.print("Generating the HTML file...")
- full_url = test.web_link()
- # Adding the input data to the HTML file
- file_html = (f'''
-
- HTML File
-
-
-
- name: {test.name}
- summary: {test.summary}
- description: {test.description}
- url: {full_url}
- ref: {test.fmf_id.ref}
- contact: {test.contact}
-
- ''')
- logger.print("HTML file generated successfully!", color="green")
- return file_html
-
-
-def generate_plan_html_page(plan: Plan, logger: Logger) -> str:
- """
- This function generates an HTML file with the input data for a plan
- :param plan: Plan object
- :param logger: tmt.Logger instance
- :return:
- """
- logger.print("Generating the HTML file...")
- full_url = plan.web_link()
- # Adding the input data to the HTML file
- file_html = (f'''
-
- HTML File
-
-
-
- name: {plan.name}
- summary: {plan.summary}
- description: {plan.description}
- url: {full_url}
- ref: {plan.fmf_id.ref}
-
- ''')
- logger.print("HTML file generated successfully!", color="green")
- return file_html
-
-
-def generate_testplan_html_page(test: tmt.Test, plan: tmt.Plan, logger: Logger) -> str:
- """
- This function generates an HTML file with the input data for a test and a plan
- :param test: Test object
- :param plan: Plan object
- :param logger: tmt.Logger instance
- :return:
- """
- logger.print("Generating the HTML file...")
- full_url_test = test.web_link()
- full_url_plan = plan.web_link()
- # Adding the input data to the HTML file
- file_html = (f'''
-
- HTML File
-
-
-
- name: {test.name}
- summary: {test.summary}
- description: {test.description}
- url: {full_url_test}
- ref: {test.fmf_id.ref}
- contact: {test.contact}
-
- name: {plan.name}
- summary: {plan.summary}
- description: {plan.description}
- url: {full_url_plan}
- ref: {plan.fmf_id.ref}
-
- ''')
- logger.print("HTML file generated successfully!", color="green")
- return file_html
-
-
-if __name__ == "__main__":
- print("This is not executable file!")
diff --git a/src/requirements.txt b/src/requirements.txt
deleted file mode 100644
index 7dd0a2b..0000000
--- a/src/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-Flask>=2.2.x
-fmf==1.3.0
-pytest==8.0.2
-requests==2.31.0
-tmt==1.31.0
diff --git a/src/service.py b/src/service.py
deleted file mode 100644
index e5368b4..0000000
--- a/src/service.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import sys
-import tmt
-import logging
-from pathlib import Path
-from src import html_generator as html
-from src.utils import git_handler as utils
-
-logger = tmt.Logger(logging.Logger("tmt-logger"))
-
-
-def process_test_request(test_url: str, test_name: str, test_ref: str, return_html: bool) -> str | None | tmt.Test:
- """
- This function processes the request for a test and returns the HTML file or the Test object
- :param test_url: Test url
- :param test_name: Test name
- :param test_ref: Test repo ref
- :param return_html: Specify if the function should return the HTML file or the Test object
- :return:
- """
- logger.print("Cloning the repository for url: " + test_url)
- logger.print("Parsing the url and name...")
- logger.print("URL: " + test_url)
- logger.print("Name: " + test_name)
-
- utils.get_git_repository(test_url, logger)
-
- repo_name = test_url.rsplit('/', 1)[-1]
- logger.print("Looking for tree...")
- tree = tmt.base.Tree(path=Path("../.tmp/" + repo_name), logger=logger)
- logger.print("Tree found!", color="green")
- logger.print("Looking for the wanted test...")
-
- test_list = tree.tests()
- wanted_test = None
- # Find the desired Test object
- for test in test_list:
- if test.name == test_name:
- wanted_test = test
- break
- if wanted_test is None:
- logger.print("Test not found!", color="red")
- return None
- logger.print("Test found!", color="green")
- if not return_html:
- return wanted_test
- return html.generate_test_html_page(wanted_test, logger=logger)
-
-
-def process_plan_request(plan_url: str, plan_name: str, plan_ref: str, return_html: bool) -> str | None | tmt.Plan:
- """
- This function processes the request for a plan and returns the HTML file or the Plan object
- :param plan_url: Plan URL
- :param plan_name: Plan name
- :param plan_ref: Plan repo ref
- :param return_html: Specify if the function should return the HTML file or the Plan object
- :return:
- """
- logger.print("Cloning the repository for url: " + plan_url)
- logger.print("Parsing the url and name...")
- logger.print("URL: " + plan_url)
- logger.print("Name: " + plan_name)
-
- utils.get_git_repository(plan_url, logger)
-
- repo_name = plan_url.rsplit('/', 1)[-1]
- logger.print("Looking for tree...")
- tree = tmt.base.Tree(path=Path("../.tmp/" + repo_name), logger=logger)
- logger.print("Tree found!", color="green")
- logger.print("Looking for the wanted plan...")
-
- plan_list = tree.plans()
- wanted_plan = None
- # Find the desired Test object
- for plan in plan_list:
- if plan.name == plan_name:
- wanted_plan = plan
- break
- if wanted_plan is None:
- logger.print("Plan not found!", color="red")
- return None
- logger.print("Plan found!", color="green")
- if not return_html:
- return wanted_plan
- return html.generate_plan_html_page(wanted_plan, logger=logger)
-
-
-def process_testplan_request(test_url, test_name, test_ref, plan_url, plan_name, plan_ref) -> str | None:
- """
- This function processes the request for a test and a plan and returns the HTML file
- :param test_url: Test URL
- :param test_name: Test name
- :param test_ref: Test repo ref
- :param plan_url: Plan URL
- :param plan_name: Plan name
- :param plan_ref: Plan repo ref
- :return:
- """
- test = process_test_request(test_url, test_name, test_ref, False)
- plan = process_plan_request(plan_url, plan_name, plan_ref, False)
- return html.generate_testplan_html_page(test, plan, logger=logger)
-
-
-def main(test_url: str | None,
- test_name: str | None,
- test_ref: str | None,
- plan_url: str | None,
- plan_name: str | None,
- plan_ref: str | None) -> str | None:
- logger.print("Starting...", color="blue")
- if test_name is not None and plan_name is None:
- return process_test_request(test_url, test_name, test_ref, True)
- elif plan_name is not None and test_name is None:
- return process_plan_request(plan_url, plan_name, plan_ref, True)
- elif plan_name is not None and test_name is not None:
- return process_testplan_request(test_url, test_name, test_ref, plan_url, plan_name, plan_ref)
-
-
-if __name__ == "__main__":
- print("This is not executable file!")
diff --git a/src/start_api.sh b/src/start_api.sh
deleted file mode 100644
index 6a0506e..0000000
--- a/src/start_api.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/usr/bin/env bash
-source ../venv/bin/activate
-flask --app api run
diff --git a/src/__init__.py b/src/tmt_web/__init__.py
similarity index 100%
rename from src/__init__.py
rename to src/tmt_web/__init__.py
diff --git a/src/tmt_web/api.py b/src/tmt_web/api.py
new file mode 100644
index 0000000..45f6639
--- /dev/null
+++ b/src/tmt_web/api.py
@@ -0,0 +1,170 @@
+import os
+from typing import Annotated, Any, Literal
+
+from celery.result import AsyncResult
+from fastapi import FastAPI
+from fastapi.params import Query
+from pydantic import BaseModel
+from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse
+
+from tmt_web import service, settings
+from tmt_web.generators import html_generator
+
+app = FastAPI()
+
+
+class TaskOut(BaseModel):
+ id: str
+ status: str
+ result: str | None = None
+ status_callback_url: str | None = None
+
+
+# Sample url: https://tmt.org/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke
+# or for plans: https://tmt.org/?plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic
+@app.get("/")
+def find_test(
+ test_url: Annotated[
+ str | None,
+ Query(
+ alias="test-url",
+ title="Test URL",
+ description="URL of a Git repository containing test metadata",
+ ),
+ ] = None,
+ test_name: Annotated[
+ str | None,
+ Query(
+ alias="test-name",
+ title="Test name",
+ description="Name of the test",
+ ),
+ ] = None,
+ test_ref: Annotated[
+ str | None,
+ Query(
+ alias="test-ref",
+ title="Test reference",
+ description="Reference of the test repository",
+ ),
+ ] = None,
+ test_path: Annotated[
+ str | None,
+ Query(
+ alias="test-path",
+ title="Test path",
+ description="Path to the test metadata directory",
+ ),
+ ] = None,
+ plan_url: Annotated[
+ str | None,
+ Query(
+ alias="plan-url",
+ title="Plan URL",
+ description="URL of a Git repository containing plan metadata",
+ ),
+ ] = None,
+ plan_name: Annotated[
+ str | None,
+ Query(
+ alias="plan-name",
+ title="Plan name",
+ description="Name of the plan",
+ ),
+ ] = None,
+ plan_ref: Annotated[
+ str | None,
+ Query(
+ alias="plan-ref",
+ title="Plan reference",
+ description="Reference of the plan repository",
+ ),
+ ] = None,
+ plan_path: Annotated[
+ str | None,
+ Query(
+ alias="plan-path",
+ title="Plan path",
+ description="Path to the plan metadata directory",
+ ),
+ ] = None,
+ out_format: Annotated[Literal["html", "json", "yaml"], Query(alias="format")] = "json",
+) -> TaskOut | str | Any:
+ # Parameter validations
+ if (test_url is None and test_name is not None) or (test_url is not None and test_name is None):
+ return "Invalid arguments!"
+ if (plan_url is None and plan_name is not None) or (plan_url is not None and plan_name is None):
+ return "Invalid arguments!"
+ if plan_url is None and plan_name is None and test_url is None and test_name is None:
+ return "Missing arguments!"
+ # TODO: forward to docs
+
+ service_args = {
+ "test_url": test_url,
+ "test_name": test_name,
+ "test_ref": test_ref,
+ "plan_url": plan_url,
+ "plan_name": plan_name,
+ "plan_ref": plan_ref,
+ "out_format": out_format,
+ "test_path": test_path,
+ "plan_path": plan_path,
+ }
+
+ # Disable Celery if not needed
+ if os.environ.get("USE_CELERY") == "false":
+ response_by_output = {
+ "html": HTMLResponse,
+ "json": JSONResponse,
+ "yaml": PlainTextResponse,
+ }
+
+ response = response_by_output.get(out_format, PlainTextResponse)
+ return response(service.main(**service_args))
+
+ r = service.main.delay(**service_args)
+
+ # Special handling of response if the format is html
+ # TODO: Shouldn't be the "yaml" format also covered with a `PlainTextResponse`?
+ if out_format == "html":
+ status_callback_url = f"{settings.API_HOSTNAME}/status/html?task-id={r.task_id}"
+ return HTMLResponse(content=html_generator.generate_status_callback(r, status_callback_url))
+
+ return _to_task_out(r)
+
+
+@app.get("/status")
+def status(task_id: Annotated[str | None,
+ Query(
+ alias="task-id",
+ title="Task ID",
+ )
+ ]) -> TaskOut | str:
+ r = service.main.app.AsyncResult(task_id)
+ return _to_task_out(r)
+
+
+@app.get("/status/html")
+def status_html(task_id: Annotated[str | None,
+ Query(
+ alias="task-id",
+ title="Task ID",
+ )
+ ]) -> HTMLResponse:
+ r = service.main.app.AsyncResult(task_id)
+ status_callback_url = f"{settings.API_HOSTNAME}/status/html?task-id={r.task_id}"
+ return HTMLResponse(content=html_generator.generate_status_callback(r, status_callback_url))
+
+
+def _to_task_out(r: AsyncResult) -> TaskOut: # type: ignore [type-arg]
+ return TaskOut(
+ id=r.task_id,
+ status=r.status,
+ result=r.traceback if r.failed() else r.result,
+ status_callback_url="{settings.API_HOSTNAME}/status?task-id={r.task_id}",
+ )
+
+
+@app.get("/health")
+def health_check():
+ return {"status": "healthy"}
diff --git a/src/utils/__init__.py b/src/tmt_web/generators/__init__.py
similarity index 100%
rename from src/utils/__init__.py
rename to src/tmt_web/generators/__init__.py
diff --git a/src/tmt_web/generators/html_generator.py b/src/tmt_web/generators/html_generator.py
new file mode 100644
index 0000000..e9d3e3d
--- /dev/null
+++ b/src/tmt_web/generators/html_generator.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+from celery.result import AsyncResult
+from jinja2 import Environment, FileSystemLoader
+from tmt import Logger, Plan, Test
+
+templ_dir = Path(__file__).resolve().parent / "templates"
+
+env = Environment(loader=FileSystemLoader(str(templ_dir)), autoescape=True)
+
+
+def render_template(template_name: str, **kwargs) -> str:
+ template = env.get_template(template_name)
+ return template.render(**kwargs)
+
+
+def generate_status_callback(r: AsyncResult, status_callback_url: str) -> str: # type: ignore [type-arg]
+ data = {
+ "status": r.status,
+ "status_callback_url": status_callback_url,
+ "result": r.result
+ }
+ return render_template("status_callback.html.j2", **data)
+
+
+def generate_html_page(obj: Test | Plan, logger: Logger) -> str:
+ logger.print("Generating the HTML file...")
+ return render_template("testorplan.html.j2", testorplan=obj)
+
+
+def generate_testplan_html_page(test: Test, plan: Plan, logger: Logger) -> str:
+ logger.print("Generating the HTML file...")
+ return render_template("testandplan.html.j2", test=test, plan=plan)
diff --git a/src/tmt_web/generators/json_generator.py b/src/tmt_web/generators/json_generator.py
new file mode 100644
index 0000000..3cf76e0
--- /dev/null
+++ b/src/tmt_web/generators/json_generator.py
@@ -0,0 +1,73 @@
+import json
+from typing import Any
+
+import tmt.utils
+from tmt import Plan, Test
+
+
+def _create_json_data(obj: Test | Plan, logger: tmt.Logger) -> dict[str, Any]:
+ """
+ Helper function to create the JSON data from a test or plan object.
+ """
+ full_url = obj.web_link()
+ return {
+ "name": obj.name,
+ "summary": obj.summary,
+ "description": obj.description,
+ "url": full_url,
+ "ref": obj.fmf_id.ref,
+ "contact": obj.contact,
+ "tag": obj.tag,
+ "tier": obj.tier,
+ "id": obj.id,
+ "fmf-id": {
+ "url": obj.fmf_id.url,
+ "path": obj.fmf_id.path.as_posix() if obj.fmf_id.path is not None else None,
+ "name": obj.fmf_id.name,
+ "ref": obj.fmf_id.ref,
+ }
+ }
+
+
+def generate_test_json(test: tmt.Test, logger: tmt.Logger) -> str:
+ """
+ This function generates an JSON file with the input data for a test.
+
+ :param test: Test object
+ :param logger: tmt.Logger instance
+ :return: JSON data for a given test
+ """
+ data = _create_json_data(test, logger)
+ logger.print("Generating the JSON file...")
+ return json.dumps(data)
+
+
+def generate_plan_json(plan: tmt.Plan, logger: tmt.Logger) -> str:
+ """
+ This function generates an JSON file with the input data for a plan.
+
+ :param plan: Plan object
+ :param logger: tmt.Logger instance
+ :return: JSON data for a given plan
+ """
+ data = _create_json_data(plan, logger)
+ logger.print("Generating the JSON file...")
+ return json.dumps(data)
+
+
+def generate_testplan_json(test: tmt.Test, plan: tmt.Plan, logger: tmt.Logger) -> str:
+ """
+ This function generates an JSON file with the input data for a test and a plan.
+
+ :param test: Test object
+ :param plan: Plan object
+ :param logger: tmt.Logger instance
+ :return: JSON data for a given test and plan
+ """
+ logger.print("Generating the JSON file...")
+ data = {
+ "test": _create_json_data(test, logger),
+ "plan": _create_json_data(plan, logger),
+ }
+ logger.print("Generating the JSON file...")
+ return json.dumps(data)
diff --git a/src/tmt_web/generators/templates/_base.html.j2 b/src/tmt_web/generators/templates/_base.html.j2
new file mode 100644
index 0000000..9a52187
--- /dev/null
+++ b/src/tmt_web/generators/templates/_base.html.j2
@@ -0,0 +1,10 @@
+
+
+
+ {{ title | default('Untitled Document') }}
+
+
+
+ {%- block content %}{% endblock %}
+
+
diff --git a/src/tmt_web/generators/templates/status_callback.html.j2 b/src/tmt_web/generators/templates/status_callback.html.j2
new file mode 100644
index 0000000..5dab855
--- /dev/null
+++ b/src/tmt_web/generators/templates/status_callback.html.j2
@@ -0,0 +1,13 @@
+{% extends '_base.html.j2' %}
+{% set title = "Status" %}
+
+{% block content %}
+ {% if status == "PENDING" %}
+ Processing... Try this clicking this url in a few seconds: {{ status_callback_url }}
+ {%- elif status == "RETRYING" %}
+ Task is retrying... Please wait for a few seconds and try again: {{ status_callback_url }}
+ {%- else %}
+ Status: {{ status }}
+ The result is:
{{ result }}
+ {%- endif %}
+{%- endblock %}
diff --git a/src/tmt_web/generators/templates/testandplan.html.j2 b/src/tmt_web/generators/templates/testandplan.html.j2
new file mode 100644
index 0000000..bb4256c
--- /dev/null
+++ b/src/tmt_web/generators/templates/testandplan.html.j2
@@ -0,0 +1,27 @@
+
+{% extends '_base.html.j2' %}
+{% set title = "Test and Plan Information" %}
+
+{% block content %}
+ Test Information
+ name: {{ test.name }}
+ summary: {{ test.summary }}
+ description: {{ test.description }}
+ url: {{ test.web_link() }}
+ ref: {{ test.fmf_id.ref }}
+ contact: {{ test.contact }}
+ tag: {{ test.tag }}
+ tier: {{ test.tier }}
+ id: {{ test.id }}
+
+ Plan Information
+ name: {{ plan.name }}
+ summary: {{ plan.summary }}
+ description: {{ plan.description }}
+ url: {{ plan.web_link() }}
+ ref: {{ plan.fmf_id.ref }}
+ contact: {{ plan.contact }}
+ tag: {{ plan.tag }}
+ tier: {{ plan.tier }}
+ id: {{ plan.id }}
+{%- endblock %}
diff --git a/src/tmt_web/generators/templates/testorplan.html.j2 b/src/tmt_web/generators/templates/testorplan.html.j2
new file mode 100644
index 0000000..8d885f8
--- /dev/null
+++ b/src/tmt_web/generators/templates/testorplan.html.j2
@@ -0,0 +1,24 @@
+
+{% extends '_base.html.j2' %}
+{% set title = testorplan.name %}
+
+{% block content %}
+ name: {{ testorplan.name }}
+ summary: {{ testorplan.summary }}
+ description: {{ testorplan.description }}
+ url: {{ testorplan.web_link() }}
+ ref: {{ testorplan.fmf_id.ref }}
+ contact: {{ testorplan.contact }}
+ tag: {{ testorplan.tag }}
+ tier: {{ testorplan.tier }}
+ id: {{ testorplan.id }}
+ fmf-id:
+
+ - url: {{ testorplan.fmf_id.url }}
+ {%- if testorplan.fmf_id.path %}
+ - path: {{ testorplan.fmf_id.path.as_posix() }}
+ {%- else %}
+ - path: None
+ {%- endif %}
+
+{%- endblock %}
diff --git a/src/tmt_web/generators/yaml_generator.py b/src/tmt_web/generators/yaml_generator.py
new file mode 100644
index 0000000..b3aed80
--- /dev/null
+++ b/src/tmt_web/generators/yaml_generator.py
@@ -0,0 +1,52 @@
+import tmt
+from tmt import Logger
+
+from tmt_web.generators.json_generator import _create_json_data
+
+
+def print_success(logger: Logger) -> None:
+ logger.print("YAML file generated successfully!", color="green")
+
+
+def generate_test_yaml(test: tmt.Test, logger: Logger) -> str:
+ """
+ This function generates an YAML file with the input data for a test.
+
+ :param test: Test object
+ :param logger: tmt.Logger instance
+ :return: YAML data for a given test
+ """
+ yaml_data = tmt.utils.dict_to_yaml(_create_json_data(test, logger))
+ print_success(logger)
+ return yaml_data
+
+
+def generate_plan_yaml(plan: tmt.Plan, logger: Logger) -> str:
+ """
+ This function generates an YAML file with the input data for a plan.
+
+ :param plan: Plan object
+ :param logger: tmt.Logger instance
+ :return: YAML data for a given plan.
+ """
+ yaml_data = tmt.utils.dict_to_yaml(_create_json_data(plan, logger))
+ print_success(logger)
+ return yaml_data
+
+
+def generate_testplan_yaml(test: tmt.Test, plan: tmt.Plan, logger: Logger) -> str:
+ """
+ This function generates an YAML file with the input data for a test and a plan.
+
+ :param test: Test object
+ :param plan: Plan object
+ :param logger: tmt.Logger instance
+ :return: YAML data for a given test and plan
+ """
+ data = {
+ "test": _create_json_data(test, logger),
+ "plan": _create_json_data(plan, logger),
+ }
+ yaml_data = tmt.utils.dict_to_yaml(data)
+ print_success(logger)
+ return yaml_data
diff --git a/src/tmt_web/service.py b/src/tmt_web/service.py
new file mode 100644
index 0000000..d6e7ab3
--- /dev/null
+++ b/src/tmt_web/service.py
@@ -0,0 +1,190 @@
+import logging
+
+import tmt
+from celery.app import Celery # type: ignore[attr-defined]
+from tmt.utils import Path # type: ignore[attr-defined]
+
+from tmt_web import settings
+from tmt_web.generators import html_generator, json_generator, yaml_generator
+from tmt_web.utils import git_handler
+
+logger = tmt.Logger(logging.getLogger("tmt-logger"))
+
+app = Celery(__name__, broker=settings.REDIS_URL, backend=settings.REDIS_URL)
+
+
+def get_tree(url: str, name: str, ref: str | None, tree_path: str) -> tmt.base.Tree:
+ """
+ This function clones the repository and returns the Tree object.
+
+ :param ref: Object ref
+ :param name: Object name
+ :param url: Object url
+ :param tree_path: Object path
+ :return: returns a Tree object
+ """
+ logger.print(f"Cloning the repository for url: {url}")
+ logger.print("Parsing the url and name...")
+ logger.print(f"URL: {url}")
+ logger.print(f"Name: {name}")
+
+ path = git_handler.get_git_repository(url, logger, ref)
+
+ if tree_path is not None:
+ tree_path += '/'
+ # If path is set, construct a path to the tmt Tree
+ if path.suffix == '.git':
+ path = path.with_suffix('')
+ path = Path(path.as_posix() + tree_path)
+
+ logger.print("Looking for tree...")
+ tree = tmt.base.Tree(path=path, logger=logger)
+ logger.print("Tree found!", color="green")
+ return tree
+
+
+def process_test_request(test_url: str,
+ test_name: str,
+ test_ref: str,
+ test_path: str,
+ return_object: bool,
+ out_format: str) -> str | tmt.Test | None:
+ """
+ This function processes the request for a test and returns the data in specified output format
+ or the Test object.
+
+ :param test_url: Test url
+ :param test_name: Test name
+ :param test_ref: Test repo ref
+ :param test_path: Test path
+ :param return_object: Specify if the function should return the HTML file or the Test object
+ :param out_format: Specifies output format
+ :return: the data in specified output format or the Test object
+ """
+
+ tree = get_tree(test_url, test_name, test_ref, test_path)
+
+ logger.print("Looking for the wanted test...")
+
+ # Find the desired Test object
+ wanted_test = tree.tests(names=[test_name])[0]
+ if not wanted_test:
+ logger.print("Test not found!", color="red")
+ return None
+
+ logger.print("Test found!", color="green")
+ if not return_object:
+ return wanted_test
+ match out_format:
+ case "html":
+ return html_generator.generate_html_page(wanted_test, logger=logger)
+ case "json":
+ return json_generator.generate_test_json(wanted_test, logger=logger)
+ case "yaml":
+ return yaml_generator.generate_test_yaml(wanted_test, logger=logger)
+ return None
+
+
+def process_plan_request(plan_url: str,
+ plan_name: str,
+ plan_ref: str,
+ plan_path: str,
+ return_object: bool,
+ out_format: str) -> str | None | tmt.Plan:
+ """
+ This function processes the request for a plan and returns the data in specified output format
+ or the Plan object.
+
+ :param plan_url: Plan URL
+ :param plan_name: Plan name
+ :param plan_ref: Plan repo ref
+ :param plan_path: Plan path
+ :param return_object: Specify if the function should return the HTML file or the Plan object
+ :param out_format: Specifies output format
+ :return: the data in specified output format or the Plan object
+ """
+
+ tree = get_tree(plan_url, plan_name, plan_ref, plan_path)
+
+ logger.print("Looking for the wanted plan...")
+
+ # Find the desired Plan object
+ wanted_plan = tree.plans(names=[plan_name])[0]
+ if not wanted_plan:
+ logger.print("Plan not found!", color="red")
+ return None
+ logger.print("Plan found!", color="green")
+ if not return_object:
+ return wanted_plan
+ match out_format:
+ case "html":
+ return html_generator.generate_html_page(wanted_plan, logger=logger)
+ case "json":
+ return json_generator.generate_plan_json(wanted_plan, logger=logger)
+ case "yaml":
+ return yaml_generator.generate_plan_yaml(wanted_plan, logger=logger)
+ return None
+
+
+def process_testplan_request(test_url,
+ test_name,
+ test_ref,
+ test_path,
+ plan_url,
+ plan_name,
+ plan_ref,
+ plan_path,
+ out_format) -> str | None:
+ """
+ This function processes the request for a test and a plan and returns the HTML file.
+
+ :param test_url: Test URL
+ :param test_name: Test name
+ :param test_ref: Test repo ref
+ :param test_path: Test path
+ :param plan_url: Plan URL
+ :param plan_name: Plan name
+ :param plan_ref: Plan repo ref
+ :param plan_path: Plan path
+ :param out_format: Specifies output format
+ :return: page data in specified output format
+ """
+ test = process_test_request(test_url, test_name, test_ref, test_path, False, out_format)
+ if not isinstance(test, tmt.Test):
+ logger.print("Invalid test object", color="red")
+ return None
+ plan = process_plan_request(plan_url, plan_name, plan_ref, plan_path, False, out_format)
+ if not isinstance(plan, tmt.Plan):
+ logger.print("Invalid plan object", color="red")
+ return None
+ match out_format:
+ case "html":
+ return html_generator.generate_testplan_html_page(test, plan, logger=logger)
+ case "json":
+ return json_generator.generate_testplan_json(test, plan, logger=logger)
+ case "yaml":
+ return yaml_generator.generate_testplan_yaml(test, plan, logger=logger)
+
+ return None
+
+
+@app.task
+def main(test_url: str | None,
+ test_name: str | None,
+ test_ref: str | None,
+ test_path: str | None,
+ plan_url: str | None,
+ plan_name: str | None,
+ plan_ref: str | None,
+ plan_path: str | None,
+ out_format: str) -> str | tmt.Test | tmt.Plan | None:
+ logger.print("Starting...", color="blue")
+ # TODO
+ if test_name is not None and plan_name is None:
+ return process_test_request(test_url, test_name, test_ref, test_path, True, out_format) # type: ignore [arg-type]
+ if plan_name is not None and test_name is None:
+ return process_plan_request(plan_url, plan_name, plan_ref, plan_path, True, out_format) # type: ignore [arg-type]
+ if plan_name is not None and test_name is not None:
+ return process_testplan_request(test_url, test_name, test_ref, test_path,
+ plan_url, plan_name, plan_ref, plan_path, out_format)
+ return None
diff --git a/src/tmt_web/settings.py b/src/tmt_web/settings.py
new file mode 100644
index 0000000..be5faac
--- /dev/null
+++ b/src/tmt_web/settings.py
@@ -0,0 +1,5 @@
+import os
+
+API_HOSTNAME = os.getenv("API_HOSTNAME", default="")
+REDIS_URL = os.getenv("REDIS_URL", default="redis://localhost:6379")
+CLONE_DIR_PATH = os.getenv("CLONE_DIR_PATH", default="./.repos/")
diff --git a/src/tmt_web/utils/__init__.py b/src/tmt_web/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tmt_web/utils/git_handler.py b/src/tmt_web/utils/git_handler.py
new file mode 100644
index 0000000..1bf569c
--- /dev/null
+++ b/src/tmt_web/utils/git_handler.py
@@ -0,0 +1,124 @@
+import contextlib
+from shutil import rmtree
+
+from tmt import Logger
+from tmt.utils import ( # type: ignore[attr-defined]
+ Command,
+ Common,
+ GeneralError,
+ Path,
+ RunError,
+ git,
+)
+
+from tmt_web import settings
+
+
+def checkout_branch(path: Path, logger: Logger, ref: str) -> None:
+ """
+ Checks out the given branch in the repository.
+
+ :param ref: Name of the ref to check out
+ :param path: Path to the repository
+ :param logger: Instance of Logger
+ """
+ try:
+ common_instance = Common(logger=logger)
+ common_instance.run(command=Command("git", "checkout", ref), cwd=path)
+ except RunError as err:
+ logger.print("Failed to do checkout in the repository!", color="red")
+ raise AttributeError from err
+
+
+def clone_repository(url: str, logger: Logger, ref: str | None = None) -> None:
+ """
+ Clones the repository from the given URL and optionally checks out a specific ref.
+
+ Raises FileExistsError if the repository is already cloned or Exception if the cloning fails.
+
+ :param url: URL to the repository
+ :param logger: Instance of Logger
+ :param ref: Optional name of the ref to check out
+ """
+ logger.print("Cloning the repository...")
+ path = get_path_to_repository(url)
+
+ if check_if_repository_exists(url):
+ if ref:
+ try:
+ checkout_branch(ref=ref, path=path, logger=logger)
+ logger.print(f"Checked out ref: {ref}", color="green")
+ except AttributeError as err:
+ logger.print(f"Failed to checkout ref: {ref}", color="red")
+ raise AttributeError from err
+ logger.print("Repository already cloned!", color="yellow")
+ raise FileExistsError
+
+ try:
+ git.git_clone(url=url, destination=path, logger=logger)
+
+ if ref:
+ try:
+ checkout_branch(ref=ref, path=path, logger=logger)
+ logger.print(f"Checked out ref: {ref}", color="green")
+ except AttributeError as err:
+ logger.print(f"Failed to checkout ref: {ref}", color="red")
+ raise AttributeError from err
+ except GeneralError as e:
+ logger.print("Failed to clone the repository!", color="red")
+ raise Exception from e
+ logger.print("Repository cloned successfully!", color="green")
+
+
+def get_path_to_repository(url: str) -> Path:
+ """
+ Returns the path to the cloned repository from the given URL.
+
+ :param url: URL to the repository
+ :return: Path to the cloned repository
+ """
+ repo_name = url.rstrip("/").rsplit("/", 1)[-1]
+ root_dir = Path(__file__).resolve().parents[2] # going up from tmt_web/utils/git_handler.py
+ return root_dir / settings.CLONE_DIR_PATH / repo_name
+
+
+def check_if_repository_exists(url: str) -> bool:
+ """
+ Checks if the repository from the given URL is already cloned.
+
+ :param url: URL to the repository
+ :return: True if the repository is already cloned, False otherwise
+ """
+ return get_path_to_repository(url).exists()
+
+
+def clear_tmp_dir(logger: Logger) -> None:
+ """
+ Clears the .tmp directory.
+
+ :param logger: Instance of Logger
+ """
+ logger.print("Clearing the .tmp directory...")
+ root_dir = Path(__file__).resolve().parents[2] # going up from tmt_web/utils/git_handler.py
+ path = root_dir / settings.CLONE_DIR_PATH
+ try:
+ rmtree(path)
+ except Exception as e:
+ logger.print("Failed to clear the repository clone directory!", color="red")
+ raise e
+
+ logger.print("Repository clone directory cleared successfully!", color="green")
+
+
+def get_git_repository(url: str, logger: Logger, ref: str | None) -> Path:
+ """
+ Clones the repository from the given URL and returns the path to the cloned repository.
+
+ :param url: URL to the repository
+ :param logger: Instance of Logger
+ :return: Path to the cloned repository
+ """
+ with contextlib.suppress(FileExistsError):
+ clone_repository(url, logger, ref)
+
+ return get_path_to_repository(url)
diff --git a/src/utils/git_handler.py b/src/utils/git_handler.py
deleted file mode 100644
index ba20c39..0000000
--- a/src/utils/git_handler.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import sys
-from subprocess import Popen
-from tmt import Logger
-import os
-import tmt.utils
-from pathlib import Path
-
-
-def clone_repository(url: str, logger: Logger) -> None:
- """
- Clones the repository from the given URL.
- Raises FileExistsError if the repository is already cloned and raises Exception if the cloning fails.
- :param url: URL to the repository
- :param logger: Instance of Logger
- :return:
- """
- logger.print("Cloning the repository...")
- path = get_path_to_repository(url)
- if check_if_repository_exists(url):
- logger.print("Repository already cloned!", color="yellow")
- raise FileExistsError
- try:
- tmt.utils.git_clone(url=url, shallow=True, destination=path, logger=logger)
- except tmt.utils.GeneralError as e:
- logger.print("Failed to clone the repository!", color="red")
- raise Exception
- logger.print("Repository cloned successfully!", color="green")
-
-
-def get_path_to_repository(url: str) -> Path:
- """
- Returns the path to the cloned repository from the given URL.
- :param url: URL to the repository
- :return: Path to the cloned repository
- """
- repo_name = url.rsplit('/', 1)[-1]
- path = os.path.realpath(__file__)
- path = path.replace("src/utils/git_handler.py", "")
- path = Path(path + "/.tmp/" + repo_name)
- return path
-
-
-def check_if_repository_exists(url: str) -> bool:
- """
- Checks if the repository from the given URL is already cloned.
- :param url: URL to the repository
- :return: True if the repository is already cloned, False otherwise
- """
- path = get_path_to_repository(url)
- return path.exists()
-
-
-def clear_tmp_dir(logger: Logger) -> None:
- """
- Clears the .tmp directory.
- :param logger: Instance of Logger
- :return:
- """
- logger.print("Clearing the .tmp directory...")
- path = os.path.realpath(__file__)
- path = path.replace("src/utils/git_handler.py", "")
- path = Path(path + "/.tmp")
- try:
- Popen(["rm", "-rf", path])
- except Exception as e:
- logger.print("Failed to clear the .tmp directory!", color="red")
- raise e
- logger.print(".tmp directory cleared successfully!", color="green")
-
-
-def get_git_repository(url: str, logger: Logger) -> Path:
- """
- Clones the repository from the given URL and returns the path to the cloned repository.
- :param url: URL to the repository
- :param logger: Instance of Logger
- :return: Path to the cloned repository
- """
- try:
- clone_repository(url, logger)
- except FileExistsError:
- pass
- return get_path_to_repository(url)
-
-
-if __name__ == "__main__":
- print("This is not executable file!")
diff --git a/tests/objects/sample_plan.fmf b/tests/objects/sample_plan.fmf
new file mode 100644
index 0000000..359a862
--- /dev/null
+++ b/tests/objects/sample_plan.fmf
@@ -0,0 +1,9 @@
+summary: Essential command line features
+discover:
+ how: fmf
+ url: https://github.com/teemtee/tmt
+prepare:
+ how: ansible
+ playbook: ansible/packages.yml
+execute:
+ how: tmt
diff --git a/tests/objects/sample_test/main.fmf b/tests/objects/sample_test/main.fmf
new file mode 100644
index 0000000..aa0b710
--- /dev/null
+++ b/tests/objects/sample_test/main.fmf
@@ -0,0 +1,2 @@
+summary: Concise summary describing what the test does
+test: ./test.sh
diff --git a/tests/objects/sample_test/test.sh b/tests/objects/sample_test/test.sh
new file mode 100755
index 0000000..b8ae7a2
--- /dev/null
+++ b/tests/objects/sample_test/test.sh
@@ -0,0 +1,6 @@
+#!/bin/sh -eux
+
+tmp=$(mktemp)
+tmt --help > "$tmp"
+grep -C3 'Test Management Tool' "$tmp"
+rm "$tmp"
diff --git a/tests/test_api.py b/tests/test_api.py
index 2ae382f..30b3e3d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,73 +1,123 @@
+import os
+import time
+
import pytest
-from src.api import app
+from fastapi.testclient import TestClient
+
+from tmt_web.api import app
-@pytest.fixture()
+@pytest.fixture
def client():
- return app.test_client()
+ return TestClient(app)
class TestApi:
- def test_basic_test_request(self, client):
- # ?test_url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke
- response = client.get("/", query_string={"test-url": "https://github.com/teemtee/tmt",
- "test-name": "/tests/core/smoke"})
- data = response.data.decode("utf-8")
- print(data)
+ """
+ This class tests the behaviour of the API directly
+ """
+ @pytest.fixture(autouse=True)
+ def _setup(self):
+ os.environ["USE_CELERY"] = "false"
+
+ def test_basic_test_request_json(self, client):
+ response = client.get("/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke&test-ref=main")
+ data = response.content.decode("utf-8")
assert "500" not in data
assert "https://github.com/teemtee/tmt/tree/main/tests/core/smoke/main.fmf" in data
+ def test_basic_test_request_json_with_path(self, client):
+ response = client.get(
+ "/?test-url=https://github.com/teemtee/tmt.git"
+ "&test-name=/test/shell/weird"
+ "&test-path=/tests/execute/basic/data"
+ "&test-ref=main")
+ data = response.content.decode("utf-8")
+ assert "500" not in data
+ assert "https://github.com/teemtee/tmt/tree/main/tests/execute/basic/data/test.fmf" in data
+
+ def test_basic_test_request_html(self, client):
+ response = client.get(
+ "/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke&test-ref=main&format=html")
+ data = response.content.decode("utf-8")
+ print(data)
+ assert "500" not in data
+ assert '' in data
+
+ def test_basic_test_request_yaml(self, client):
+ response = client.get("/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke&format=yaml")
+ data = response.content.decode("utf-8")
+ print(data)
+ assert "500" not in data
+ assert "url: https://github.com/teemtee/tmt/tree/main/tests/core/smoke/main.fmf" in data
+
def test_basic_plan_request(self, client):
- # ?plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic&type=plan
- response = client.get("/", query_string={"plan-url": "https://github.com/teemtee/tmt",
- "plan-name": "/plans/features/basic"})
- data = response.data.decode("utf-8")
+ response = client.get("/?plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic&type=plan")
+ data = response.content.decode("utf-8")
print(data)
assert "500" not in data
assert "https://github.com/teemtee/tmt/tree/main/plans/features/basic.fmf" in data
def test_basic_testplan_request(self, client):
- # ?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke&
- # ?plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic&type=plan
- response = client.get("/", query_string={"test-url": "https://github.com/teemtee/tmt",
- "test-name": "/tests/core/smoke",
- "plan-url": "https://github.com/teemtee/tmt",
- "plan-name": "/plans/features/basic"})
- data = response.data.decode("utf-8")
+ response = client.get("/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke&"
+ "plan-url=https://github.com/teemtee/tmt&plan-name=/plans/features/basic&type=plan")
+ data = response.content.decode("utf-8")
print(data)
assert "500" not in data
assert "https://github.com/teemtee/tmt/tree/main/tests/core/smoke/main.fmf" in data
assert "https://github.com/teemtee/tmt/tree/main/plans/features/basic.fmf" in data
def test_invalid_test_arguments(self, client):
- response = client.get("/", query_string={"test-url": "https://github.com/teemtee/tmt",
- "test-name": None})
- data = response.data.decode("utf-8")
+ response = client.get("/?test-url=https://github.com/teemtee/tmt")
+ data = response.content.decode("utf-8")
assert "Invalid arguments!" in data
- response = client.get("/", query_string={"test-url": None,
- "test-name": "/tests/core/smoke"})
- data = response.data.decode("utf-8")
+ response = client.get("/?test-name=/tests/core/smoke")
+ data = response.content.decode("utf-8")
assert "Invalid arguments!" in data
def test_invalid_plan_arguments(self, client):
- response = client.get("/", query_string={"plan-url": "https://github.com/teemtee/tmt",
- "plan-name": None})
- data = response.data.decode("utf-8")
+ response = client.get("/?plan-url=https://github.com/teemtee/tmt")
+ data = response.content.decode("utf-8")
assert "Invalid arguments!" in data
- response = client.get("/", query_string={"plan-url": None,
- "plan-name": "/plans/features/basic"})
- data = response.data.decode("utf-8")
+ response = client.get("/?plan-name=/plans/features/basic")
+ data = response.content.decode("utf-8")
assert "Invalid arguments!" in data
def test_invalid_testplan_arguments(self, client):
- response = client.get("/", query_string={"test-url": "https://github.com/teemtee/tmt",
- "test-name": None,
- "plan-url": "https://github.com/teemtee/tmt",
- "plan-name": "/plans/features/basic"})
- data = response.data.decode("utf-8")
+ response = client.get("/?test-url=https://github.com/teemtee/tmt&plan-url=https://github.com/teemtee/tmt&"
+ "plan-name=/plans/features/basic")
+ data = response.content.decode("utf-8")
assert "Invalid arguments!" in data
def test_invalid_argument_names(self, client):
- response = client.get("/", query_string={"test_urlurl": "https://github.com/teemtee/tmt",
- "test_nn": "/tests/core/smoke"})
- assert response.status_code == 500
+ response = client.get("/?test_urlur=https://github.com/teemtee/tmt&test_nn=/tests/core/smoke")
+ data = response.content.decode("utf-8")
+ assert response.status_code == 200
+ assert data == '"Missing arguments!"'
+
+
+class TestCelery:
+ """
+ This class tests the API with the Celery instance
+ """
+ @pytest.fixture(autouse=True)
+ def _setup(self):
+ os.environ["USE_CELERY"] = "true"
+
+ def test_basic_test_request(self, client):
+ response = client.get("/?test-url=https://github.com/teemtee/tmt&test-name=/tests/core/smoke")
+ json_data = response.json()
+ while True:
+ if json_data["status"] == "PENDING":
+ response = client.get("/status?task-id=" + json_data["id"])
+ json_data = response.json()
+ time.sleep(0.1)
+ elif json_data["status"] == "SUCCESS":
+ result = json_data["result"]
+ assert "500" not in result
+ assert "https://github.com/teemtee/tmt/tree/main/tests/core/smoke/main.fmf" in result
+ break
+ elif json_data["status"] == "FAILURE":
+ pytest.fail("status = FAILURE: " + json_data["result"])
+ else:
+ pytest.fail("Unknown status: " + json_data["status"])
diff --git a/tests/unit/test_git_handler.py b/tests/unit/test_git_handler.py
index ec96a8e..837550f 100644
--- a/tests/unit/test_git_handler.py
+++ b/tests/unit/test_git_handler.py
@@ -1,22 +1,33 @@
+import contextlib
+import logging
+import os
import time
+from pathlib import Path
import pytest
import tmt
-import logging
-from src.utils import git_handler
+from tmt_web.utils import git_handler
-class TestUtils:
- logger = tmt.Logger(logging.Logger("tmt-logger"))
+class TestGitHandler:
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
def test_clear_tmp_dir(self):
+ # Create test directory if it doesn't exist
+ try:
+ path = Path(__file__).resolve().parents[2].joinpath(os.getenv("CLONE_DIR_PATH", "./.repos/"))
+ path.mkdir(exist_ok=True)
+ except FileExistsError:
+ pass
git_handler.clear_tmp_dir(self.logger)
def test_get_path_to_repository(self):
+ self.test_clone_repository()
assert git_handler.get_path_to_repository("https://github.com/teemtee/tmt").exists()
def test_check_if_repository_exists(self):
+ self.test_clone_repository()
assert git_handler.check_if_repository_exists("https://github.com/teemtee/tmt")
def test_clone_repository(self):
@@ -24,7 +35,33 @@ def test_clone_repository(self):
while git_handler.check_if_repository_exists("https://github.com/teemtee/tmt") is True:
git_handler.clear_tmp_dir(self.logger)
time.sleep(1)
- git_handler.clone_repository("https://github.com/teemtee/tmt", logger=self.logger)
- with pytest.raises(FileExistsError):
- git_handler.clone_repository("https://github.com/teemtee/tmt", logger=self.logger)
+ git_handler.clone_repository(url="https://github.com/teemtee/tmt",
+ logger=self.logger)
+
+ def test_clone_repository_even_if_exists(self):
+ with contextlib.suppress(FileExistsError):
+ git_handler.clone_repository(url="https://github.com/teemtee/tmt",
+ logger=self.logger)
+
+ def test_clone_checkout_branch(self):
+ with contextlib.suppress(FileExistsError):
+ git_handler.clone_repository(url="https://github.com/teemtee/tmt",
+ logger=self.logger, ref="quay")
+
+ def test_clone_checkout_branch_exception(self):
+ with pytest.raises(AttributeError):
+ git_handler.clone_repository(url="https://github.com/teemtee/tmt",
+ logger=self.logger, ref="quadd")
+
+ def test_checkout_branch(self):
+ self.test_clone_repository_even_if_exists()
+ git_handler.checkout_branch(ref="quay", path=git_handler.get_path_to_repository(
+ url="https://github.com/teemtee/tmt"), logger=self.logger)
+ git_handler.checkout_branch(ref="main", path=git_handler.get_path_to_repository(
+ url="https://github.com/teemtee/tmt"), logger=self.logger)
+ def test_checkout_branch_exception(self):
+ self.test_clone_repository_even_if_exists()
+ with pytest.raises(AttributeError):
+ git_handler.checkout_branch(ref="quaddy", path=git_handler.get_path_to_repository(
+ url="https://github.com/teemtee/tmt"), logger=self.logger)
diff --git a/tests/unit/test_html_generator.py b/tests/unit/test_html_generator.py
new file mode 100644
index 0000000..cd1beb1
--- /dev/null
+++ b/tests/unit/test_html_generator.py
@@ -0,0 +1,13 @@
+import logging
+
+import tmt
+
+from tmt_web.generators import html_generator
+
+
+class TestHtmlGenerator:
+ def test_generate_test_html(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ test = tmt.Tree(logger=logger).tests(names=["/tests/objects/sample_test"])[0]
+ data = html_generator.generate_html_page(test, logger)
+ assert 'name: /tests/objects/sample_test' in data
diff --git a/tests/unit/test_json_generator.py b/tests/unit/test_json_generator.py
new file mode 100644
index 0000000..3b98fab
--- /dev/null
+++ b/tests/unit/test_json_generator.py
@@ -0,0 +1,27 @@
+import logging
+
+import tmt
+
+from tmt_web.generators import json_generator
+
+
+class TestJsonGenerator:
+ def test_generate_test_json(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ test = tmt.Tree(logger=logger).tests(names=["/tests/objects/sample_test"])[0]
+ data = json_generator.generate_test_json(test, logger)
+ assert '"name": "/tests/objects/sample_test"' in data
+
+ def test_generate_plan_json(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ plan = tmt.Tree(logger=logger).plans(names=["/tests/objects/sample_plan"])[0]
+ data = json_generator.generate_plan_json(plan, logger)
+ assert '"name": "/tests/objects/sample_plan"' in data
+
+ def test_generate_testplan_json(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ test = tmt.Tree(logger=logger).tests(names=["/tests/objects/sample_test"])[0]
+ plan = tmt.Tree(logger=logger).plans(names=["/tests/objects/sample_plan"])[0]
+ data = json_generator.generate_testplan_json(test, plan, logger)
+ assert '"name": "/tests/objects/sample_test"' in data
+ assert '"name": "/tests/objects/sample_plan"' in data
diff --git a/tests/unit/test_yaml_generator.py b/tests/unit/test_yaml_generator.py
new file mode 100644
index 0000000..cb62d04
--- /dev/null
+++ b/tests/unit/test_yaml_generator.py
@@ -0,0 +1,27 @@
+import logging
+
+import tmt
+
+from tmt_web.generators import yaml_generator
+
+
+class TestYamlGenerator:
+ def test_generate_test_yaml(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ test = tmt.Tree(logger=logger).tests(names=["/tests/objects/sample_test"])[0]
+ data = yaml_generator.generate_test_yaml(test, logger)
+ assert 'name: /tests/objects/sample_test' in data
+
+ def test_generate_plan_yaml(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ plan = tmt.Tree(logger=logger).plans(names=["/tests/objects/sample_plan"])[0]
+ data = yaml_generator.generate_plan_yaml(plan, logger)
+ assert 'name: /tests/objects/sample_plan' in data
+
+ def test_generate_testplan_yaml(self):
+ logger = tmt.Logger(logging.getLogger("tmt-logger"))
+ test = tmt.Tree(logger=logger).tests(names=["/tests/objects/sample_test"])[0]
+ plan = tmt.Tree(logger=logger).plans(names=["/tests/objects/sample_plan"])[0]
+ data = yaml_generator.generate_testplan_yaml(test, plan, logger)
+ assert 'name: /tests/objects/sample_test' in data
+ assert 'name: /tests/objects/sample_plan' in data