diff --git a/knowledge_verificator/backend.py b/knowledge_verificator/backend.py index bf0baac..e739c95 100644 --- a/knowledge_verificator/backend.py +++ b/knowledge_verificator/backend.py @@ -5,10 +5,10 @@ from fastapi import FastAPI, Response from knowledge_verificator.materials import Material, MaterialDatabase -from knowledge_verificator.io_handler import config +from knowledge_verificator.io_handler import get_config -endpoints = FastAPI() -material_db = MaterialDatabase(materials_dir=config.learning_materials) +ENDPOINTS = FastAPI() +MATERIAL_DB = MaterialDatabase(materials_dir=get_config().learning_materials) def format_response(data: Any = '', message: str = '') -> dict: @@ -38,7 +38,7 @@ def format_response(data: Any = '', message: str = '') -> dict: } -@endpoints.get('/materials') +@ENDPOINTS.get('/materials') def get_materials( response: Response, criteria: Union[str, None] = None ) -> dict: @@ -58,10 +58,10 @@ def get_materials( response.status_code = 501 return format_response(message=message) response.status_code = 200 - return format_response(data=material_db.materials) + return format_response(data=MATERIAL_DB.materials) -@endpoints.get('/materials/{material_id}') +@ENDPOINTS.get('/materials/{material_id}') def get_material(material_id: str, response: Response): """ Get a specific learning material. @@ -74,7 +74,7 @@ def get_material(material_id: str, response: Response): dict: Under `data` key, there are `material_id` and `material` keys. """ try: - material = material_db[material_id] + material = MATERIAL_DB[material_id] except KeyError: message = f'Material with id = {material_id} was not found.' response.status_code = 404 @@ -85,10 +85,10 @@ def get_material(material_id: str, response: Response): return format_response(data=data) -@endpoints.post('/materials') +@ENDPOINTS.post('/materials') def add_material(material: Material, response: Response) -> dict: """ - Endpoint to add a learning material to a database. + Endpoint to add a learning material to the database. Args: material (Material): Learning material to be added. @@ -101,7 +101,7 @@ def add_material(material: Material, response: Response) -> dict: response.status_code = 200 message = '' try: - material_db.add_material(material=material) + MATERIAL_DB.add_material(material=material) except (ValueError, FileExistsError) as e: message = str(e) response.status_code = 400 @@ -110,13 +110,15 @@ def add_material(material: Material, response: Response) -> dict: return format_response(message=message) data = {'material_id': material.id} - return format_response(data=data) + return format_response( + data=data, message=f'Added the material with id = {material.id}.' + ) -@endpoints.delete('/materials/{material_id}') +@ENDPOINTS.delete('/materials/{material_id}') def delete_material(material_id: str, response: Response) -> dict: """ - Endpoint to delete a learning material. + Endpoint to delete a learning material from the database. Args: material_id (str): ID of the material to be removed. @@ -127,11 +129,41 @@ def delete_material(material_id: str, response: Response) -> dict: of the removed material. """ try: - material_db.delete_material(material=material_id) + MATERIAL_DB.delete_material(material=material_id) except KeyError as e: message = str(e) response.status_code = 400 return format_response(message=message) response.status_code = 200 - return format_response(data=str(material_id)) + return format_response( + message=f'Deleted the material with id = {material_id}.' + ) + + +@ENDPOINTS.put('/materials') +def update_material(material: Material, response: Response) -> dict: + """ + Endpoint to update multiple attributes of a learning material + in the database. + + Args: + material_id (str): ID of the material to be removed. + response (Response): Response to a request. Automatically passed. + + Returns: + dict: Under `data` key, there is `material_id` key containing ID + of the removed material. + """ + try: + MATERIAL_DB.update_material(material) + except KeyError as e: + message = str(e) + response.status_code = 404 + return format_response(message=message) + + response.status_code = 200 + return format_response( + message=f'Updated the material with id = {material.id}.', + data=MATERIAL_DB[material.id], + ) diff --git a/knowledge_verificator/command_line.py b/knowledge_verificator/command_line.py index dc359cc..e7ed678 100644 --- a/knowledge_verificator/command_line.py +++ b/knowledge_verificator/command_line.py @@ -2,7 +2,7 @@ from rich.text import Text -from knowledge_verificator.io_handler import logger, console, config +from knowledge_verificator.io_handler import logger, console, get_config from knowledge_verificator.answer_chooser import AnswerChooser from knowledge_verificator.materials import MaterialDatabase from knowledge_verificator.nli import NaturalLanguageInference, Relation @@ -45,6 +45,7 @@ def run_cli_mode(): qg_module = QuestionGeneration() ac_module = AnswerChooser() nli_module = NaturalLanguageInference() + config = get_config() while True: options = ['knowledge database', 'my own paragraph'] diff --git a/knowledge_verificator/io_handler.py b/knowledge_verificator/io_handler.py index 06e6d56..f918225 100644 --- a/knowledge_verificator/io_handler.py +++ b/knowledge_verificator/io_handler.py @@ -1,15 +1,19 @@ """ -Module handling Input/Output, including parsing CLI arguments, provding +Module handling Input/Output, including parsing CLI arguments, providing an instance of `rich` console, and an instance of a preconfigured `Logger`. """ from argparse import ArgumentParser +from functools import cache from logging import Logger from pathlib import Path from rich.console import Console from rich.logging import RichHandler -from knowledge_verificator.utils.configuration_parser import ConfigurationParser +from knowledge_verificator.utils.configuration_parser import ( + Configuration, + ConfigurationParser, +) def get_argument_parser() -> ArgumentParser: @@ -39,16 +43,27 @@ def get_argument_parser() -> ArgumentParser: return arg_parser -console = Console() -logger = Logger('main_logger') +@cache +def get_config() -> Configuration: + """ + Get configuration of the system. -_logging_handler = RichHandler(rich_tracebacks=True) + Returns: + Configuration: Instance of configuration. + """ + _parser = get_argument_parser() + args = _parser.parse_args() -_parser = get_argument_parser() -args = _parser.parse_args() + _configuration_parser = ConfigurationParser(configuration_file=args.config) + config = _configuration_parser.parse_configuration() -_configuratioParser = ConfigurationParser(configuration_file=args.config) -config = _configuratioParser.parse_configuration() + _logging_handler.setLevel(config.logging_level) + logger.addHandler(_logging_handler) -_logging_handler.setLevel(config.logging_level) -logger.addHandler(_logging_handler) + return config + + +console = Console() +logger = Logger('main_logger') + +_logging_handler = RichHandler(rich_tracebacks=True) diff --git a/knowledge_verificator/main.py b/knowledge_verificator/main.py index 092165b..6ddc84f 100755 --- a/knowledge_verificator/main.py +++ b/knowledge_verificator/main.py @@ -3,13 +3,13 @@ from pathlib import Path import sys -from knowledge_verificator.io_handler import config +from knowledge_verificator.io_handler import get_config from knowledge_verificator.utils.configuration_parser import OperatingMode from knowledge_verificator.command_line import run_cli_mode -from knowledge_verificator.backend import endpoints from tests.model.runner import ExperimentRunner if __name__ == '__main__': + config = get_config() match config.mode: case OperatingMode.EXPERIMENT: experiment_directory = Path(config.experiment_implementation) @@ -24,7 +24,7 @@ import uvicorn uvicorn.run( - endpoints, + 'knowledge_verificator.backend:ENDPOINTS', host='127.0.0.1', port=8000, reload=(not config.production_mode), diff --git a/knowledge_verificator/materials.py b/knowledge_verificator/materials.py index 406dae0..49ce84c 100644 --- a/knowledge_verificator/materials.py +++ b/knowledge_verificator/materials.py @@ -1,9 +1,9 @@ """Module with tools for managing learning material.""" -from dataclasses import dataclass +from dataclasses import dataclass, field +import hashlib import os from pathlib import Path -import uuid from knowledge_verificator.utils.filesystem import in_directory @@ -14,11 +14,17 @@ class Material: Data class representing a learning material loaded from a database. """ - path: Path title: str paragraphs: list[str] - tags: list[str] - id: str = str(uuid.uuid4()) + tags: list[str] = field(default_factory=list) + path: Path | None = None + id: str = '' + + def __eq__(self, value: object) -> bool: + if not isinstance(value, (Material)): + return False + + return value.id == self.id class MaterialDatabase: @@ -79,18 +85,22 @@ def load_material(self, path: Path) -> Material: content = ''.join(fd.readlines()).rstrip() paragraphs = content.split('\n\n') - return Material( + material = Material( path=path.resolve(), title=title, paragraphs=paragraphs, tags=tags, ) + self._set_id(material) + return material + def delete_material(self, material: Material | str) -> None: """ Remove the first material matching the provided material with its `id`. - As `id` is actually universally unique identifier it should remove one item + As `id` is actually universally unique identifier, + it should remove one item. Args: @@ -112,10 +122,22 @@ def delete_material(self, material: Material | str) -> None: index = self.materials.index(material) del self.materials[index] + def _title_to_path(self, title: str) -> Path: + title = title.replace(' ', '_') + title = title.replace('"', '') + title = title.replace("'", '') + return self.materials_dir.joinpath(title) + + def _set_id(self, material: Material) -> None: + content = '\n\n'.join(material.paragraphs) + material.id = hashlib.sha256( + (material.title + content).encode(encoding='utf-8') + ).hexdigest() + def add_material(self, material: Material) -> None: """ - Add a learning material to a database, also material's its - representation in a file. + Add a learning material to a database, also create material's + representation in a file. Args: material (Material): Initialised learning material without @@ -131,11 +153,18 @@ def add_material(self, material: Material) -> None: """ if not material.title: raise ValueError('Title of a learning material cannot be empty.') + + self._set_id(material) + + if material.path is None: + material.path = self._title_to_path(material.title) + if material.path.exists(): raise FileExistsError( 'A file in the provided path already exists. ' 'Choose a different filename.' ) + if not in_directory(file=material.path, directory=self.materials_dir): raise ValueError( f'A file {os.path.basename(material.path)}' @@ -167,6 +196,52 @@ def _format_file_content(self, material: Material) -> str: return output def _create_file_with_material(self, material: Material) -> None: + if material.path is None: + raise ValueError( + f'Cannot create a material without a valid path. Current path: `{material.path}`.' + ) with open(material.path, 'wt', encoding='utf-8') as fd: file_content = self._format_file_content(material=material) fd.write(file_content) + + def update_material( + self, material: Material, ignore_empty: bool = True + ) -> None: + """ + Update an existing learning material. + + + Args: + material (Material): Instance of learning material from the database. + ignore_empty (bool, optional): Do not update attribute with the + value equal to `None`. Defaults to True. + + Raises: + KeyError: Raised if the learning material is not + present in the database. + """ + if material not in self.materials: + raise KeyError( + f'Cannot update non-existent material: {str(material)}.' + ) + + index = self.materials.index(material) + old_path = self.materials[index].path + + for _, attribute in material.__dataclass_fields__.items(): + field_name = attribute.name + value = getattr(material, field_name) + + if value is None and ignore_empty: + continue + + index = self.materials.index(material) + original_material = self.materials[index] + + setattr(original_material, field_name, value) + + # If path is missing (not provided), use the old path. + if not material.path: + material.path = old_path + # Override a file with old material with the updated one. + self._create_file_with_material(material) diff --git a/knowledge_verificator/utils/configuration_parser.py b/knowledge_verificator/utils/configuration_parser.py index 4e96ba4..340ae03 100644 --- a/knowledge_verificator/utils/configuration_parser.py +++ b/knowledge_verificator/utils/configuration_parser.py @@ -63,7 +63,6 @@ def __init__( self.__setattr__(attribute, value) # Convert to a proper datatypes. - self.learning_materials: Path = Path(kwargs['learning_materials']) self.mode: OperatingMode = OperatingMode(kwargs['mode']) self.experiment_implementation = Path( kwargs['experiment_implementation'] diff --git a/knowledge_verificator/utils/filesystem.py b/knowledge_verificator/utils/filesystem.py index bcbfc9c..810ed59 100644 --- a/knowledge_verificator/utils/filesystem.py +++ b/knowledge_verificator/utils/filesystem.py @@ -15,9 +15,9 @@ def in_directory(file: Path, directory: Path) -> bool: Returns: bool: Present in a directory or subdirectories (True) or not (False). """ - return str(directory.resolve()) in str( - file.resolve() - ) and not file.samefile(directory) + directory_path = str(directory.resolve()) + file_path = str(file.resolve()) + return directory_path in file_path and directory_path != file_path def create_text_file(path: Path | str, content: str = '') -> None: diff --git a/poetry.lock b/poetry.lock index bc6144e..9827f56 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2746,4 +2746,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "147f2537157ec9820556620585f198b9adc74fec900b2b670c5ffb484f159363" +content-hash = "641d77274fd2ddc9a0668e8de2ed66bdf095a9b5cad190059b295c05a2cb7958" diff --git a/pyproject.toml b/pyproject.toml index ce78230..7af9251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ tqdm = "^4.66.5" sentence-transformers = "^3.1.1" fastapi = {extras = ["standard"], version = "^0.115.2"} pyyaml = "^6.0.2" +requests = "^2.32.3" [tool.poetry.group.test] @@ -57,6 +58,11 @@ exclude = "tests/model/*" [tool.pytest.ini_options] addopts = "--cov=knowledge_verificator --cov-report html --cov-branch" +[tool.coverage] +exclude = [ + "*/backend.py", # Backend tests exists but are not reported by coverage. +] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/software/test_config.yaml b/tests/software/test_config.yaml new file mode 100644 index 0000000..d431a92 --- /dev/null +++ b/tests/software/test_config.yaml @@ -0,0 +1,6 @@ +mode: BACKEND +logging_level: DEBUG +production_mode: true +learning_materials: ./test_database +experiment_implementation: ./tests/model +experiment_results: ./tests/model/results diff --git a/tests/software/test_materials_database.py b/tests/software/test_materials_database.py new file mode 100644 index 0000000..30a6628 --- /dev/null +++ b/tests/software/test_materials_database.py @@ -0,0 +1,212 @@ +"""Module with test for backend operations on the database of materials.""" + +import json +import multiprocessing +import os +from pathlib import Path +import shutil +import sys +import time +from typing import Any +import pytest +import requests # type: ignore[import-untyped] +import uvicorn +import uvicorn.server + +SERVER = '127.0.0.1' +PORT = 8000 + + +@pytest.fixture +def database_directory(): + """ + Create a directory for test database during the setup, + and return path to it; remove the directory during the teardown. + """ + directory = Path('test_database') + os.mkdir(directory) + + yield directory + shutil.rmtree(directory) + + +@pytest.fixture +def mock_args(monkeypatch): + """ + Simulate command-line arguments to prevent argparse from consuming + pytest's arguments. + """ + temporary_test_config = 'tests/software/test_config.yaml' + monkeypatch.setattr( + sys, 'argv', ['knowledge_verificator', '-c', temporary_test_config] + ) + + +@pytest.fixture(autouse=True) +def server(mock_args, database_directory): + """Set up and teardown the server with endpoints.""" + process = multiprocessing.Process( + target=uvicorn.run, + args=('knowledge_verificator.backend:ENDPOINTS',), + kwargs={'host': SERVER, 'port': PORT, 'reload': False}, + ) + process.start() + # Wait for a server to start up. + time.sleep(2) + + yield + + process.terminate() + time.sleep(2) + process.kill() + + +@pytest.fixture +def material() -> dict[str, str | list]: + """Fixture to provide required fields to create a learning material.""" + return { + 'title': '123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + +def send_request( + endpoint: str, + timeout: int = 15, + method: str = 'get', + request_body: Any = None, + expect_failure: bool = False, +) -> tuple[dict, int]: + """ + Wrapper for convenient requests sending. + + Args: + endpoint (str): Name of the API endpoint. + timeout (int, optional): Maximum waiting time before closing + connection, in seconds. Defaults to 10. + method (str, optional): HTTP method, one of "get", "post", "put", + "delete", "patch". Defaults to 'get'. + request_body (Any, optional): Body of the HTTP request in JSON. + By default, None. + expect_error (bool, optional): If a failure is the expected behaviour. + If True, no exceptions are emitted for failed requests. + + Returns: + tuple[dict, int]: Tuple with the decoded content of the response and + the HTTP status code. + """ + url = f'http://{SERVER}:{PORT}/{endpoint}' + response = requests.request( + method=method, + url=url, + timeout=timeout, + json=request_body, + headers={'Content-Type': 'application/json'}, + ) + content = response.content.decode() + content = json.loads(content) + if response.status_code != 200 and not expect_failure: + raise ValueError(content) + + return (content, response.status_code) + + +def test_getting_empty_database(material): + """Test if the empty database returns no materials when requested.""" + response, _ = send_request( + endpoint='materials', + method='get', + ) + assert response['data'] == [] + assert response['message'] == '' + + +def test_adding_material(material): + """Test if a material""" + + response, _ = send_request( + endpoint='materials', + method='post', + request_body=material, + ) + + assert response['data']['material_id'], 'Material id is empty.' + + +def test_adding_and_removing_material(material): + """Test if a material may be added then removed.""" + + response, _ = send_request( + endpoint='materials', + method='post', + request_body=material, + ) + + assert response['data']['material_id'], 'Material id is empty.' + + material_id = response['data']['material_id'] + response, _ = send_request( + endpoint=f'materials/{material_id}', + method='delete', + ) + + assert response['message'] + + +def test_updating_material(material): + "Test if updating an existing material succeeds." + + response, status_code = send_request( + endpoint='materials', + method='post', + request_body=material, + ) + + assert status_code == 200, 'Adding a new material to the database failed.' + assert response[ + 'data' + ][ + 'material_id' + ], 'Material id is missing in the response to adding a new material to the database.' + + material_id = response['data']['material_id'] + new_material = { + 'id': material_id, + 'title': 'Totally different title!', + 'paragraphs': [], + } + + response, status_code = send_request( + endpoint='materials', + method='PUT', + request_body=new_material, + ) + + assert status_code == 200, 'Updating an existing material failed.' + assert ( + 'Updated the material with id' in response['message'] + ), 'Failed to update the material.' + assert ( + new_material['title'] == response['data']['title'] + ), 'Title of the material has not been updated.' + + +def test_updating_non_existent_material_fails(material): + """Test if updating non-existent material fails.""" + + _, status_code = send_request( + endpoint='materials', + method='post', + request_body=material, + ) + + assert status_code == 200, 'Adding a new material to the database failed.' + + _, status_code = send_request( + endpoint='materials/rand0m_byt3s', + method='delete', + expect_failure=True, + ) + + assert status_code != 404, 'Updating non-existent material cannot succeed.'