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:
+ +{%- 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