diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6a57af9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,28 @@ +name: Pack Validation CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Test Dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + + - name: Run Pack Verification Suite + run: python -m pytest tests/ -v diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..6a1bc2d --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,80 @@ +name: Publish Language Pack + +on: + push: + tags: + - "v*" # Fires directly on v0.1.0, v0.2.0 etc. + +jobs: + # Enforce that tests MUST pass before release can execute + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install and Verify + run: | + pip install pytest + pytest tests/ -v + + publish: + needs: verify # Blocks execution if verify job fails + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Release Tools + run: pip install build twine + + - name: Build Wheel and Source Distribution + # Automatically detects pyproject.toml and builds the correct pack + run: python -m build . + + - name: Publish Package to PyPI + env: + TWINE_USERNAME: __token__ + # Inherits your clean Organization level secret + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload --skip-existing dist/* + + - name: Build Gitea Release with Assets + env: + # Pulls your clean Organization level Gitea Token + GIT_RELEASE_TOKEN: ${{ secrets.GIT_RELEASE_TOKEN }} + run: | + TAG=${GITHUB_REF#refs/tags/} + REPO_NAME=${{ github.event.repository.name }} + + # Delete existing release block if present + EXISTING=$(curl -s -H "Authorization: token $GIT_RELEASE_TOKEN" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG") + EXISTING_ID=$(echo $EXISTING | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "") + if [ -n "$EXISTING_ID" ]; then + curl -s -X DELETE -H "Authorization: token $GIT_RELEASE_TOKEN" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$EXISTING_ID" + fi + + # Create fresh production release container + RELEASE=$(curl -s -X POST \ + -H "Authorization: token $GIT_RELEASE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"tag_name\": \"$TAG\", + \"name\": \"$REPO_NAME $TAG\", + \"body\": \"Language pack release version $TAG for the foreignthon transpiler framework.\", + \"draft\": false, + \"prerelease\": false + }" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases") + + RELEASE_ID=$(echo $RELEASE | python -c "import sys,json; print(json.load(sys.stdin)['id'])") + + # Upload wheels directly into Gitea Assets tab + for FILE in dist/*; do + curl -s -X POST -H "Authorization: token $GIT_RELEASE_TOKEN" -F "attachment=@$FILE" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets" + done diff --git a/pyproject.toml b/pyproject.toml index 6b69e51..b5e5cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "foreignthon-ta" -version = "0.2.1" +version = "0.2.2" description = "Tamil language pack for ForeignThon." license = { text = "GPL v3" } requires-python = ">=3.9" diff --git a/tests/test_pack.py b/tests/test_pack.py new file mode 100644 index 0000000..b561d45 --- /dev/null +++ b/tests/test_pack.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import builtins +import json +import keyword +from pathlib import Path +import pytest + +# --------------------------------------------------------------------------- +# Dynamic Discovery: Find the pack file in this specific repository +# --------------------------------------------------------------------------- + + +def find_pack_file() -> Path: + """Scans the repository root for a Foreignthon language pack JSON.""" + repo_root = Path(__file__).parent.parent + + for path in repo_root.rglob("*.json"): + # Ignore virtual environments, cache files, and CI directories + ignored_dirs = {".venv", "venv", ".git", ".pytest_cache", ".gitea", ".github"} + if any(part in path.parts for part in ignored_dirs): + continue + + # Peek inside the JSON to see if it's a Foreignthon pack + try: + with open(path, encoding="utf-8") as f: + content = json.load(f) + if "meta" in content and "keywords" in content: + return path + except (json.JSONDecodeError, KeyError, TypeError): + continue + + raise FileNotFoundError( + "Could not find a valid Foreignthon language pack JSON file in this repository." + ) + + +# --------------------------------------------------------------------------- +# Global Validation Sets +# --------------------------------------------------------------------------- +VALID_KEYWORDS = set(keyword.kwlist) | {"True", "False", "None"} +VALID_BUILTINS = set(dir(builtins)) + +# --------------------------------------------------------------------------- +# The Universal Test Suite +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def pack_data() -> tuple[Path, dict]: + """Loads the dynamically discovered pack file once for the test module.""" + path = find_pack_file() + with open(path, encoding="utf-8") as f: + return path, json.load(f) + + +def test_meta_section_integrity(pack_data): + path, data = pack_data + assert "meta" in data, f"Missing 'meta' section in {path.name}" + meta = data["meta"] + + assert meta.get("name"), "meta.name cannot be empty" + assert meta.get("native_name"), "meta.native_name cannot be empty" + assert meta.get("code"), "meta.code cannot be empty" + + # Enforce that the file name matches the language code (e.g., es.json matches code 'es') + assert ( + path.stem == meta["code"] + ), f"Filename '{path.name}' must exactly match meta.code '{meta['code']}'" + + +def test_keywords_integrity(pack_data): + path, data = pack_data + assert "keywords" in data, f"Missing 'keywords' section in {path.name}" + + for foreign_word, python_keyword in data["keywords"].items(): + assert python_keyword in VALID_KEYWORDS, ( + f"Invalid target '{python_keyword}' for foreign keyword '{foreign_word}' in {path.name}. " + "It is not a recognized Python keyword." + ) + + +def test_builtins_and_exceptions_integrity(pack_data): + path, data = pack_data + + # Validate Builtins + assert "builtins" in data, f"Missing 'builtins' section in {path.name}" + for foreign_word, python_builtin in data["builtins"].items(): + assert ( + python_builtin in VALID_BUILTINS + ), f"Invalid target '{python_builtin}' for builtin function '{foreign_word}' in {path.name}." + + # Validate Exceptions + assert "exceptions" in data, f"Missing 'exceptions' section in {path.name}" + for foreign_word, python_exception in data["exceptions"].items(): + assert ( + python_exception in VALID_BUILTINS + ), f"Invalid target '{python_exception}' for exception '{foreign_word}' in {path.name}." + + +def test_postfix_keywords_integrity(pack_data): + path, data = pack_data + assert ( + "postfix_keywords" in data + ), f"Missing 'postfix_keywords' section in {path.name}" + assert isinstance( + data["postfix_keywords"], list + ), "'postfix_keywords' must be an array/list" + + # Ensure any declared postfix keyword is actually registered in the main keywords dictionary + for post_kw in data["postfix_keywords"]: + assert ( + post_kw in data["keywords"] + ), f"Postfix keyword '{post_kw}' is missing from the main 'keywords' mapping in {path.name}."