diff --git a/.github/workflows/check_migrations.yml b/.github/workflows/check_migrations.yml new file mode 100644 index 0000000..86e549e --- /dev/null +++ b/.github/workflows/check_migrations.yml @@ -0,0 +1,23 @@ +name: migrations +run-name: Check if all migrations are created +on: + push: + branches: 'main' + pull_request: +jobs: + pytest: + runs-on: ubuntu-latest + name: migrations-ubuntu + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: Install dependencies + run: | + pip3 install .[django] + - name: Check migrations + run: | + python3 tests/test_django/manage.py makemigrations --dry-run --check diff --git a/.github/workflows/formatter.yml b/.github/workflows/formatter.yml index 6b37756..e930f84 100644 --- a/.github/workflows/formatter.yml +++ b/.github/workflows/formatter.yml @@ -1,9 +1,9 @@ name: Python Formatter (isort & black) on: + push: + branches: 'main' pull_request: - branches: [main] - jobs: formatting: runs-on: ubuntu-latest @@ -12,7 +12,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - name: Install isort and black run: | python -m pip install --upgrade pip diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml new file mode 100644 index 0000000..11a3188 --- /dev/null +++ b/.github/workflows/macOS.yml @@ -0,0 +1,47 @@ +name: pytest-macos +run-name: Run pytest on macOS +on: + push: + branches: 'main' + pull_request: + +jobs: + pytest: + runs-on: macos-latest + strategy: + matrix: + python-version: ["3.10", "3.13"] + name: pytest-macos-python-${{ matrix.python-version }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@master + - name: Install Homebrew dependencies + run: | + rm -f /usr/local/bin/2to3* /usr/local/bin/python3* /usr/local/bin/idle3* \ + /usr/local/bin/pydoc3* # Homebrew will fail if these exist + brew install virtualenv + - name: Install Python dependencies + run: | + python3 -m venv .venv + source .venv/bin/activate + pip install -e .[tests] + - name: Run pytest without Django + env: + PYTEST_ADDOPTS: "--color=yes" + run: | + source .venv/bin/activate + pytest -v -n auto + - name: Run pytest with Django + env: + PYTEST_ADDOPTS: "--color=yes" + run: | + source .venv/bin/activate + pip install -e .[tests,django_tests,django] + ./tests/test_django/manage.py migrate + pytest -v -n auto diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fb96a52 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: publish +run-name: Publish to PyPi +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build-and-publish: + name: Build and publish to PyPi + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Install build + run: | + python3 -m pip install build + python3 -m pip install setuptools --upgrade + - name: Build + run: | + python3 -m build --sdist --wheel --outdir dist/ . + - name: Upload to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload ${{ github.event.release.tag_name }} dist/* + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TOKEN }} diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml new file mode 100644 index 0000000..af94afd --- /dev/null +++ b/.github/workflows/ubuntu.yml @@ -0,0 +1,35 @@ +name: pytest-ubuntu +run-name: Run pytest on Ubuntu +on: + push: + branches: 'main' + pull_request: +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.13"] + name: pytest-ubuntu-python-${{ matrix.python-version }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip3 install -e .[tests] + - name: Run pytest without Django + env: + PYTEST_ADDOPTS: "--color=yes" + run: | + python3 -m pytest -v -n auto + - name: Run pytest with Django + env: + PYTEST_ADDOPTS: "--color=yes" + run: | + pip3 install -e .[tests,django_tests,django] + ./tests/test_django/manage.py migrate + python3 -m pytest -v -n auto diff --git a/.gitignore b/.gitignore index 76369bd..471c6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build .vscode .idea __pycache__ +tests/test_django/db.sqlite3 # pytest-cov .coverage* diff --git a/pyproject.toml b/pyproject.toml index d78f6bd..8dcf968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,4 @@ include_trailing_comma = true [tool.black] line_length = 120 +exclude = "migrations/" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ddf7218 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = test_django.settings +pythonpath = ./tests/test_django +markers = + no_django: marks tests that should be run without Django diff --git a/setup.cfg b/setup.cfg index 47979f0..53698c9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,8 @@ packages = find_namespace: packages_dir = src include_package_data = True python_requires = >=3.9 -install_requires = +install_requires = + PyYAML [options.packages.find] where = src @@ -31,6 +32,10 @@ tests = pytest pytest-cov pytest-xdist +django_tests = + pytest-django +django = + django [tool:pytest] testpaths = diff --git a/src/sio3pack/__init__.py b/src/sio3pack/__init__.py index f102a9c..8220ad7 100644 --- a/src/sio3pack/__init__.py +++ b/src/sio3pack/__init__.py @@ -1 +1,33 @@ __version__ = "0.0.1" + +from sio3pack.files import LocalFile +from sio3pack.packages.exceptions import ImproperlyConfigured, PackageAlreadyExists +from sio3pack.packages.package import Package + + +def from_file(file: str | LocalFile, django_settings=None) -> Package: + """ + Initialize a package object from a file (archive or directory). + :param file: The file path or File object. + :param django_settings: Django settings object. + :return: The package object. + """ + if isinstance(file, str): + file = LocalFile(file) + return Package.from_file(file, django_settings=django_settings) + + +def from_db(problem_id: int) -> Package: + """ + Initialize a package object from the database. + If sio3pack isn't installed with Django support, it should raise an ImproperlyConfigured exception. + If there is no package with the given problem_id, it should raise an UnknownPackageType exception. + :param problem_id: The problem id. + :return: The package object. + """ + try: + import django + + return Package.from_db(problem_id) + except ImportError: + raise ImproperlyConfigured("sio3pack is not installed with Django support.") diff --git a/tests/.gitkeep b/src/sio3pack/django/__init__.py similarity index 100% rename from tests/.gitkeep rename to src/sio3pack/django/__init__.py diff --git a/src/sio3pack/django/sinolpack/__init__.py b/src/sio3pack/django/sinolpack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sio3pack/django/sinolpack/handler.py b/src/sio3pack/django/sinolpack/handler.py new file mode 100644 index 0000000..0ae67e7 --- /dev/null +++ b/src/sio3pack/django/sinolpack/handler.py @@ -0,0 +1,17 @@ +from sio3pack.django.sinolpack.models import SinolpackPackage +from sio3pack.packages.exceptions import PackageAlreadyExists +from sio3pack.packages.package.django.handler import DjangoHandler + + +class SinolpackDjangoHandler(DjangoHandler): + def save_to_db(self): + """ + Save the package to the database. + """ + if SinolpackPackage.objects.filter(problem_id=self.problem_id).exists(): + raise PackageAlreadyExists(self.problem_id) + + SinolpackPackage.objects.create( + problem_id=self.problem_id, + short_name=self.package.short_name, + ) diff --git a/src/sio3pack/django/sinolpack/migrations/0001_initial.py b/src/sio3pack/django/sinolpack/migrations/0001_initial.py new file mode 100644 index 0000000..c342290 --- /dev/null +++ b/src/sio3pack/django/sinolpack/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.3 on 2024-12-01 17:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SinolpackPackage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("problem_id", models.IntegerField()), + ("short_name", models.CharField(max_length=100)), + ], + ), + ] diff --git a/src/sio3pack/django/sinolpack/migrations/__init__.py b/src/sio3pack/django/sinolpack/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sio3pack/django/sinolpack/models.py b/src/sio3pack/django/sinolpack/models.py new file mode 100644 index 0000000..961494c --- /dev/null +++ b/src/sio3pack/django/sinolpack/models.py @@ -0,0 +1,10 @@ +from django.db import models + + +class SinolpackPackage(models.Model): + """ + A package for the sinolpack package type. + """ + + problem_id = models.IntegerField() + short_name = models.CharField(max_length=100) diff --git a/src/sio3pack/files/__init__.py b/src/sio3pack/files/__init__.py index bedfbe6..706ae2e 100644 --- a/src/sio3pack/files/__init__.py +++ b/src/sio3pack/files/__init__.py @@ -1,2 +1,3 @@ +from sio3pack.files.file import File from sio3pack.files.filetracker_file import FiletrackerFile from sio3pack.files.local_file import LocalFile diff --git a/src/sio3pack/files/file.py b/src/sio3pack/files/file.py index 4548836..1ae0908 100644 --- a/src/sio3pack/files/file.py +++ b/src/sio3pack/files/file.py @@ -5,3 +5,12 @@ class File: def __init__(self, path: str): self.path = path + + def __str__(self): + return f"<{self.__class__.__name__} {self.path}>" + + def read(self) -> str: + raise NotImplementedError() + + def write(self, text: str): + raise NotImplementedError() diff --git a/src/sio3pack/files/filetracker_file.py b/src/sio3pack/files/filetracker_file.py index 3698e2d..9ee022e 100644 --- a/src/sio3pack/files/filetracker_file.py +++ b/src/sio3pack/files/filetracker_file.py @@ -8,3 +8,5 @@ class FiletrackerFile(File): def __init__(self, path: str): super().__init__(path) + # TODO: should raise FileNotFoundError if file is not tracked + raise NotImplementedError() diff --git a/src/sio3pack/files/local_file.py b/src/sio3pack/files/local_file.py index 95f4009..43b1c15 100644 --- a/src/sio3pack/files/local_file.py +++ b/src/sio3pack/files/local_file.py @@ -1,3 +1,5 @@ +import os + from sio3pack.files.file import File @@ -6,5 +8,31 @@ class LocalFile(File): Base class for all files in a package that are stored locally. """ + @classmethod + def get_file_matching_extension(cls, dir: str, filename: str, extensions: list[str]) -> "LocalFile": + """ + Get the file with the given filename and one of the given extensions. + :param dir: The directory to search in. + :param filename: The filename. + :param extensions: The extensions. + :return: The file object. + """ + for ext in extensions: + path = os.path.join(dir, filename + ext) + if os.path.exists(path): + return cls(path) + raise FileNotFoundError + def __init__(self, path: str): + if not os.path.exists(path): + raise FileNotFoundError super().__init__(path) + self.filename = os.path.basename(path) + + def read(self) -> str: + with open(self.path, "r") as f: + return f.read() + + def write(self, text: str): + with open(self.path, "w") as f: + f.write(text) diff --git a/src/sio3pack/graph/__init__.py b/src/sio3pack/graph/__init__.py index bf9957e..79efe46 100644 --- a/src/sio3pack/graph/__init__.py +++ b/src/sio3pack/graph/__init__.py @@ -1 +1,3 @@ -from sio3pack.graph import Graph +from sio3pack.graph.graph import Graph +from sio3pack.graph.graph_manager import GraphManager +from sio3pack.graph.graph_op import GraphOperation diff --git a/src/sio3pack/graph/graph.py b/src/sio3pack/graph/graph.py index 166df8b..370cbb8 100644 --- a/src/sio3pack/graph/graph.py +++ b/src/sio3pack/graph/graph.py @@ -3,5 +3,15 @@ class Graph: A class to represent a job graph. """ + @classmethod + def from_dict(cls, data: dict): + raise NotImplementedError() + def __init__(self, name: str): self.name = name + + def get_prog_files(self) -> list[str]: + """ + Get all program files in the graph. + """ + raise NotImplementedError() diff --git a/src/sio3pack/graph/graph_manager.py b/src/sio3pack/graph/graph_manager.py new file mode 100644 index 0000000..4a8e4f8 --- /dev/null +++ b/src/sio3pack/graph/graph_manager.py @@ -0,0 +1,29 @@ +import json + +from sio3pack.files import File +from sio3pack.graph.graph import Graph + + +class GraphManager: + @classmethod + def from_file(cls, file: File): + graphs = {} + content = json.loads(file.read()) + for name, graph in content.items(): + graphs[name] = Graph.from_dict(graph) + return cls(graphs) + + def __init__(self, graphs: dict[str, Graph]): + self.graphs = graphs + + def get_prog_files(self) -> list[str]: + """ + Get all program files used in all graphs. + """ + files = set() + for graph in self.graphs.values(): + files.update(graph.get_prog_files()) + return list(files) + + def get(self, name: str) -> Graph: + return self.graphs[name] diff --git a/src/sio3pack/graph/graph_op.py b/src/sio3pack/graph/graph_op.py new file mode 100644 index 0000000..f5d4994 --- /dev/null +++ b/src/sio3pack/graph/graph_op.py @@ -0,0 +1,23 @@ +from sio3pack.graph.graph import Graph + + +class GraphOperation: + """ + A class to represent a graph that should be run on workers. + Allows for returning results. + """ + + def __init__(self, graph: Graph, return_results: bool = False, return_func: callable = None): + """ + :param graph: The graph to run on workers. + :param return_results: Whether to return the results. + :param return_func: The function to use to return the + results, if return_results is True. + """ + self.graph = graph + self.return_results = return_results + self.return_func = return_func + + def return_results(self, data: dict): + if self.return_func: + return self.return_func(data) diff --git a/src/sio3pack/packages/exceptions.py b/src/sio3pack/packages/exceptions.py new file mode 100644 index 0000000..1f9e0e4 --- /dev/null +++ b/src/sio3pack/packages/exceptions.py @@ -0,0 +1,19 @@ +class UnknownPackageType(Exception): + def __init__(self, arg: str | int) -> None: + if isinstance(arg, str): + self.path = arg + super().__init__(f"Unknown package type for file {arg}.") + else: + self.problem_id = arg + super().__init__(f"Unknown package type for problem with id={arg}.") + + +class ImproperlyConfigured(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class PackageAlreadyExists(Exception): + def __init__(self, problem_id: int) -> None: + self.problem_id = problem_id + super().__init__(f"A package already exists for problem with id={problem_id}.") diff --git a/src/sio3pack/packages/package/django/__init__.py b/src/sio3pack/packages/package/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sio3pack/packages/package/django/handler.py b/src/sio3pack/packages/package/django/handler.py new file mode 100644 index 0000000..e838fca --- /dev/null +++ b/src/sio3pack/packages/package/django/handler.py @@ -0,0 +1,14 @@ +from typing import Type + +from sio3pack.packages.exceptions import ImproperlyConfigured + + +class DjangoHandler: + def __init__(self, package: Type["Package"], problem_id: int): + self.package = package + self.problem_id = problem_id + + +class NoDjangoHandler: + def __call__(self, *args, **kwargs): + raise ImproperlyConfigured("sio3pack is not installed with Django support.") diff --git a/src/sio3pack/packages/package/model.py b/src/sio3pack/packages/package/model.py index fa7a215..9438957 100644 --- a/src/sio3pack/packages/package/model.py +++ b/src/sio3pack/packages/package/model.py @@ -1,17 +1,96 @@ +import importlib from typing import Any -from sio3pack.files.file import File -from sio3pack.graph.graph import Graph -from sio3pack.test.test import Test +from sio3pack import LocalFile +from sio3pack.files import File +from sio3pack.graph import Graph, GraphOperation +from sio3pack.packages.exceptions import UnknownPackageType +from sio3pack.packages.package.django.handler import NoDjangoHandler +from sio3pack.test import Test +from sio3pack.utils.archive import Archive +from sio3pack.utils.classinit import RegisteredSubclassesBase -class Package: +class Package(RegisteredSubclassesBase): """ Base class for all packages. """ + abstract = True + def __init__(self): - pass + super().__init__() + + @classmethod + def identify(cls, file: LocalFile): + """ + Identify if the package is of this type. + """ + raise NotImplementedError() + + @classmethod + def from_file(cls, file: LocalFile, django_settings=None): + """ + Create a package from a file. + """ + for subclass in cls.subclasses: + if subclass.identify(file): + package = subclass() + package._from_file(file, django_settings) + return package + raise UnknownPackageType(file.path) + + def _from_file(self, file: LocalFile): + self.file = file + if isinstance(file, LocalFile): + if Archive.is_archive(file.path): + self.is_archive = True + else: + self.is_archive = False + + @classmethod + def identify_db(cls, problem_id: int): + """ + Identify if the package is of this type. Should check if there + is a package of this type in the database with the given problem_id. + """ + raise NotImplementedError() + + @classmethod + def from_db(cls, problem_id: int): + """ + Create a package from the database. If sio3pack isn't installed with Django + support, it should raise an ImproperlyConfigured exception. If there is no + package with the given problem_id, it should raise an UnknownPackageType + exception. + """ + for subclass in cls.subclasses: + if subclass.identify_db(problem_id): + package = subclass() + package._from_db(problem_id) + return package + raise UnknownPackageType(problem_id) + + def _from_db(self, problem_id: int): + """ + Internal method to setup the package from the database. If sio3pack + isn't installed with Django support, it should raise an ImproperlyConfigured + exception. + """ + self.problem_id = problem_id + + def _setup_django_handler(self, problem_id: int): + try: + import django + + self.django_enabled = True + module_path, class_name = self.django_handler.rsplit(".", 1) + module = importlib.import_module(module_path) + handler = getattr(module, class_name) + self.django = handler(package=self, problem_id=problem_id) + except ImportError: + self.django_enabled = False + self.django = NoDjangoHandler() def get_task_id(self) -> str: pass @@ -37,11 +116,18 @@ def get_tests(self) -> list[Test]: def get_test(self, test_id: str) -> Test: pass - def get_package_graph(self) -> Graph: + def get_unpack_graph(self) -> GraphOperation | None: + pass + + def get_run_graph(self, file: File, tests: list[Test] | None = None) -> GraphOperation | None: pass - def get_run_graph(self, file: File, tests: list[Test] | None = None) -> Graph: + def get_save_outs_graph(self, tests: list[Test] | None = None) -> GraphOperation | None: pass - def get_save_outs_graph(self, tests: list[Test] | None = None) -> Graph: + def save_to_db(self, problem_id: int): + """ + Save the package to the database. If sio3pack isn't installed with Django + support, it should raise an ImproperlyConfigured exception. + """ pass diff --git a/src/sio3pack/packages/sinolpack/enums.py b/src/sio3pack/packages/sinolpack/enums.py new file mode 100644 index 0000000..0f44533 --- /dev/null +++ b/src/sio3pack/packages/sinolpack/enums.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class ModelSolutionKind(Enum): + NORMAL = 0 + SLOW = 1 + INCORRECT = 2 + + @classmethod + def from_regex(cls, group): + if group == "": + return cls.NORMAL + if group == "s": + return cls.SLOW + if group == "b": + return cls.INCORRECT + raise ValueError(f"Invalid model solution kind: {group}") diff --git a/src/sio3pack/packages/sinolpack/model.py b/src/sio3pack/packages/sinolpack/model.py index a31f567..9d31864 100644 --- a/src/sio3pack/packages/sinolpack/model.py +++ b/src/sio3pack/packages/sinolpack/model.py @@ -1,4 +1,16 @@ +import os +import re +import tempfile + +import yaml + +from sio3pack import LocalFile +from sio3pack.graph import Graph, GraphManager, GraphOperation +from sio3pack.packages.exceptions import ImproperlyConfigured from sio3pack.packages.package import Package +from sio3pack.packages.sinolpack.enums import ModelSolutionKind +from sio3pack.util import naturalsort_key +from sio3pack.utils.archive import Archive, UnrecognizedArchiveFormat class Sinolpack(Package): @@ -6,4 +18,355 @@ class Sinolpack(Package): Represents a OIOIOI's standard problem package. """ - pass + django_handler = "sio3pack.django.sinolpack.handler.SinolpackDjangoHandler" + + @classmethod + def _find_main_dir(cls, archive: Archive) -> str | None: + dirs = list(map(os.path.normcase, archive.dirnames())) + dirs = list(map(os.path.normpath, dirs)) + toplevel_dirs = list(set(f.split(os.sep)[0] for f in dirs)) + problem_dirs = [] + for dir in toplevel_dirs: + for required_subdir in ("in", "out"): + if all(f.split(os.sep)[:2] != [dir, required_subdir] for f in dirs): + break + else: + problem_dirs.append(dir) + if len(problem_dirs) == 1: + return problem_dirs[0] + + return None + + @classmethod + def identify(cls, file: LocalFile) -> bool: + """ + Identifies whether file is a Sinolpack. + + :param file: File with package. + :return: True when file is a Sinolpack, otherwise False. + """ + path = file.path + try: + archive = Archive(path) + return cls._find_main_dir(archive) is not None + except UnrecognizedArchiveFormat: + return os.path.exists(os.path.join(path, "in")) and os.path.exists(os.path.join(path, "out")) + + @classmethod + def identify_from_db(cls, problem_id: int) -> bool: + """ + Identifies whether problem is a Sinolpack. + + :param problem_id: ID of the problem. + :return: True when problem is a Sinolpack, otherwise False. + """ + from sio3pack.django.sinolpack.models import SinolpackPackage + + return SinolpackPackage.objects.filter(problem_id=problem_id).exists() + + def __del__(self): + if hasattr(self, "tmpdir"): + self.tmpdir.cleanup() + + def __init__(self): + super().__init__() + + def _from_file(self, file: LocalFile, django_settings=None): + super()._from_file(file) + if self.is_archive: + archive = Archive(file.path) + self.short_name = self._find_main_dir(archive) + self.tmpdir = tempfile.TemporaryDirectory() + archive.extract(to_path=self.tmpdir.name) + self.rootdir = os.path.join(self.tmpdir.name, self.short_name) + else: + # FIXME: Won't work in sinol-make. + self.short_name = os.path.basename(file.path) + self.rootdir = file.path + + try: + graph_file = self.get_in_root("graph.json") + self.graph_manager = GraphManager.from_file(graph_file) + except FileNotFoundError: + self.has_custom_graph = False + + self.django_settings = django_settings + + self._process_package() + + def _from_db(self, problem_id: int): + super()._from_db(problem_id) + super()._setup_django_handler(problem_id) + if not self.django_enabled: + raise ImproperlyConfigured("sio3pack is not installed with Django support.") + + def _default_graph_manager(self) -> GraphManager: + return GraphManager( + { + "unpack": Graph.from_dict( + { + "name": "unpack", + # ... + } + ) + } + ) + + def _get_from_django_settings(self, key: str, default=None): + if self.django_settings is None: + return default + return getattr(self.django_settings, key, default) + + def get_doc_dir(self) -> str: + """ + Returns the path to the directory containing the problem's documents. + """ + return os.path.join(self.rootdir, "doc") + + def get_in_doc_dir(self, filename: str) -> LocalFile: + """ + Returns the path to the input file in the documents' directory. + """ + return LocalFile(os.path.join(self.get_doc_dir(), filename)) + + def get_in_root(self, filename: str) -> LocalFile: + """ + Returns the path to the input file in the root directory. + """ + return LocalFile(os.path.join(self.rootdir, filename)) + + def get_prog_dir(self) -> str: + """ + Returns the path to the directory containing the problem's program files. + """ + return os.path.join(self.rootdir, "prog") + + def get_in_prog_dir(self, filename: str) -> LocalFile: + """ + Returns the path to the input file in the program directory. + """ + return LocalFile(os.path.join(self.get_prog_dir(), filename)) + + def get_attachments_dir(self) -> str: + """ + Returns the path to the directory containing the problem's attachments. + """ + return os.path.join(self.rootdir, "attachments") + + def _process_package(self): + self._process_config_yml() + self._detect_full_name() + self._detect_full_name_translations() + self._process_prog_files() + self._process_statements() + self._process_attachments() + + if not self.has_custom_graph: + # Create the graph with processed files. + # TODO: Uncomment this line when Graph will work. + # self.graph_manager = self._default_graph_manager() + pass + + def _process_config_yml(self): + """ + Process the config.yml file. If it exists, it will be loaded into the config attribute. + """ + try: + config = self.get_in_root("config.yml") + self.config = yaml.safe_load(config.read()) + except FileNotFoundError: + self.config = {} + + def _detect_full_name(self): + """ + Sets the problem's full name from the ``config.yml`` (key ``title``) + or from the ``title`` tag in the LaTeX source file (backwards compatibility). + The ``config.yml`` file takes precedence over the LaTeX source. + + Example of how the ``title`` tag may look like: + \title{A problem} + """ + if "title" in self.config: + self.full_name = self.config["title"] + return + + try: + source = self.get_in_doc_dir(self.short_name + "zad.tex") + text = source.read() + r = re.search(r"^[^%]*\\title{(.+)}", text, re.MULTILINE) + if r is not None: + self.full_name = r.group(1) + except FileNotFoundError: + pass + + def _detect_full_name_translations(self): + """Creates problem's full name translations from the ``config.yml`` + (keys matching the pattern ``title_[a-z]{2}``, where ``[a-z]{2}`` represents + two-letter language code defined in ``settings.py``), if any such key is given. + """ + self.lang_titles = {} + for lang_code, _ in self._get_from_django_settings("LANGUAGES", [("en", "English")]): + key = f"title_{lang_code}" + if key in self.config: + self.lang_titles[lang_code] = self.config[key] + + def get_submittable_extensions(self): + """ + Returns a list of extensions that are submittable. + """ + return self.config.get( + "submittable_langs", + self._get_from_django_settings("SUBMITTABLE_LANGUAGES", ["c", "cpp", "cc", "cxx", "py"]), + ) + + def get_model_solution_regex(self): + """ + Returns the regex used to determine model solutions. + """ + extensions = self.get_submittable_extensions() + return rf"^{self.short_name}[0-9]*([bs]?)[0-9]*(_.*)?\.(" + "|".join(extensions) + ")" + + def _get_model_solutions(self) -> list[tuple[ModelSolutionKind, str]]: + """ + Returns a list of model solutions, where each element is a tuple of model solution kind and filename. + """ + if not os.path.exists(self.get_prog_dir()): + return [] + + regex = self.get_model_solution_regex() + model_solutions = [] + for file in os.listdir(self.get_prog_dir()): + match = re.match(regex, file) + if match and os.path.isfile(os.path.join(self.get_prog_dir(), file)): + model_solutions.append((ModelSolutionKind.from_regex(match.group(1)), file)) + + return model_solutions + + def sort_model_solutions( + self, model_solutions: list[tuple[ModelSolutionKind, str]] + ) -> list[tuple[ModelSolutionKind, str]]: + """ + Sorts model solutions by kind. + """ + + def sort_key(model_solution): + kind, name = model_solution + return kind.value, naturalsort_key(name[: name.index(".")]) + + return list(sorted(model_solutions, key=sort_key)) + + def _process_prog_files(self): + """ + Process all files in the problem's program directory that are used. + Saves all models solution files. If the problem has a custom graph file, + takes the files that are used in the graph. Otherwise, ingen, inwer and + files in `extra_compilation_files` and `extra_execution_files` from config + are saved. + """ + + # Process model solutions. + self.model_solutions = self.sort_model_solutions(self._get_model_solutions()) + + if self.has_custom_graph: + self.additional_files = self.graph_manager.get_prog_files() + else: + self.additional_files = [] + self.additional_files.extend(self.config.get("extra_compilation_files", [])) + self.additional_files.extend(self.config.get("extra_execution_files", [])) + extensions = self.get_submittable_extensions() + self.special_files: dict[str, bool] = {} + for file in ("ingen", "inwer", "soc", "chk"): + try: + self.additional_files.append( + LocalFile.get_file_matching_extension( + self.get_prog_dir(), self.short_name + file, extensions + ).filename + ) + self.special_files[file] = True + except FileNotFoundError: + self.special_files[file] = False + + def _process_statements(self): + """ + Creates a problem statement from html or pdf source. + + TODO: we have to support this somehow, but we can't use makefiles. Probably a job for sio3workers. + If `USE_SINOLPACK_MAKEFILES` is set to True in the OIOIOI settings, + the pdf file will be compiled from a LaTeX source. + """ + docdir = self.get_doc_dir() + if not os.path.exists(docdir): + return + + lang_prefs = [""] + [ + f"-{lang}" for lang, _ in self._get_from_django_settings("LANGUAGES", [("en", "English"), ("pl", "Polish")]) + ] + + self.lang_statements = {} + for lang in lang_prefs: + try: + htmlzipfile = self.get_in_doc_dir(f"{self.short_name}zad{lang}.html.zip") + # TODO: what to do with html? + # if self._html_disallowed(): + # raise ProblemPackageError( + # _( + # "You cannot upload package with " + # "problem statement in HTML. " + # "Try again using PDF format." + # ) + # ) + # + # self._force_index_encoding(htmlzipfile) + # statement = ProblemStatement(problem=self.problem, language=lang[1:]) + # statement.content.save( + # self.short_name + lang + '.html.zip', File(open(htmlzipfile, 'rb')) + # ) + except FileNotFoundError: + pass + + try: + pdffile = self.get_in_doc_dir(f"{self.short_name}zad{lang}.pdf") + if lang == "": + self.statement = pdffile + else: + self.lang_statements[lang[1:]] = pdffile + except FileNotFoundError: + pass + + def _process_attachments(self): + """ """ + attachments_dir = self.get_attachments_dir() + if not os.path.isdir(attachments_dir): + return + self.attachments = [ + attachment + for attachment in os.listdir(attachments_dir) + if os.path.isfile(os.path.join(attachments_dir, attachment)) + ] + + def get_unpack_graph(self) -> GraphOperation | None: + try: + return GraphOperation( + self.graph_manager.get("unpack"), + True, + self._unpack_return_data, + ) + except KeyError: + return None + + def _unpack_return_data(self, data: dict): + """ + Adds data received from the unpack operation to the package. + """ + # TODO: implement. The unpack will probably return tests, so we need to process them. + pass + + def save_to_db(self, problem_id: int): + """ + Save the package to the database. If sio3pack isn't installed with Django + support, it should raise an ImproperlyConfigured exception. + """ + self._setup_django_handler(problem_id) + if not self.django_enabled: + raise ImproperlyConfigured("sio3pack is not installed with Django support.") + self.django.save_to_db() diff --git a/src/sio3pack/test/__init__.py b/src/sio3pack/test/__init__.py index 0478c51..9c36d80 100644 --- a/src/sio3pack/test/__init__.py +++ b/src/sio3pack/test/__init__.py @@ -1 +1 @@ -from sio3pack.test import Test +from sio3pack.test.test import Test diff --git a/src/sio3pack/test/simple_test.py b/src/sio3pack/test/simple_test.py index 57c375c..3532aa0 100644 --- a/src/sio3pack/test/simple_test.py +++ b/src/sio3pack/test/simple_test.py @@ -1,5 +1,5 @@ -from sio3pack.files.file import File -from sio3pack.test.test import Test +from sio3pack.files import File +from sio3pack.test import Test class SimpleTest(Test): diff --git a/src/sio3pack/util.py b/src/sio3pack/util.py new file mode 100644 index 0000000..855f96f --- /dev/null +++ b/src/sio3pack/util.py @@ -0,0 +1,6 @@ +import re + + +def naturalsort_key(key): + convert = lambda text: int(text) if text.isdigit() else text + return [convert(c) for c in re.split("([0-9]+)", key)] diff --git a/src/sio3pack/utils/__init__.py b/src/sio3pack/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sio3pack/utils/archive.py b/src/sio3pack/utils/archive.py new file mode 100644 index 0000000..6a6b8f5 --- /dev/null +++ b/src/sio3pack/utils/archive.py @@ -0,0 +1,242 @@ +# Taken from +# https://github.com/gdub/python-archive/blob/master/archive/__init__.py + +# Copyright (c) Gary Wilson Jr. and contributors. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import tarfile +import zipfile + + +class ArchiveException(RuntimeError): + """Base exception class for all archive errors.""" + + +class UnrecognizedArchiveFormat(ArchiveException): + """Error raised when passed file is not a recognized archive format.""" + + +class UnsafeArchive(ArchiveException): + """ + Error raised when passed file contains paths that would be extracted + outside of the target directory. + """ + + +def extract(path, to_path="", ext="", **kwargs): + """ + Unpack the tar or zip file at the specified path to the directory + specified by to_path. + """ + Archive(path, ext=ext).extract(to_path, **kwargs) + + +class Archive(object): + """ + The external API class that encapsulates an archive implementation. + """ + + def __init__(self, file, ext=""): + """ + Arguments: + * 'file' can be a string path to a file or a file-like object. + * Optional 'ext' argument can be given to override the file-type + guess that is normally performed using the file extension of the + given 'file'. Should start with a dot, e.g. '.tar.gz'. + """ + self.filename = file + self._archive = self._archive_cls(self.filename, ext=ext)(self.filename) + + def __str__(self): + return f"" + + @staticmethod + def _archive_cls(file, ext=""): + """ + Return the proper Archive implementation class, based on the file type. + """ + cls = None + filename = None + if isinstance(file, str): + filename = file + else: + try: + filename = file.name + except AttributeError: + raise UnrecognizedArchiveFormat("File object not a recognized archive format.") + lookup_filename = filename + ext + base, tail_ext = os.path.splitext(lookup_filename.lower()) + cls = extension_map.get(tail_ext) + if not cls: + base, ext = os.path.splitext(base) + cls = extension_map.get(ext) + if not cls: + raise UnrecognizedArchiveFormat("Path not a recognized archive format: %s" % filename) + return cls + + @classmethod + def is_archive(cls, file: str) -> bool: + """ + Check if the file is a recognized archive format. + """ + try: + cls._archive_cls(file) + return True + except UnrecognizedArchiveFormat: + return False + + def extract(self, *args, **kwargs): + self._archive.extract(*args, **kwargs) + + def filenames(self): + return self._archive.filenames() + + def dirnames(self): + return self._archive.dirnames() + + def extracted_size(self): + return self._archive.extracted_size() + + +class BaseArchive(object): + """ + Base Archive class. Implementations should inherit this class. + """ + + def __del__(self): + if hasattr(self, "_archive"): + self._archive.close() + + def filenames(self): + """ + Return a list of the filenames contained in the archive. + """ + raise NotImplementedError() + + def dirnames(self): + """ + Return a list of the dirnames contained in the archive. + """ + raise NotImplementedError() + + def extracted_size(self): + """ + Return total file size of extracted files in bytes. + """ + raise NotImplementedError() + + def _extract(self, to_path): + """ + Performs the actual extraction. Separate from 'extract' method so that + we don't recurse when subclasses don't declare their own 'extract' + method. + """ + self._archive.extractall(to_path) + + def extract(self, to_path="", method="safe"): + if method == "safe": + self.check_files(to_path) + elif method == "insecure": + pass + else: + raise ValueError("Invalid method option") + self._extract(to_path) + + def check_files(self, to_path=None): + """ + Check that all of the files contained in the archive are within the + target directory. + """ + if to_path: + target_path = os.path.normpath(os.path.realpath(to_path)) + else: + target_path = os.getcwd() + for filename in self.filenames(): + extract_path = os.path.join(target_path, filename) + extract_path = os.path.normpath(os.path.realpath(extract_path)) + if not extract_path.startswith(target_path): + raise UnsafeArchive( + "Archive member destination is outside the target" " directory. member: %s" % filename + ) + + +class TarArchive(BaseArchive): + def __init__(self, file): + # tarfile's open uses different parameters for file path vs. file obj. + if isinstance(file, str): + self._archive = tarfile.open(name=file) + else: + self._archive = tarfile.open(fileobj=file) + + def filenames(self): + return [tarinfo.name for tarinfo in self._archive.getmembers() if tarinfo.isfile()] + + def dirnames(self): + return [tarinfo.name for tarinfo in self._archive.getmembers() if tarinfo.isdir()] + + def extracted_size(self): + total = 0 + for member in self._archive: + total += member.size + return total + + def check_files(self, to_path=None): + BaseArchive.check_files(self, to_path) + + for finfo in self._archive: + if finfo.issym(): + raise UnsafeArchive("Archive contains symlink: " + finfo.name) + if finfo.islnk(): + raise UnsafeArchive("Archive contains hardlink: " + finfo.name) + + +class ZipArchive(BaseArchive): + def __init__(self, file): + # ZipFile's 'file' parameter can be path (string) or file-like obj. + self._archive = zipfile.ZipFile(file) + + def extracted_size(self): + total = 0 + for member in self._archive.infolist(): + total += member.file_size + return total + + def filenames(self): + return [zipinfo.filename for zipinfo in self._archive.infolist() if not zipinfo.is_dir()] + + def dirnames(self): + dirs = set() + for zipinfo in self._archive.infolist(): + if zipinfo.is_dir(): + dirs.add(zipinfo.filename) + else: + dirs.add(os.path.dirname(zipinfo.filename)) + return list(dirs) + + +extension_map = { + ".tar": TarArchive, + ".tar.bz2": TarArchive, + ".tar.gz": TarArchive, + ".tgz": TarArchive, + ".tz2": TarArchive, + ".zip": ZipArchive, +} diff --git a/src/sio3pack/utils/classinit.py b/src/sio3pack/utils/classinit.py new file mode 100644 index 0000000..a1356b4 --- /dev/null +++ b/src/sio3pack/utils/classinit.py @@ -0,0 +1,87 @@ +# From oioioi/base/utils/__init__.py + + +class ClassInitMeta(type): + """Meta class triggering __classinit__ on class intialization.""" + + def __init__(cls, class_name, bases, new_attrs): + super(ClassInitMeta, cls).__init__(class_name, bases, new_attrs) + cls.__classinit__() + + +class ClassInitBase(object, metaclass=ClassInitMeta): + """Abstract base class injecting ClassInitMeta meta class.""" + + @classmethod + def __classinit__(cls): + """ + Empty __classinit__ implementation. + + This must be a no-op as subclasses can't reliably call base class's + __classinit__ from their __classinit__s. + + Subclasses of __classinit__ should look like: + + .. python:: + + class MyClass(ClassInitBase): + + @classmethod + def __classinit__(cls): + # Need globals().get as MyClass may be still undefined. + super(globals().get('MyClass', cls), + cls).__classinit__() + ... + + class Derived(MyClass): + + @classmethod + def __classinit__(cls): + super(globals().get('Derived', cls), + cls).__classinit__() + ... + """ + pass + + +class RegisteredSubclassesBase(ClassInitBase): + """A base class for classes which should have a list of subclasses + available. + + The list of subclasses is available in their :attr:`subclasses` class + attributes. Classes which have *explicitly* set :attr:`abstract` class + attribute to ``True`` are not added to :attr:`subclasses`. + """ + + _subclasses_loaded = False + + @classmethod + def __classinit__(cls): + this_cls = globals().get("RegisteredSubclassesBase", cls) + super(this_cls, cls).__classinit__() + if this_cls is cls: + # This is RegisteredSubclassesBase class. + return + + assert ( + "subclasses" not in cls.__dict__ + ), "%s defines attribute subclasses, but has " "RegisteredSubclassesMeta metaclass" % (cls,) + cls.subclasses = [] + cls.abstract = cls.__dict__.get("abstract", False) + + def find_superclass(cls): + superclasses = [c for c in cls.__bases__ if issubclass(c, this_cls)] + if not superclasses: + return None + if len(superclasses) > 1: + raise AssertionError("%s derives from more than one " "RegisteredSubclassesBase" % (cls.__name__,)) + superclass = superclasses[0] + return superclass + + # Add the class to all superclasses' 'subclasses' attribute, including + # self. + superclass = cls + while superclass is not this_cls: + if not cls.abstract: + superclass.subclasses.append(cls) + superclass = find_superclass(superclass) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..29564c3 --- /dev/null +++ b/test.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +cd "`dirname $0`" + +cd tests/test_django +./manage.py makemigrations +./manage.py migrate +cd ../.. + +if [ -z "$1" ]; then + pytest -v tests/ +else + pytest -v tests/ $1 +fi diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..890beac --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest + +try: + import django + + __django_installed = True +except ImportError: + __django_installed = False + + +def pytest_collection_modifyitems(config, items): + for item in items: + if "no_django" in item.keywords: + if __django_installed: + item.add_marker(pytest.mark.skip(reason="Django is installed, skipping no_django tests.")) + + +def pytest_ignore_collect(collection_path, config): + if not __django_installed: + return "test_django" in str(collection_path) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..1e2a433 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,116 @@ +import importlib.util +import os.path +import shutil +import tarfile +import tempfile +import zipfile +from enum import Enum + +import pytest + + +class Compression(Enum): + NONE = "" + ZIP = "zip" + TAR_GZ = "tar.gz" + TGZ = "tgz" + + +all_compressions = [c.value for c in Compression if c != Compression.NONE] + + +class PackageInfo: + def __init__(self, path, type, task_id, compression): + self.path = path + self.type = type + self.task_id = task_id + self.compression = compression + + def is_archive(self): + return self.compression != Compression.NONE + + +def _tar_archive(dir, dest, compression=None): + """ + Create a tar archive of the specified directory. + """ + if compression is None: + mode = "w" + else: + mode = f"w:{compression}" + with tarfile.open(dest, mode) as tar: + tar.add(dir, arcname=os.path.basename(dir)) + + +def _zip_archive(dir, dest): + """ + Create a zip archive of the specified directory. + """ + with zipfile.ZipFile(dest, "w") as zip: + for root, dirs, files in os.walk(dir): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.join(os.path.basename(dir), os.path.relpath(file_path, dir)) + zip.write(file_path, arcname) + + +def _create_package(package_name, tmpdir, archive=False, extension=Compression.ZIP): + packages = os.path.join(os.path.dirname(__file__), "test_packages") + if not os.path.exists(os.path.join(packages, package_name)): + raise FileNotFoundError(f"Package {package_name} does not exist") + + spec = importlib.util.spec_from_file_location(package_name, os.path.join(packages, package_name, "__init__.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + task_id = module.TASK_ID + type = module.TYPE + package_path = os.path.join(tmpdir.name, task_id) + shutil.copytree(os.path.join(packages, package_name), package_path) + shutil.rmtree(os.path.join(package_path, "__pycache__"), ignore_errors=True) + os.unlink(os.path.join(package_path, "__init__.py")) + + if archive: + if extension == Compression.ZIP: + _zip_archive(package_path, os.path.join(tmpdir.name, f"{task_id}.zip")) + elif extension == Compression.TAR_GZ or extension == Compression.TGZ: + _tar_archive(package_path, os.path.join(tmpdir.name, f"{task_id}.{extension.value}"), "gz") + else: + raise ValueError(f"Unknown extension {extension}") + package_path = os.path.join(tmpdir.name, f"{task_id}.{extension.value}") + + return PackageInfo( + path=package_path, + type=type, + task_id=task_id, + compression=extension, + ) + + +@pytest.fixture +def get_package(request): + """ + Fixture to create a temporary directory with specified package. + """ + package_name = request.param + tmpdir = tempfile.TemporaryDirectory() + package_info = _create_package(package_name, tmpdir) + + yield lambda: package_info + + tmpdir.cleanup() + + +@pytest.fixture +def get_archived_package(request): + """ + Fixture to create a temporary directory with specified package, but archived. + """ + package_name, extension = request.param + archive = extension != Compression.NONE + tmpdir = tempfile.TemporaryDirectory() + package_info = _create_package(package_name, tmpdir, archive, extension) + + yield lambda: package_info + + tmpdir.cleanup() diff --git a/tests/packages/__init__.py b/tests/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/sinolpack/__init__.py b/tests/packages/sinolpack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/sinolpack/test_sinolpack.py b/tests/packages/sinolpack/test_sinolpack.py new file mode 100644 index 0000000..d798c4a --- /dev/null +++ b/tests/packages/sinolpack/test_sinolpack.py @@ -0,0 +1,32 @@ +import os.path + +import pytest + +import sio3pack +from sio3pack.packages import Sinolpack +from tests.fixtures import Compression, PackageInfo, all_compressions, get_archived_package, get_package + + +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_from_file(get_archived_package): + package_info: PackageInfo = get_archived_package() + assert package_info.type == "sinolpack" + package = sio3pack.from_file(package_info.path) + assert isinstance(package, Sinolpack) + assert package.short_name == package_info.task_id + if package_info.is_archive(): + assert package.is_archive + else: + assert package.rootdir == package_info.path + + +@pytest.mark.no_django +@pytest.mark.parametrize("get_package", ["simple"], indirect=True) +def test_no_django(get_package): + package_info: PackageInfo = get_package() + with pytest.raises(sio3pack.ImproperlyConfigured): + sio3pack.from_db(1) + + package = sio3pack.from_file(package_info.path) + with pytest.raises(sio3pack.ImproperlyConfigured): + package.save_to_db(1) diff --git a/tests/test_django/__init__.py b/tests/test_django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_django/manage.py b/tests/test_django/manage.py new file mode 100755 index 0000000..8b47138 --- /dev/null +++ b/tests/test_django/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_django.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/test_django/test_django/__init__.py b/tests/test_django/test_django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_django/test_django/asgi.py b/tests/test_django/test_django/asgi.py new file mode 100644 index 0000000..b83d195 --- /dev/null +++ b/tests/test_django/test_django/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for test_django project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_django.settings") + +application = get_asgi_application() diff --git a/tests/test_django/test_django/settings.py b/tests/test_django/test_django/settings.py new file mode 100644 index 0000000..68bec3e --- /dev/null +++ b/tests/test_django/test_django/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for test_django project. + +Generated by 'django-admin startproject' using Django 5.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-f$oojxszb)@)-w0&-=4+24&oa$brdv6ltj34n@25=rq1n-kq6&" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Here add all sio3pack apps + "sio3pack.django.sinolpack", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "test_django.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "test_django.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/test_django/test_django/urls.py b/tests/test_django/test_django/urls.py new file mode 100644 index 0000000..7a1b55e --- /dev/null +++ b/tests/test_django/test_django/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for test_django project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/tests/test_django/test_django/wsgi.py b/tests/test_django/test_django/wsgi.py new file mode 100644 index 0000000..a3bfd6b --- /dev/null +++ b/tests/test_django/test_django/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for test_django project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_django.settings") + +application = get_wsgi_application() diff --git a/tests/test_django/test_sio3pack/__init__.py b/tests/test_django/test_sio3pack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_django/test_sio3pack/test_sinolpack.py b/tests/test_django/test_sio3pack/test_sinolpack.py new file mode 100644 index 0000000..6516081 --- /dev/null +++ b/tests/test_django/test_sio3pack/test_sinolpack.py @@ -0,0 +1,22 @@ +import pytest + +import sio3pack +from sio3pack.django.sinolpack.models import SinolpackPackage +from sio3pack.packages import Sinolpack +from tests.fixtures import Compression, PackageInfo, get_archived_package + + +@pytest.mark.django_db +@pytest.mark.parametrize("get_archived_package", [("simple", c) for c in Compression], indirect=True) +def test_simple(get_archived_package): + package_info: PackageInfo = get_archived_package() + assert package_info.type == "sinolpack" + package = sio3pack.from_file(package_info.path) + assert isinstance(package, Sinolpack) + package.save_to_db(1) + assert SinolpackPackage.objects.filter(problem_id=1).exists() + db_package = SinolpackPackage.objects.get(problem_id=1) + assert db_package.short_name == package.short_name + + with pytest.raises(sio3pack.PackageAlreadyExists): + package.save_to_db(1) diff --git a/tests/test_packages/simple/__init__.py b/tests/test_packages/simple/__init__.py new file mode 100644 index 0000000..0f21af5 --- /dev/null +++ b/tests/test_packages/simple/__init__.py @@ -0,0 +1,2 @@ +TASK_ID = "abc" +TYPE = "sinolpack" diff --git a/tests/test_packages/simple/config.yml b/tests/test_packages/simple/config.yml new file mode 100644 index 0000000..020745c --- /dev/null +++ b/tests/test_packages/simple/config.yml @@ -0,0 +1 @@ +title: Simple package diff --git a/tests/test_packages/simple/in/.gitkeep b/tests/test_packages/simple/in/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_packages/simple/out/.gitkeep b/tests/test_packages/simple/out/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_packages/simple/prog/.gitkeep b/tests/test_packages/simple/prog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..e69de29