diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..23bbc11 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.env +.git +.gitignore +.dockerignore +node_modules/ +tests/ +code-exec-bruno/ +README.md +docker-compose.yml +Dockerfile +postgres/ +scripts/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cd8bdef --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Database Configuration +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=code_executor + +# Database Connection URL (used by yoyo-migrations) +# Format: postgresql://user:password@host:port/dbname +DATABASE_URL=postgresql://postgres:postgres@db:5432/code_executor diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 5d8945b..ce347e6 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -11,15 +11,28 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 - - name: Get latest tag + - name: Get latest tag from remote id: get_tag run: | - tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v1.0.0") + # Fetch all tags to ensure we see tags even if they aren't in this branch's history + git fetch --tags --force + + # Find the highest version tag using natural version sorting + # Filters for tags starting with 'v' + tag=$(git tag -l "v*" --sort=-v:refname | head -n 1) + + # If no tag is found, start at v1.0.0 + if [ -z "$tag" ]; then + tag="v1.0.0" + fi + echo "latest_tag=$tag" >> $GITHUB_OUTPUT + echo "Found latest tag: $tag" - name: Bump version (semantic roll over) id: bump @@ -28,6 +41,7 @@ jobs: base=${tag#v} IFS='.' read -r major minor patch <<< "$base" + # Logic: Rolls over to next decimal place if value is 9 if [ "$patch" -lt 9 ]; then patch=$((patch + 1)) else @@ -42,26 +56,31 @@ jobs: new_tag="v${major}.${minor}.${patch}" echo "new_tag=$new_tag" >> $GITHUB_OUTPUT + echo "New calculated tag: $new_tag" - name: Generate changelog id: changelog run: | prev=${{ steps.get_tag.outputs.latest_tag }} - new=${{ steps.bump.outputs.new_tag }} - - # If there was no previous tag, show all commits - if [ "$prev" = "v1.0.0" ]; then - log=$(git log --pretty=format:"- %s") - else + + # If the latest_tag was the fallback v1.0.0 and doesn't actually exist in git + if git rev-parse "$prev" >/dev/null 2>&1; then log=$(git log $prev..HEAD --pretty=format:"- %s") + else + log=$(git log --pretty=format:"- %s") fi + # Handle empty logs + if [ -z "$log" ]; then log="- No changes documented."; fi + echo "changelog<> $GITHUB_OUTPUT echo "$log" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - - name: Create new tag + - name: Create and Push Tag run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" git tag ${{ steps.bump.outputs.new_tag }} git push origin ${{ steps.bump.outputs.new_tag }} @@ -74,4 +93,4 @@ jobs: ## Changes since last release ${{ steps.changelog.outputs.changelog }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5462ba..e42378f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,46 @@ -*/tests/*/ -*/__pycache__/ +# Environment +.env +.venv/ +ENV/ +env/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# OS / IDE +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +.vscode/ +.idea/ + +# Database / Tools +*.sqlite +*.db +alembic/versions/__pycache__/ *.class *.exe \ No newline at end of file diff --git a/API/app.py b/API/app.py deleted file mode 100644 index 86f1390..0000000 --- a/API/app.py +++ /dev/null @@ -1,46 +0,0 @@ -from flask import Flask, request, jsonify -from executor import execute_code, execute_custom_code - -app = Flask(__name__, static_folder='html') - -@app.post('/run') -def custom_code_executor(): - """Endpoint to run the specific code with given lang""" - lang = request.args.get('lang') - if not lang: - return jsonify(status="error", message="Missing 'lang' query parameter"), 400 - - if not request.is_json: - return jsonify(status="error", message="Request must be JSON"), 400 - - data = request.get_json() - code = data.get('code') - if not code: - return jsonify(status="error", message="Missing 'code' in request body"), 400 - - res = execute_custom_code(code, lang) - return jsonify(res), (500 if res.get("status") == "error" else 200) - -@app.post('/code/') -def code_executor(question_id): - """Endpoint to execute code for a given question ID.""" - if not request.is_json: - return jsonify(status="error", message="Request must be JSON"), 400 - - data = request.get_json() - code, lang = data.get('code'), data.get('language') - if not code or not lang: - return jsonify(status="error", message="Missing 'code' or 'language'"), 400 - - res = execute_code(code, lang, question_id) - return jsonify(res), (500 if res.get("status") == "error" else 200) - -@app.route('/') -def home(): - """Serve the main HTML page.""" - return app.send_static_file('index.html') - -@app.errorhandler(404) -def page_not_found(e): - """Handle 404 errors by serving a custom page.""" - return app.send_static_file('404.html'), 404 diff --git a/API/config.py b/API/config.py deleted file mode 100644 index c003bd0..0000000 --- a/API/config.py +++ /dev/null @@ -1,59 +0,0 @@ -import os, json, re - -COMPILERS = { - "c": {"compiler": "gcc", "extension": ".c"}, - "cpp": {"compiler": "g++", "extension": ".cpp"}, - "java": {"compiler": "javac", "extension": ".java"}, - "python": {"interpreter": "python", "extension": ".py"}, - "javascript": {"interpreter": "node", "extension": ".js"}, -} - -TEST_CASES_ROOT_DIR = "tests" - -def validate_code(lang: str, code: str, rules: dict) -> bool: - # Validate code against language-specific rules - patterns = rules.get(lang, []) - return all(re.search(p, code) for p in patterns) - -def loadTest(qid: str) -> dict: - # Load test cases and configuration for a given question ID - qpath = os.path.join(TEST_CASES_ROOT_DIR, str(qid)) - if not os.path.isdir(qpath): - raise FileNotFoundError(f"Question directory not found: {qpath}") - - data = {"timeout": 5, "test_pairs": [], "templates": {}} - cfg_path = os.path.join(qpath, "config.json") - - if os.path.exists(cfg_path): - try: - with open(cfg_path, encoding="utf-8") as f: - cfg = json.load(f) - data["timeout"] = cfg.get("timeout", 5) - data["templates"] = cfg.get("templates", {}) - data["rules"] = cfg.get("rules", {}) - except json.JSONDecodeError: - print(f"Warning: invalid JSON in {cfg_path}. Using default timeout.") - else: - print(f"Warning: config.json not found for {qid}. Using default timeout.") - - ins, outs = {}, {} - for fn in os.listdir(qpath): - m = re.match(r'^(\d+)\.(in|out)$', fn) - if not m: - continue - with open(os.path.join(qpath, fn), encoding="utf-8") as f: - (ins if m.group(2) == "in" else outs)[m.group(1)] = f.read() - - nums = sorted(set(ins) | set(outs), key=int) - for n in nums: - if n not in outs: - raise ValueError(f"Missing {n}.out for question {qid}") - data["test_pairs"].append({ - "test_number": n, - "input": ins.get(n, ""), - "expected_output": outs[n] - }) - - if not data["test_pairs"]: - raise FileNotFoundError(f"No test pairs found for {qid}") - return data diff --git a/API/html/index.html b/API/html/index.html deleted file mode 100644 index 050eabe..0000000 --- a/API/html/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - DevScape API - - - - -
-
-

Code Executor API

-

- Welcome to the backend gateway of the - Code Executor API. -

-
-

Made for developers, by developers. View Docs

-
-
- - diff --git a/API/requirements.txt b/API/requirements.txt deleted file mode 100644 index afd93b9..0000000 --- a/API/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask==3.0.3 -gunicorn==23.0.0 \ No newline at end of file diff --git a/API/tests/README.md b/API/tests/README.md deleted file mode 100644 index a806cdf..0000000 --- a/API/tests/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# ๐Ÿงช Test Case Guidelines - -Each question is stored in a folder named with a number, for example: `1`, `2`, `3`. - -## ๐Ÿ“‚ Folder Structure - -``` -tests/ -โ”œโ”€โ”€ 1/ -โ”‚ โ”œโ”€โ”€ 1.in # ๐Ÿ“ Input for test case 1 -โ”‚ โ”œโ”€โ”€ 1.out # โœ… Expected output for test case 1 -โ”‚ โ”œโ”€โ”€ 2.in -โ”‚ โ””โ”€โ”€ 2.out -โ”œโ”€โ”€ 2/ -โ”‚ โ”œโ”€โ”€ 1.in -โ”‚ โ”œโ”€โ”€ 1.out -โ”‚ โ””โ”€โ”€ config.json # โš™๏ธ Optional: timeout, rules, templates -``` - ---- -- **Folder name** = `folder_id` used in the API endpoint. -- `.in` โ†’ Input file. -- `.out` โ†’ Expected output file. -- Optional **`config.json`** can define: - - โฑ๏ธ Timeout - - ๐Ÿงฉ Default code templates - - ๐Ÿงฎ Validation rules - -## ๐Ÿงพ Example `config.json` - -```json -{ - "timeout": 10, - "templates": { - "python": "def main():\n __CODE_GOES_HERE__\n\nif __name__ == '__main__':\n main()" - }, - "rules": { - "python": [ - "\\w+\\s*=\\s*\\[.*\\]", - "len\\(\\w+\\)" - ] - } -} -``` - -## โž• Adding a New Test Case - -1. Create a new folder with the next number. -2. Add `.in` files for inputs. -3. Add `.out` files for expected outputs. -4. Make sure input and output filenames match. -4. Add `config.json` if special rules or timeout are needed. diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..11fded4 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,68 @@ +# CodeExecutor-API Documentation + +Dynamic code execution API supporting multiple languages and database-backed problem management. + +## Base URL +`http://localhost:3000` + +## Interactive Documentation +Interactive docs are available at: [http://localhost:3000/docs](http://localhost:3000/docs) + +--- + +## Endpoints + +### 1. List Problems +Retrieve a summary of all problems in the database. Supports filtering by category or tag. +- **URL**: `/problems` +- **Method**: `GET` +- **Query Parameters**: + - `category`: Filter by category name (e.g., `/problems?category=Math`) + - `tag`: Filter by tag name (e.g., `/problems?tag=String`) +- **Response**: `200 OK` +```json +{ + "status": "success", + "data": [ + { "id": "uuid", "title": "Two Sum", "difficulty": "easy" } + ] +} +``` + +### 2. Get Problem Details +Retrieve specific problem metadata, configuration, and public test cases. +- **URL**: `/problem/` +- **Method**: `GET` +- **Response**: `200 OK` + +### 3. Execute Code (Problem-based) +Run code against test cases associated with a specific problem. +- **URL**: `/code/` +- **Method**: `POST` +- **Body**: +```json +{ + "language": "python", + "code": "print('hello')" +} +``` + +### 4. Custom Execution +Run arbitrary code without a pre-defined problem. +- **URL**: `/run?lang=python` +- **Method**: `POST` +- **Body**: +```json +{ + "code": "print('hello world')" +} +``` + +--- + +## Supported Languages +- **Python**: `python` +- **JavaScript**: `javascript` +- **Java**: `java` +- **C**: `c` +- **C++**: `cpp` diff --git a/Dockerfile b/Dockerfile index ebe550d..b0c4e37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,38 @@ FROM debian:bookworm-slim +# Install system dependencies RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential \ - default-jdk \ - python3 \ - python3-pip \ - nodejs \ - npm && \ + build-essential \ + default-jdk \ + python3 \ + python3-pip \ + nodejs \ + npm && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +# Fix python naming RUN update-alternatives --install /usr/bin/python python /usr/bin/python3 1 +# Create a non-root user +RUN useradd -m coder && \ + mkdir -p /usr/src/app && \ + chown coder:coder /usr/src/app + WORKDIR /usr/src/app -COPY API/requirements.txt . +# Install python dependencies as root (system-wide) +COPY src/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt --break-system-packages -COPY API/ . +# Copy application source +COPY --chown=coder:coder src/ . +COPY --chown=coder:coder alembic/ ./alembic/ +COPY --chown=coder:coder alembic.ini . + +USER coder EXPOSE 3000 -CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:3000", "--timeout", "60", "app:app"] +CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:3000", "--timeout", "60", "--access-logfile", "-", "app:app"] diff --git a/README.md b/README.md index eed77de..88623cf 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,108 @@ - # ๐Ÿ–ฅ๏ธ CodeExecutor-API -This project provides a API to run submitted code against predefined test cases. ๐Ÿงช +A professional, layered-architecture API for dynamic code execution. This project allows you to run code in multiple languages against database-backed problems and test cases. ๐Ÿš€๐Ÿงช + +--- + +## ๐Ÿ› ๏ธ Prerequisites +Before embarking on this journey, ensure your machine is equipped with: +- ๐Ÿณ **Docker**: [Install Docker](https://docs.docker.com/get-docker/) +- ๐Ÿณ **Docker Compose**: [Install Docker Compose](https://docs.docker.com/compose/install/) -## ๐Ÿ› ๏ธ Requirements: - `๐Ÿณ Docker` `๐Ÿณ Docker compose` +--- + +## ๐Ÿš€ Step-by-Step Setup Guide -## ๐Ÿš€ Running the API -Build and start the container: +Follow these steps to get your own instance of **CodeExecutor-API** up and running in minutes! +### 1๏ธโƒฃ Clone the Repository +Open your terminal and clone this project: +```bash +git clone https://github.com/Saannddy/CodeExecutor-API +cd CodeExecutor-API +``` + +### 2๏ธโƒฃ Configure Environment โš™๏ธ +The project uses a `.env` file for configuration. Copy the example to get started: +```bash +cp .env.example .env +``` +*(You can tweak the `DATABASE_URL` or ports inside `.env` if needed, but defaults work perfectly!)* + +### 3๏ธโƒฃ Build and Launch ๐Ÿ—๏ธ +Run the entire stack (API + PostgreSQL DB) using Docker Compose: ```bash docker compose up -d --build ``` +- `-d`: Runs containers in the background (**detached mode**). +- `--build`: Ensures all recent code changes are freshly compiled. + +### 5๏ธโƒฃ Automatic Initialization ๐Ÿช„ +On the very first run, the system will automatically: +- Create all database tables (using UUIDs for safety). +- Seed **7+ coding problems** (Two Sum, Palindrome, etc.). +- Link problems with their respective categories and tags. + --- -## ๐Ÿ”— API Endpoint -**POST** `http://127.0.0.1:3000/code/` +## ๐Ÿ—๏ธ Database Management +While the system handles initialization automatically, you may need these manual commands for development: -Request JSON Format: +### ๐Ÿ”„ Migrations (Alembic) +If you modify the models in `src/models/`, use these commands to sync the database: +- **Check Status**: `docker compose exec code-api alembic current` +- **Apply Migrations**: `docker compose exec code-api alembic upgrade head` +- **Generate New Migration**: + ```bash + docker compose exec code-api alembic revision --autogenerate -m "description_of_change" + ``` -```json -{ - "code": "", - "language": "python" -} -``` - `๐Ÿ“ Notes: For \t (tab) please sent 4 spacebar instead` - -Response JSON Format: - -```json -{ - "status": "correct|incorrect|error", - "msg": "Summary message", - "tests": [ - { - "case": 1, - "status": "passed|failed", - "msg": "...", - "stdout": "...", - "stderr": "..." - } - ] -} -``` +### ๐ŸŒฑ Data Seeding +If you need to re-seed or reset the initial data: +- **Run Seeder**: `docker compose exec code-api python3 -m scripts.seed` +*(The seeder is idempotent and will skip problems that already exist!)* -**POST** `http://127.0.0.1:3000/run` +--- -Request JSON Format: +## ๏ฟฝ๏ธ Recommended Tooling +For the most professional development experience, we recommend the following tools: +- โšก **[OrbStack](https://orbstack.dev/)**: A fast, light, and easy way to run Docker containers on macOS. +- ๐Ÿ **[Beekeeper Studio](https://www.beekeeperstudio.io/)**: A smooth and easy-to-use SQL editor and database manager for PostgreSQL. +- ๐Ÿถ **[Bruno](https://www.usebruno.com/)**: A fast and git-friendly API client for testing the CodeExecutor endpoints. -```json -{ - "code": "", - "language": "python" -} -``` - `๐Ÿ“ Notes: For \t (tab) please sent 4 spacebar instead` +--- + +## ๏ฟฝ๐Ÿ“– Complete API Documentation +Since you're up and running, you'll want to know how to talk to the API! + +- **Detailed Endpoints**: See [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) for full request/response examples. +- **Interactive Swagger/Scalar Docs**: Visit [http://localhost:3000/docs](http://localhost:3000/docs) while the app is running. +- **OpenAPI Spec**: Available at `http://localhost:3000/openapi.yaml`. + +--- -### ๐Ÿ“ Notes +## ๏ฟฝ Testing with Bruno +For the best developer experience, we've included a **Bruno Collection**: +1. Open **Bruno**. +2. Import the folder located at `./bruno`. +3. Start firing requests! -* โฑ๏ธ Default port is `3000` change port as needed in `docker-compose.yml`. -* โฑ๏ธ The API will be available at `http://127.0.0.1:` -* ๐Ÿ“ Folder IDs correspond to folders under `tests/`. +--- + +## ๐Ÿงช Supported Languages +- ๐Ÿ **Python** (`python`) +- ๐ŸŸจ **JavaScript** (`javascript`) +- โ˜• **Java** (`java`) +- ๐ŸŸฆ **C** (`c`) +- ๐ŸŸฅ **C++** (`cpp`) + +--- + +## ๐Ÿ“ Usage Best Practices +- ๐Ÿ’ก **4 Spaces**: Always use spaces instead of tabs in your JSON `code` strings. +- โฑ๏ธ **Timeouts**: Most problems are capped at 5 seconds for safety. +- ๐Ÿ”’ **Sandboxing**: Code runs in a dedicated non-root container environment. + +--- +Build with focus on **Modular, Scalable, and Professional** architecture. โœจ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d847087 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,59 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Add src to path if needed +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'src'))) + +from sqlmodel import SQLModel +from models import Problem, TestCase, Category, Tag, ProblemCategoryLink, ProblemTagLink # noqa + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = SQLModel.metadata + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = os.environ.get("DATABASE_URL") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + url = os.environ.get("DATABASE_URL") + + from sqlalchemy import create_engine + connectable = create_engine(url) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..697cf67 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0052deea4f5e_fix_forward_references.py b/alembic/versions/0052deea4f5e_fix_forward_references.py new file mode 100644 index 0000000..99070ae --- /dev/null +++ b/alembic/versions/0052deea4f5e_fix_forward_references.py @@ -0,0 +1,33 @@ +"""fix_forward_references + +Revision ID: 0052deea4f5e +Revises: a901d9d3fb87 +Create Date: 2026-02-09 13:12:24.953037 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '0052deea4f5e' +down_revision: Union[str, Sequence[str], None] = 'a901d9d3fb87' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py b/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py new file mode 100644 index 0000000..277329c --- /dev/null +++ b/alembic/versions/4aa144bbb4a0_use_unidirectional_relationships.py @@ -0,0 +1,33 @@ +"""use_unidirectional_relationships + +Revision ID: 4aa144bbb4a0 +Revises: 0052deea4f5e +Create Date: 2026-02-09 13:12:53.164099 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '4aa144bbb4a0' +down_revision: Union[str, Sequence[str], None] = '0052deea4f5e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/a901d9d3fb87_standardize_relationship_names.py b/alembic/versions/a901d9d3fb87_standardize_relationship_names.py new file mode 100644 index 0000000..d36778d --- /dev/null +++ b/alembic/versions/a901d9d3fb87_standardize_relationship_names.py @@ -0,0 +1,33 @@ +"""standardize_relationship_names + +Revision ID: a901d9d3fb87 +Revises: e010f5d8dfa5 +Create Date: 2026-02-09 13:11:49.782157 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a901d9d3fb87' +down_revision: Union[str, Sequence[str], None] = 'e010f5d8dfa5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py b/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py new file mode 100644 index 0000000..1b35ff6 --- /dev/null +++ b/alembic/versions/c48ea95cb4e7_initial_sqlmodel_migration.py @@ -0,0 +1,85 @@ +"""initial_sqlmodel_migration + +Revision ID: c48ea95cb4e7 +Revises: +Create Date: 2026-02-09 13:01:14.448516 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'c48ea95cb4e7' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + op.create_table('problems', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('difficulty', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('config', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_problems_title'), 'problems', ['title'], unique=False) + op.create_table('tags', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tags_name'), 'tags', ['name'], unique=True) + op.create_table('problem_categories', + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.PrimaryKeyConstraint('problem_id', 'category_id') + ) + op.create_table('problem_tags', + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('tag_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), + sa.PrimaryKeyConstraint('problem_id', 'tag_id') + ) + op.create_table('test_cases', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('problem_id', sa.Uuid(), nullable=False), + sa.Column('input', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('output', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('is_hidden', sa.Boolean(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['problem_id'], ['problems.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('test_cases') + op.drop_table('problem_tags') + op.drop_table('problem_categories') + op.drop_index(op.f('ix_tags_name'), table_name='tags') + op.drop_table('tags') + op.drop_index(op.f('ix_problems_title'), table_name='problems') + op.drop_table('problems') + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py b/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py new file mode 100644 index 0000000..3f276e3 --- /dev/null +++ b/alembic/versions/e010f5d8dfa5_resolve_relationship_conflicts.py @@ -0,0 +1,33 @@ +"""resolve_relationship_conflicts + +Revision ID: e010f5d8dfa5 +Revises: c48ea95cb4e7 +Create Date: 2026-02-09 13:11:16.449054 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'e010f5d8dfa5' +down_revision: Union[str, Sequence[str], None] = 'c48ea95cb4e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..b271425 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "bruno", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/docs.bru b/bruno/docs.bru new file mode 100644 index 0000000..c741aa3 --- /dev/null +++ b/bruno/docs.bru @@ -0,0 +1,16 @@ +meta { + name: docs + type: http + seq: 3 +} + +get { + url: http://localhost:3000/docs + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/environments/code-exec.bru b/bruno/environments/code-exec.bru new file mode 100644 index 0000000..484251d --- /dev/null +++ b/bruno/environments/code-exec.bru @@ -0,0 +1,3 @@ +vars:secret [ + code-exec-url +] diff --git a/bruno/local/GET- Problems/folder.bru b/bruno/local/GET- Problems/folder.bru new file mode 100644 index 0000000..8b9fca9 --- /dev/null +++ b/bruno/local/GET- Problems/folder.bru @@ -0,0 +1,7 @@ +meta { + name: GET: Problems +} + +auth { + mode: inherit +} diff --git a/bruno/local/GET- Problems/get all problems.bru b/bruno/local/GET- Problems/get all problems.bru new file mode 100644 index 0000000..ddc1a85 --- /dev/null +++ b/bruno/local/GET- Problems/get all problems.bru @@ -0,0 +1,16 @@ +meta { + name: get all problems + type: http + seq: 1 +} + +get { + url: http://localhost:3000/problems + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/GET- Problems/get problems by id.bru b/bruno/local/GET- Problems/get problems by id.bru new file mode 100644 index 0000000..8ee6f16 --- /dev/null +++ b/bruno/local/GET- Problems/get problems by id.bru @@ -0,0 +1,16 @@ +meta { + name: get problems by id + type: http + seq: 2 +} + +get { + url: http://localhost:3000/problem/2a3380b4-fbc7-517d-9583-01d41d1cbb80 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/GET- Problems/get problems by tag and category.bru b/bruno/local/GET- Problems/get problems by tag and category.bru new file mode 100644 index 0000000..cccaea7 --- /dev/null +++ b/bruno/local/GET- Problems/get problems by tag and category.bru @@ -0,0 +1,21 @@ +meta { + name: get problems by tag and category + type: http + seq: 3 +} + +get { + url: http://localhost:3000/problems?tag=string&&category=math + body: none + auth: inherit +} + +params:query { + tag: string + category: math +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/POST- Send Code/folder.bru b/bruno/local/POST- Send Code/folder.bru new file mode 100644 index 0000000..b07b899 --- /dev/null +++ b/bruno/local/POST- Send Code/folder.bru @@ -0,0 +1,8 @@ +meta { + name: POST: Send Code + seq: 2 +} + +auth { + mode: inherit +} diff --git a/bruno/local/POST- Send Code/send code failed.bru b/bruno/local/POST- Send Code/send code failed.bru new file mode 100644 index 0000000..18e7816 --- /dev/null +++ b/bruno/local/POST- Send Code/send code failed.bru @@ -0,0 +1,23 @@ +meta { + name: send code failed + type: http + seq: 1 +} + +post { + url: http://localhost:3000/code/176dbf00-7afd-5250-9442-11d464d7278b + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/POST- Send Code/send code pass.bru b/bruno/local/POST- Send Code/send code pass.bru new file mode 100644 index 0000000..be99caf --- /dev/null +++ b/bruno/local/POST- Send Code/send code pass.bru @@ -0,0 +1,23 @@ +meta { + name: send code pass + type: http + seq: 2 +} + +post { + url: http://localhost:3000/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80 + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/local/folder.bru b/bruno/local/folder.bru new file mode 100644 index 0000000..5bcd900 --- /dev/null +++ b/bruno/local/folder.bru @@ -0,0 +1,8 @@ +meta { + name: local + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno/prod/POST- Send Code/folder.bru b/bruno/prod/POST- Send Code/folder.bru new file mode 100644 index 0000000..0c2d31c --- /dev/null +++ b/bruno/prod/POST- Send Code/folder.bru @@ -0,0 +1,8 @@ +meta { + name: POST: Send Code + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno/prod/POST- Send Code/send code.bru b/bruno/prod/POST- Send Code/send code.bru new file mode 100644 index 0000000..7bad388 --- /dev/null +++ b/bruno/prod/POST- Send Code/send code.bru @@ -0,0 +1,23 @@ +meta { + name: send code + type: http + seq: 1 +} + +post { + url: {{code-exec-url}}/code/2a3380b4-fbc7-517d-9583-01d41d1cbb80 + body: json + auth: inherit +} + +body:json { + { + "code": "num1, num2 = map(int, input().split())\nprint(num1 * num2)", + "language": "python" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/prod/folder.bru b/bruno/prod/folder.bru new file mode 100644 index 0000000..ed47821 --- /dev/null +++ b/bruno/prod/folder.bru @@ -0,0 +1,8 @@ +meta { + name: prod + seq: 2 +} + +auth { + mode: inherit +} diff --git a/docker-compose.yml b/docker-compose.yml index d895b4a..e1ac6dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,38 @@ services: + db: + image: postgres:16-alpine + restart: always + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DB:-code_executor} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-code_executor}" ] + interval: 5s + timeout: 5s + retries: 5 + code-api: image: code-api:latest - build: context: . dockerfile: ./Dockerfile - ports: - "3000:3000" - restart: always stdin_open: true - tty: true \ No newline at end of file + tty: true + depends_on: + db: + condition: service_healthy + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-code_executor} + command: > + sh -c "python3 -m alembic upgrade head && python3 -m scripts.seed && gunicorn --bind 0.0.0.0:3000 --access-logfile - app:app" + +volumes: + postgres_data: diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..c917077 --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +from .routes import api_bp diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..ed5b11f --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,83 @@ +from flask import Blueprint, request, jsonify, send_from_directory, current_app +from services import ProblemService, ExecutionService +from core import execute_custom_code + +api_bp = Blueprint('api', __name__) +problem_service = ProblemService() +execution_service = ExecutionService() + +@api_bp.route('/openapi.yaml') +def serve_openapi(): + """Serve the OpenAPI specification file.""" + return send_from_directory(current_app.root_path, 'openapi.yaml') + +@api_bp.route('/docs') +def scalar_docs(): + """Serve interactive API documentation via Scalar.""" + return f""" + + + + CodeExecutor-API Documentation + + + + + + + + + + """, 200 + +@api_bp.get('/problems') +def get_problems(): + """List problems with optional category/tag filtering.""" + category = request.args.get('category') + tag = request.args.get('tag') + + if category: + problems = problem_service.list_problems_by_category(category) + elif tag: + problems = problem_service.list_problems_by_tag(tag) + else: + problems = problem_service.list_all_problems() + + return jsonify(status="success", data=problems), 200 + +@api_bp.get('/problem/') +def get_problem(problem_id): + """Retrieve detailed information for a single problem.""" + problem = problem_service.get_problem_details(problem_id) + if not problem: + return jsonify(status="error", message="Problem not found"), 404 + return jsonify(status="success", data=problem), 200 + +@api_bp.post('/code/') +def execute_problem_code(problem_id): + """Execute code against stored test cases for a problem.""" + if not request.is_json: + return jsonify(status="error", message="Request must be JSON"), 400 + + data = request.get_json() + code, lang = data.get('code'), data.get('language') + if not code or not lang: + return jsonify(status="error", message="Missing 'code' or 'language'"), 400 + + res = execution_service.run_problem_code(problem_id, code, lang) + return jsonify(res), (500 if res.get("status") == "error" else 200) + +@api_bp.post('/run') +def custom_code_executor(): + """Execute arbitrary code without test cases.""" + lang = request.args.get('lang') + if not lang or not request.is_json: + return jsonify(status="error", message="Missing lang or invalid body"), 400 + + data = request.get_json() + code = data.get('code') + if not code: + return jsonify(status="error", message="Missing code"), 400 + + res = execute_custom_code(code, lang) + return jsonify(res), (500 if res.get("status") == "error" else 200) diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..4830ff3 --- /dev/null +++ b/src/app.py @@ -0,0 +1,23 @@ +from flask import Flask +from api import api_bp + +def create_app(): + app = Flask(__name__, static_folder='html') + + # Register routes + app.register_blueprint(api_bp) + + @app.route('/') + def home(): + return app.send_static_file('index.html') + + @app.errorhandler(404) + def page_not_found(e): + return app.send_static_file('404.html'), 404 + + return app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=3000) diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..61128e7 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +from .executor import execute_code, execute_custom_code diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..937fe5d --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,14 @@ +import os, re + +COMPILERS = { + "c": {"compiler": "gcc", "extension": ".c"}, + "cpp": {"compiler": "g++", "extension": ".cpp"}, + "java": {"compiler": "javac", "extension": ".java"}, + "python": {"interpreter": "python", "extension": ".py"}, + "javascript": {"interpreter": "node", "extension": ".js"}, +} + +def validate_code(lang: str, code: str, rules: dict) -> bool: + """Validate code against language-specific rules.""" + patterns = rules.get(lang, []) + return all(not re.search(p, code) for p in patterns) diff --git a/API/executor.py b/src/core/executor.py similarity index 63% rename from API/executor.py rename to src/core/executor.py index 2e7ce4b..97a18b6 100644 --- a/API/executor.py +++ b/src/core/executor.py @@ -1,8 +1,10 @@ -import subprocess, os, tempfile -from config import COMPILERS, loadTest, validate_code +import subprocess +import os +import tempfile +from .config import COMPILERS, validate_code def execute_custom_code(code: str, lang: str) -> dict: - """Execute the given code in the specific language""" + """Execute raw code without test cases (custom run).""" if lang not in COMPILERS: return {"status": "error", "stdout": "", "stderr": f"Unsupported language: {lang}"} @@ -13,21 +15,16 @@ def execute_custom_code(code: str, lang: str) -> dict: else: return _run_interpreted(code, lang) -def execute_code(code: str, lang: str, qid: str) -> dict: - """Execute the given code in the specified language against test cases for the question ID.""" +def execute_code(code: str, lang: str, tests: list, timeout: int = 5, templates: dict = None, rules: dict = None) -> dict: + """Execute code against a list of test cases with validation and templating.""" if lang not in COMPILERS: return {"status": "error", "message": f"Unsupported language: {lang}"} - try: - data = loadTest(qid) - tests, timeout = data['test_pairs'], data['timeout'] - templates = data.get('templates', {}) - rules = data.get('rules', {}) - except Exception as e: - return {"status": "error", "message": f"Error loading test cases: {e}"} + templates = templates or {} + rules = rules or {} if not validate_code(lang, code, rules): - return {"status": "error", "message": "Code does not meet the required rules."} + return {"status": "error", "message": "Code does not meet the prescribed rules."} template = templates.get(lang) if template and "__CODE_GOES_HERE__" in template: @@ -40,65 +37,86 @@ def execute_code(code: str, lang: str, qid: str) -> dict: else: return _run_interpreted(code, lang, tests, timeout) - -def _run_c_cpp(code, lang, tests, timeout): - """Compile and run C/C++ code.""" +def _run_c_cpp(code, lang, tests=None, timeout=5): + """Compile and execute C/C++ code.""" cfg = COMPILERS[lang] fd, src = tempfile.mkstemp(suffix=cfg['extension']) os.close(fd) exe = src.replace(cfg['extension'], "") try: - open(src, 'w', encoding='utf-8').write(code) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + comp = subprocess.run([cfg['compiler'], src, "-o", exe], capture_output=True, text=True, timeout=timeout) if comp.returncode: return {"status": "incorrect", "message": "Compilation failed", "compiler_output": comp.stderr} + + if tests is None: + res = subprocess.run([exe], capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + return _run_tests([exe], tests, timeout) finally: for f in [src, exe]: if os.path.exists(f): os.remove(f) - -def _run_java(code, tests, timeout): - """Compile and run Java code.""" +def _run_java(code, tests=None, timeout=5): + """Compile and execute Java code (requires 'public class Main').""" cfg = COMPILERS["java"] fd, src = tempfile.mkstemp(suffix=".java") os.close(fd) d = os.path.dirname(src) try: - open(src, 'w', encoding='utf-8').write(code) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + if not code.strip().startswith("public class Main"): - return {"status": "error", "message": "Java code must use 'public class Main'."} + return {"status": "error", "message": "Java code must utilize 'public class Main'."} + comp = subprocess.run([cfg['compiler'], src, "-d", d], capture_output=True, text=True, timeout=timeout) if comp.returncode: return {"status": "incorrect", "message": "Compilation failed", "compiler_output": comp.stderr} - return _run_tests(["java", "-cp", d, "Main"], tests, timeout) + + cmd = ["java", "-cp", d, "Main"] + if tests is None: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + + return _run_tests(cmd, tests, timeout) finally: for f in [src, os.path.join(d, "Main.class")]: if os.path.exists(f): os.remove(f) - -def _run_interpreted(code, lang, tests, timeout): - """Run interpreted languages like Python and JavaScript.""" +def _run_interpreted(code, lang, tests=None, timeout=5): + """Execute interpreted languages (e.g., Python, JavaScript).""" cfg = COMPILERS[lang] fd, src = tempfile.mkstemp(suffix=cfg['extension']) os.close(fd) try: - open(src, 'w', encoding='utf-8').write(code) - return _run_tests([cfg['interpreter'], src], tests, timeout) + with open(src, 'w', encoding='utf-8') as f: + f.write(code) + + cmd = [cfg['interpreter'], src] + if tests is None: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return {"status": "success", "stdout": res.stdout, "stderr": res.stderr} + + return _run_tests(cmd, tests, timeout) finally: if os.path.exists(src): os.remove(src) - def _run_tests(cmd_base, tests, timeout): - """Run the compiled/interpreted code against test cases.""" + """Batch execute test cases against the generated command.""" results, status, msg = [], "correct", "All test cases passed!" for t in tests: try: r = subprocess.run(cmd_base, input=t['input'], capture_output=True, text=True, timeout=timeout) out, err = r.stdout.strip(), r.stderr.strip() s, m = "passed", "Test passed." + if r.returncode or out != t['expected_output'].strip(): s, m, status, msg = "failed", f"Expected '{t['expected_output'].strip()}', got '{out}'", "incorrect", "Some test cases failed." + results.append({ "case": int(t['test_number']), "status": s, @@ -116,4 +134,5 @@ def _run_tests(cmd_base, tests, timeout): }) status, msg = "timeout", "Time Limit Exceeded" break + return {"status": status, "msg": msg, "tests": results} diff --git a/API/html/404.html b/src/html/404.html similarity index 100% rename from API/html/404.html rename to src/html/404.html diff --git a/src/html/index.html b/src/html/index.html new file mode 100644 index 0000000..3747c83 --- /dev/null +++ b/src/html/index.html @@ -0,0 +1,127 @@ + + + + + + DevScape API + + + + + +
+
+

Code Executor API

+

+ Welcome to the backend gateway of the + Code Executor API. +

+ +
+ + + \ No newline at end of file diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py new file mode 100644 index 0000000..0eb03b7 --- /dev/null +++ b/src/infrastructure/__init__.py @@ -0,0 +1 @@ +from .database import engine, SessionLocal, get_session diff --git a/src/infrastructure/database.py b/src/infrastructure/database.py new file mode 100644 index 0000000..ff133e3 --- /dev/null +++ b/src/infrastructure/database.py @@ -0,0 +1,20 @@ +import os +import logging +from sqlmodel import create_engine, Session +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = os.environ.get('DATABASE_URL') + +engine = create_engine(DATABASE_URL, echo=False) if DATABASE_URL else None +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=Session) if engine else None + +if engine: + logging.info("SQLModel engine and sessionmaker initialized.") + +def get_session(): + """Dependency for getting a database session.""" + if SessionLocal: + with SessionLocal() as session: + yield session + else: + yield None diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..4564675 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1 @@ +from .base import Problem, TestCase, Category, Tag, ProblemCategoryLink, ProblemTagLink diff --git a/src/models/base.py b/src/models/base.py new file mode 100644 index 0000000..6dea531 --- /dev/null +++ b/src/models/base.py @@ -0,0 +1,46 @@ +from typing import List, Optional +from uuid import UUID, uuid4 +from sqlmodel import SQLModel, Field, Relationship, JSON, Column + +class ProblemCategoryLink(SQLModel, table=True): + __tablename__ = "problem_categories" + problem_id: UUID = Field(foreign_key="problems.id", primary_key=True) + category_id: UUID = Field(foreign_key="categories.id", primary_key=True) + +class ProblemTagLink(SQLModel, table=True): + __tablename__ = "problem_tags" + problem_id: UUID = Field(foreign_key="problems.id", primary_key=True) + tag_id: UUID = Field(foreign_key="tags.id", primary_key=True) + +class Problem(SQLModel, table=True): + __tablename__ = "problems" + id: UUID = Field(default_factory=uuid4, primary_key=True) + title: str = Field(index=True) + description: str + difficulty: str + config: Optional[dict] = Field(default_factory=dict, sa_column=Column(JSON)) + + test_cases: List["TestCase"] = Relationship(back_populates="problem", sa_relationship_kwargs={"cascade": "all, delete-orphan"}) + categories: List["Category"] = Relationship(link_model=ProblemCategoryLink) + tags: List["Tag"] = Relationship(link_model=ProblemTagLink) + +class Category(SQLModel, table=True): + __tablename__ = "categories" + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(unique=True, index=True) + +class Tag(SQLModel, table=True): + __tablename__ = "tags" + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(unique=True, index=True) + +class TestCase(SQLModel, table=True): + __tablename__ = "test_cases" + id: UUID = Field(default_factory=uuid4, primary_key=True) + problem_id: UUID = Field(foreign_key="problems.id") + input: str + output: str + is_hidden: bool = Field(default=True) + sort_order: int + + problem: "Problem" = Relationship(back_populates="test_cases") diff --git a/src/openapi.yaml b/src/openapi.yaml new file mode 100644 index 0000000..1193bd4 --- /dev/null +++ b/src/openapi.yaml @@ -0,0 +1,103 @@ +openapi: 3.1.0 +info: + title: CodeExecutor-API + description: API for executing code in various languages against database-backed problems. + version: 1.0.0 +servers: + - url: http://localhost:3000 + description: Local development server + +paths: + /problems: + get: + summary: List all problems + parameters: + - name: category + in: query + description: Filter problems by category name + schema: + type: string + - name: tag + in: query + description: Filter problems by tag name + schema: + type: string + responses: + '200': + description: A list of problems + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: array + items: + type: object + properties: + id: { type: string } + title: { type: string } + difficulty: { type: string } + + /problem/{problem_id}: + get: + summary: Get problem details + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Detailed problem information + '404': + description: Problem not found + + /code/{problem_id}: + post: + summary: Execute code for a problem + parameters: + - name: problem_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + language: { type: string } + code: { type: string } + responses: + '200': + description: Execution results + + /run: + post: + summary: Run arbitrary code + parameters: + - name: lang + in: query + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: { type: string } + responses: + '200': + description: Execution results diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 0000000..9114006 --- /dev/null +++ b/src/repositories/__init__.py @@ -0,0 +1,2 @@ +from .problem_repository import ProblemRepository +from .test_case_repository import TestCaseRepository diff --git a/src/repositories/problem_repository.py b/src/repositories/problem_repository.py new file mode 100644 index 0000000..a09deff --- /dev/null +++ b/src/repositories/problem_repository.py @@ -0,0 +1,50 @@ +from sqlmodel import select +from sqlalchemy.orm import joinedload +from infrastructure import SessionLocal +from models import Problem, Category, Tag + +class ProblemRepository: + def __init__(self, session=None): + self._session = session + + def _get_session(self): + return self._session if self._session else SessionLocal() + + def find_all(self): + """Retrieve all problems with basic information.""" + with self._get_session() as session: + statement = select(Problem).order_by(Problem.id) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] + + def find_by_id(self, problem_id): + """Internal helper to fetch a Problem model by UUID.""" + with self._get_session() as session: + return session.get(Problem, problem_id) + + def find_details_by_id(self, problem_id): + """Fetch full problem details with hydrated relationships for API response.""" + with self._get_session() as session: + statement = select(Problem).where(Problem.id == problem_id).options( + joinedload(Problem.categories), + joinedload(Problem.tags) + ) + problem = session.exec(statement).first() + if not problem: + return None + + p_dict = problem.model_dump() + p_dict['categories'] = [c.name for c in problem.categories] + p_dict['tags'] = [t.name for t in problem.tags] + return p_dict + + def find_by_category(self, category_name): + """Filter problems by category name (case-insensitive).""" + with self._get_session() as session: + statement = select(Problem).join(Problem.categories).where(Category.name.ilike(category_name)) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] + + def find_by_tag(self, tag_name): + """Filter problems by tag name (case-insensitive).""" + with self._get_session() as session: + statement = select(Problem).join(Problem.tags).where(Tag.name.ilike(tag_name)) + return [p.model_dump(exclude={"config", "description"}) for p in session.exec(statement).all()] diff --git a/src/repositories/test_case_repository.py b/src/repositories/test_case_repository.py new file mode 100644 index 0000000..cdf5ad8 --- /dev/null +++ b/src/repositories/test_case_repository.py @@ -0,0 +1,26 @@ +from sqlmodel import select +from infrastructure import SessionLocal +from models import TestCase + +class TestCaseRepository: + def __init__(self, session=None): + self._session = session + + def _get_session(self): + return self._session if self._session else SessionLocal() + + def find_all_by_problem(self, problem_id): + """Fetch all test cases for execution, mapped to engine format.""" + with self._get_session() as session: + statement = select(TestCase).where(TestCase.problem_id == problem_id).order_by(TestCase.sort_order) + results = session.exec(statement).all() + return [{"input": tc.input, "expected_output": tc.output, "test_number": tc.sort_order} for tc in results] + + def find_public_by_problem(self, problem_id): + """Fetch non-hidden test cases for public documentation.""" + with self._get_session() as session: + statement = select(TestCase).where( + TestCase.problem_id == problem_id, + TestCase.is_hidden == False + ).order_by(TestCase.sort_order) + return [tc.model_dump(exclude={"is_hidden"}) for tc in session.exec(statement).all()] diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..cf7f922 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +gunicorn==23.0.0 +psycopg2-binary==2.9.9 +sqlmodel==0.0.22 +alembic==1.13.1 \ No newline at end of file diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py new file mode 100644 index 0000000..679fff5 --- /dev/null +++ b/src/scripts/__init__.py @@ -0,0 +1 @@ +from .seed import seed_data diff --git a/src/scripts/seed.py b/src/scripts/seed.py new file mode 100644 index 0000000..e00ef6b --- /dev/null +++ b/src/scripts/seed.py @@ -0,0 +1,202 @@ +import logging +import uuid +from sqlmodel import Session, select +from infrastructure import engine +from models import Problem, Category, Tag, TestCase + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + +NAMESPACE = uuid.NAMESPACE_DNS + +def get_uuid(name: str) -> uuid.UUID: + """Generate a deterministic UUID based on a string name.""" + return uuid.uuid5(NAMESPACE, name) + +def seed_data(): + if not engine: + logging.error("No database engine found. Skipping seeding.") + return + + with Session(engine) as session: + # Check if we already have data + if session.exec(select(Problem)).first(): + logging.info("Database already seeded. Skipping.") + return + + logging.info("Seeding initial data with deterministic UUIDs...") + + def get_or_create_category(name): + cat_id = get_uuid(f"cat_{name}") + cat = session.exec(select(Category).where(Category.id == cat_id)).first() + if not cat: + cat = Category(id=cat_id, name=name) + session.add(cat) + session.commit() + session.refresh(cat) + return cat + + def get_or_create_tag(name): + tag_id = get_uuid(f"tag_{name}") + tag = session.exec(select(Tag).where(Tag.id == tag_id)).first() + if not tag: + tag = Tag(id=tag_id, name=name) + session.add(tag) + session.commit() + session.refresh(tag) + return tag + + # 1. Categories + math = get_or_create_category("Math") + string_cat = get_or_create_category("String") + algorithms = get_or_create_category("Algorithms") + + # 2. Tags + easy = get_or_create_tag("Easy") + medium = get_or_create_tag("Medium") + basic = get_or_create_tag("Basic") + array = get_or_create_tag("Array") + + # 3. Helper for Problems and Test Cases + def add_problem(title, description, difficulty, category_list, tag_list, config=None, test_cases=None): + p_id = get_uuid(f"prob_{title}") + p = Problem( + id=p_id, + title=title, + description=description, + difficulty=difficulty, + config=config or {"timeout": 5} + ) + p.categories = category_list + p.tags = tag_list + session.add(p) + + if test_cases: + for i, tc_data in enumerate(test_cases): + tc_id = get_uuid(f"tc_{title}_{i}") + tc = TestCase( + id=tc_id, + problem_id=p_id, + input=tc_data['input'], + output=tc_data['output'], + is_hidden=tc_data.get('is_hidden', True), + sort_order=i + 1 + ) + session.add(tc) + + session.commit() + return p + + # 4. Seed Problems + + # Multiply Two Numbers (New) + add_problem( + title="Multiply Two Numbers", + description="Read two space-separated integers from stdin and print their product.", + difficulty="Easy", + category_list=[math], + tag_list=[easy, basic], + config={ + "timeout": 2, + "templates": { + "python": "num1, num2 = map(int, input().split())\nprint(num1 * num2)" + } + }, + test_cases=[ + {"input": "2 3", "output": "6", "is_hidden": False}, + {"input": "5 10", "output": "50", "is_hidden": False}, + {"input": "0 100", "output": "0", "is_hidden": True}, + {"input": "-5 5", "output": "-25", "is_hidden": True}, + {"input": "12 12", "output": "144", "is_hidden": True}, + ] + ) + + # Two Sum + add_problem( + title="Two Sum", + description="Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`.", + difficulty="Easy", + category_list=[math, algorithms], + tag_list=[easy, array], + config={ + "timeout": 5, + "templates": { + "python": "def two_sum(nums, target):\n # Write your code here\n pass" + } + }, + test_cases=[ + {"input": "[2, 7, 11, 15], 9", "output": "[0, 1]", "is_hidden": False}, + {"input": "[3, 2, 4], 6", "output": "[1, 2]", "is_hidden": False}, + {"input": "[3, 3], 6", "output": "[0, 1]", "is_hidden": True}, + {"input": "[1, 5, 8], 13", "output": "[1, 2]", "is_hidden": True}, + {"input": "[-1, -2, -3, -4, -5], -8", "output": "[2, 4]", "is_hidden": True}, + ] + ) + + # Palindrome Check + add_problem( + title="Palindrome Check", + description="Check if a given string is a palindrome.", + difficulty="Easy", + category_list=[string_cat], + tag_list=[easy, basic], + test_cases=[ + {"input": "'racecar'", "output": "True", "is_hidden": False}, + {"input": "'hello'", "output": "False", "is_hidden": False}, + {"input": "'a'", "output": "True", "is_hidden": True}, + {"input": "''", "output": "True", "is_hidden": True}, + {"input": "'Was it a car or a cat I saw?'", "output": "False", "is_hidden": True}, # Case sensitive or space issues depending on logic + ] + ) + + # Factorial + add_problem( + title="Factorial", + description="Calculate the factorial of a non-negative integer `n`.", + difficulty="Easy", + category_list=[math], + tag_list=[easy, basic], + test_cases=[ + {"input": "5", "output": "120", "is_hidden": False}, + {"input": "0", "output": "1", "is_hidden": False}, + {"input": "1", "output": "1", "is_hidden": True}, + {"input": "10", "output": "3628800", "is_hidden": True}, + {"input": "3", "output": "6", "is_hidden": True}, + ] + ) + + # Fibonacci Number + add_problem( + title="Fibonacci Number", + description="Find the `n`-th Fibonacci number.", + difficulty="Medium", + category_list=[math, algorithms], + tag_list=[medium], + test_cases=[ + {"input": "10", "output": "55", "is_hidden": False}, + {"input": "1", "output": "1", "is_hidden": False}, + {"input": "0", "output": "0", "is_hidden": True}, + {"input": "2", "output": "1", "is_hidden": True}, + {"input": "20", "output": "6765", "is_hidden": True}, + ] + ) + + # Valid Anagram + add_problem( + title="Valid Anagram", + description="Given two strings `s` and `t`, return `true` if `t` is an anagram of `s`, and `false` otherwise.", + difficulty="Easy", + category_list=[string_cat], + tag_list=[easy], + test_cases=[ + {"input": "'anagram', 'nagaram'", "output": "True", "is_hidden": False}, + {"input": "'rat', 'car'", "output": "False", "is_hidden": False}, + {"input": "'a', 'ab'", "output": "False", "is_hidden": True}, + {"input": "'debug', 'bugged'", "output": "False", "is_hidden": True}, + {"input": "'listen', 'silent'", "output": "True", "is_hidden": True}, + ] + ) + + logging.info("Successfully seeded 6 problems with 5 test cases each.") + +if __name__ == "__main__": + seed_data() diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..2de0b93 --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,2 @@ +from .problem_service import ProblemService +from .execution_service import ExecutionService diff --git a/src/services/execution_service.py b/src/services/execution_service.py new file mode 100644 index 0000000..a007545 --- /dev/null +++ b/src/services/execution_service.py @@ -0,0 +1,25 @@ +from repositories import ProblemRepository, TestCaseRepository +from core import execute_code as core_execute + +class ExecutionService: + def __init__(self): + self.test_case_repo = TestCaseRepository() + self.problem_repo = ProblemRepository() + + def run_problem_code(self, problem_id, code, lang): + """Execute provided code against all test cases for a specific problem.""" + problem = self.problem_repo.find_by_id(problem_id) + if not problem: + return {"status": "error", "message": "Problem not found"} + + test_cases = self.test_case_repo.find_all_by_problem(problem_id) + cfg = problem.config if hasattr(problem, 'config') else {} + + return core_execute( + code=code, + lang=lang, + tests=test_cases, + timeout=cfg.get("timeout", 5), + templates=cfg.get("templates", {}), + rules=cfg.get("rules", {}) + ) diff --git a/src/services/problem_service.py b/src/services/problem_service.py new file mode 100644 index 0000000..2bdc89f --- /dev/null +++ b/src/services/problem_service.py @@ -0,0 +1,33 @@ +from repositories import ProblemRepository, TestCaseRepository + +class ProblemService: + def __init__(self): + self.problem_repo = ProblemRepository() + self.test_case_repo = TestCaseRepository() + + def list_all_problems(self): + """Retrieve a list of all problems with basic information.""" + return self.problem_repo.find_all() + + def get_problem_details(self, problem_id): + """Fetch full problem details, including config and public test cases.""" + problem_dict = self.problem_repo.find_details_by_id(problem_id) + if not problem_dict: + return None + + # Filter configuration for API response + allowed_keys = {'timeout', 'templates'} + problem_dict['config'] = {k: v for k, v in problem_dict.get('config', {}).items() if k in allowed_keys} + + # Enrich with public test cases + problem_dict['test_cases'] = self.test_case_repo.find_public_by_problem(problem_id) + + return problem_dict + + def list_problems_by_category(self, category_name): + """Filter problems by category.""" + return self.problem_repo.find_by_category(category_name) + + def list_problems_by_tag(self, tag_name): + """Filter problems by tag.""" + return self.problem_repo.find_by_tag(tag_name)