diff --git a/.github/workflows/book-build.yml b/.github/workflows/book-build.yml index 8ce16e6..40e2de6 100644 --- a/.github/workflows/book-build.yml +++ b/.github/workflows/book-build.yml @@ -22,7 +22,7 @@ permissions: contents: write env: - PANDOC_VERSION: "3.1.11" + PANDOC_VERSION: "3.6.1" jobs: # ═══════════════════════════════════════════════════ @@ -34,44 +34,35 @@ jobs: outputs: lines: ${{ steps.check.outputs.lines }} has_book: ${{ steps.check.outputs.has_book }} + images: ${{ steps.check.outputs.images }} + tables: ${{ steps.check.outputs.tables }} steps: - uses: actions/checkout@v4 - name: Check book source id: check run: | - if [ ! -f docs/book/book.md ]; then + if [ ! -f book.md ]; then echo "has_book=false" >> $GITHUB_OUTPUT echo "lines=0" >> $GITHUB_OUTPUT - echo "WARNING: docs/book/book.md not found — skipping PDF build" + echo "images=0" >> $GITHUB_OUTPUT + echo "tables=0" >> $GITHUB_OUTPUT + echo "WARNING: book.md not found" exit 0 fi echo "has_book=true" >> $GITHUB_OUTPUT - LINES=$(wc -l < docs/book/book.md) + LINES=$(wc -l < book.md) + IMAGES=$(grep -c '!\[' book.md || echo 0) + TABLES=$(grep -c '^|' book.md || echo 0) + CODE=$(grep -c '```' book.md || echo 0) + CHAPTERS=$(grep -c '^## ' book.md || echo 0) echo "lines=$LINES" >> $GITHUB_OUTPUT - echo "Book source: $LINES lines" - - - name: Strip control characters - if: steps.check.outputs.has_book == 'true' - run: | - sed -i 's/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g' docs/book/book.md - echo "Control characters cleaned" - - - name: Validate structure - if: steps.check.outputs.has_book == 'true' - run: | - CHAPTERS=$(grep -c "^## " docs/book/book.md || echo 0) - echo "Chapters: $CHAPTERS" - if grep -qi "Srikanth Patchava" docs/book/book.md; then - echo "Author: verified" - fi - TABLES=$(grep -c "^|" docs/book/book.md || echo 0) - CODE_BLOCKS=$(grep -c '```' docs/book/book.md || echo 0) - echo "Tables: $TABLES lines" - echo "Code blocks: $((CODE_BLOCKS / 2))" + echo "images=$IMAGES" >> $GITHUB_OUTPUT + echo "tables=$TABLES" >> $GITHUB_OUTPUT + echo "Book: $LINES lines, $CHAPTERS chapters, $IMAGES images, $TABLES table rows, $((CODE/2)) code blocks" # ═══════════════════════════════════════════════════ - # 2. BUILD PDF + # 2. BUILD PDF WITH FULL FIGURE/TABLE/IMAGE SUPPORT # ═══════════════════════════════════════════════════ build-pdf: name: Build PDF @@ -81,23 +72,40 @@ jobs: outputs: pdf_name: ${{ steps.meta.outputs.pdf_name }} pdf_size: ${{ steps.verify.outputs.pdf_size }} + pdf_pages: ${{ steps.verify.outputs.pdf_pages }} steps: - uses: actions/checkout@v4 - - name: Install pandoc & LaTeX + - name: Install pandoc 3.x & LaTeX run: | + # Install latest pandoc for Eisvogel + citeproc support + wget -q https://github.com/jgm/pandoc/releases/download/${{ env.PANDOC_VERSION }}/pandoc-${{ env.PANDOC_VERSION }}-1-amd64.deb + sudo dpkg -i pandoc-${{ env.PANDOC_VERSION }}-1-amd64.deb + # LaTeX with all needed packages for figures, tables, images sudo apt-get update - sudo apt-get install -y pandoc texlive-xetex texlive-fonts-recommended texlive-fonts-extra texlive-science texlive-latex-extra + sudo apt-get install -y \ + texlive-xetex \ + texlive-fonts-recommended \ + texlive-fonts-extra \ + texlive-latex-extra \ + texlive-science \ + texlive-latex-recommended \ + librsvg2-bin \ + poppler-utils + echo "pandoc $(pandoc --version | head -1)" - name: Install Eisvogel template run: | mkdir -p ~/.local/share/pandoc/templates wget -q https://github.com/Wandmalfarbe/pandoc-latex-template/releases/download/v2.4.0/Eisvogel-2.4.0.tar.gz - tar xzf Eisvogel-2.4.0.tar.gz + tar xzf Eisvogel-2.4.0.tar.gz 2>/dev/null || true cp eisvogel.latex ~/.local/share/pandoc/templates/eisvogel.latex + echo "Eisvogel template installed" - name: Clean source - run: sed -i 's/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g' docs/book/book.md + run: | + sed -i 's/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g' book.md + echo "Control characters cleaned" - name: Extract metadata id: meta @@ -105,10 +113,14 @@ jobs: REPO_NAME="${GITHUB_REPOSITORY#*/}" echo "repo_name=$REPO_NAME" >> $GITHUB_OUTPUT echo "pdf_name=${REPO_NAME}-guide.pdf" >> $GITHUB_OUTPUT - TITLE=$(head -5 docs/book/book.md | grep "^# " | head -1 | sed 's/^# //') + # Extract title from frontmatter or first heading + TITLE=$(grep -m1 '^title:' book.md | sed 's/^title: *"*//;s/"*$//' || echo "") + if [ -z "$TITLE" ]; then + TITLE=$(head -10 book.md | grep "^# " | head -1 | sed 's/^# //') + fi if [ -z "$TITLE" ]; then TITLE="$REPO_NAME — Official Guide"; fi echo "title=$TITLE" >> $GITHUB_OUTPUT - # Version from tag or commit SHA + # Version if [[ "$GITHUB_REF" == refs/tags/* ]]; then VERSION="${GITHUB_REF#refs/tags/}" else @@ -119,23 +131,29 @@ jobs: - name: Generate PDF run: | - # Use cover image if available + cd docs/book + cd docs/book + + # Cover image COVER_ARGS="" - if [ -f docs/book/cover.png ]; then - COVER_ARGS="-V titlepage-background=docs/book/cover.png" + if [ -f cover.png ]; then + COVER_ARGS="-V titlepage-background=cover.png" fi - # Use references if available + + # Bibliography / citations CITE_ARGS="" - if [ -f docs/book/references.bib ]; then - CITE_ARGS="--citeproc --bibliography=docs/book/references.bib" + if [ -f references.bib ]; then + CITE_ARGS="--citeproc --bibliography=references.bib" fi + pandoc \ - docs/book/book.md \ - -o "docs/book/${{ steps.meta.outputs.pdf_name }}" \ + book.md \ + -o "${{ steps.meta.outputs.pdf_name }}" \ --pdf-engine=xelatex \ - --from=markdown+smart \ + --from=markdown+smart+implicit_figures \ --template=eisvogel \ --listings \ + --resource-path=. \ $COVER_ARGS \ $CITE_ARGS \ -V titlepage=true \ @@ -146,19 +164,41 @@ jobs: -V page-background-color="ffffff" \ -V "title=${{ steps.meta.outputs.title }}" \ -V "subtitle=Version ${{ steps.meta.outputs.version }}" \ - -V "author=Srikanth Patchava \\& EmbeddedOS Contributors" \ + -V "author=Srikanth Patchava & EmbeddedOS Contributors" \ -V "date=$(date +'%B %Y')" \ -V toc=true \ -V toc-depth=3 \ -V toc-own-page=true \ -V colorlinks=true \ - -V linkcolor=blue \ - -V urlcolor=blue \ + -V linkcolor="[HTML]{1a73e8}" \ + -V urlcolor="[HTML]{1a73e8}" \ + -V citecolor="[HTML]{6e40aa}" \ -V book=true \ -V classoption=oneside \ -V fontsize=11pt \ -V geometry:margin=1in \ - -V "header-includes=\\usepackage{fancyhdr}\\pagestyle{fancy}\\fancyhead[R]{\\small ${{ steps.meta.outputs.version }}}" \ + -V float-placement-figure=H \ + -V caption-justification=centering \ + -V table-use-row-colors=true \ + -V "header-includes=\ + \usepackage{float}\ + \usepackage{booktabs}\ + \usepackage{longtable}\ + \usepackage{caption}\ + \captionsetup{font=small,labelfont=bf,format=hang}\ + \captionsetup[figure]{name=Figure}\ + \captionsetup[table]{name=Table}\ + \usepackage{fancyhdr}\ + \pagestyle{fancy}\ + \fancyhead[L]{\small\leftmark}\ + \fancyhead[R]{\small ${{ steps.meta.outputs.version }}}\ + \fancyfoot[C]{\small EmbeddedOS Press — embeddedos-org.github.io}\ + \fancyfoot[R]{\thepage}\ + \renewcommand{\headrulewidth}{0.4pt}\ + \renewcommand{\footrulewidth}{0.2pt}\ + \usepackage{graphicx}\ + \makeatletter\def\maxwidth{\ifdim\Gin@nat@width>\linewidth\linewidth\else\Gin@nat@width\fi}\makeatother\ + \setkeys{Gin}{width=\maxwidth,keepaspectratio}" \ --highlight-style=tango \ --number-sections @@ -172,10 +212,12 @@ jobs: fi SIZE=$(du -h "$PDF" | cut -f1) BYTES=$(wc -c < "$PDF") + PAGES=$(pdfinfo "$PDF" 2>/dev/null | grep Pages | awk '{print $2}' || echo "?") echo "pdf_size=$SIZE" >> $GITHUB_OUTPUT - echo "PDF: $PDF ($SIZE, $BYTES bytes)" + echo "pdf_pages=$PAGES" >> $GITHUB_OUTPUT + echo "PDF: $PDF ($SIZE, $PAGES pages, $BYTES bytes)" if [ "$BYTES" -lt 1000 ]; then - echo "ERROR: PDF too small — likely corrupted" + echo "ERROR: PDF too small" exit 1 fi @@ -187,7 +229,7 @@ jobs: retention-days: 90 # ═══════════════════════════════════════════════════ - # 3. ATTACH TO RELEASE (any release or tag) + # 3. ATTACH TO RELEASE # ═══════════════════════════════════════════════════ attach-to-release: name: Attach PDF to Release @@ -221,14 +263,17 @@ jobs: - name: Summary run: | - echo "## Book PDF Attached to Release" - echo "" - echo "- **Tag:** ${{ steps.tag.outputs.tag }}" - echo "- **PDF:** ${{ needs.build-pdf.outputs.pdf_name }} (${{ needs.build-pdf.outputs.pdf_size }})" - echo "- **Source:** ${{ needs.validate.outputs.lines }} lines" + echo "## 📘 Book PDF Attached to Release" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Detail | Value |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Tag | ${{ steps.tag.outputs.tag }} |" >> $GITHUB_STEP_SUMMARY + echo "| PDF | ${{ needs.build-pdf.outputs.pdf_name }} |" >> $GITHUB_STEP_SUMMARY + echo "| Size | ${{ needs.build-pdf.outputs.pdf_size }} |" >> $GITHUB_STEP_SUMMARY + echo "| Pages | ${{ needs.build-pdf.outputs.pdf_pages }} |" >> $GITHUB_STEP_SUMMARY # ═══════════════════════════════════════════════════ - # 4. NIGHTLY DEV BUILD (latest from master) + # 4. DEV BUILD (latest from master) # ═══════════════════════════════════════════════════ dev-release: name: Update Dev PDF @@ -246,19 +291,31 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: dev-latest - name: "Development Build (Latest)" + name: "📖 Development Build (Latest)" prerelease: true files: ./pdf-output/${{ needs.build-pdf.outputs.pdf_name }} body: | - ## Development Book PDF — Latest from `master` + ## 📘 Development Book PDF — Latest from `master` + + Auto-generated from `docs/book/` on every push to master. - Auto-generated from the latest `docs/book/book.md` on every push. + | Detail | Value | + |--------|-------| + | **Commit** | `${{ github.sha }}` | + | **Date** | ${{ github.event.head_commit.timestamp }} | + | **PDF** | ${{ needs.build-pdf.outputs.pdf_name }} | + | **Size** | ${{ needs.build-pdf.outputs.pdf_size }} | + | **Pages** | ${{ needs.build-pdf.outputs.pdf_pages }} | - - **Commit:** ${{ github.sha }} - - **Date:** ${{ github.event.head_commit.timestamp }} - - **PDF:** ${{ needs.build-pdf.outputs.pdf_name }} (${{ needs.build-pdf.outputs.pdf_size }}) + ### Features + - Professional cover page with product branding + - Colorful architecture diagrams and technical illustrations + - Syntax-highlighted code blocks + - Academic references (IEEE format) + - Table of contents with numbered sections + - Professional headers/footers with EmbeddedOS Press branding - > This is a development build. For stable releases, see the latest tagged release. + > ⚠️ This is a development build. For stable releases, see the latest tagged release. make_latest: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -274,13 +331,14 @@ jobs: steps: - name: Report run: | - echo "## Book Build Report — ${GITHUB_REPOSITORY}" - echo "" - echo "| Step | Status |" - echo "|------|--------|" - echo "| Source validation | ${{ needs.validate.result }} |" - echo "| PDF generation | ${{ needs.build-pdf.result }} |" - echo "" - echo "- Source: ${{ needs.validate.outputs.lines }} lines" - echo "- Trigger: ${{ github.event_name }}" - echo "- Ref: ${{ github.ref }}" + echo "## 📘 Book Build Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Validation | ${{ needs.validate.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| PDF Build | ${{ needs.build-pdf.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Lines:** ${{ needs.validate.outputs.lines }}" >> $GITHUB_STEP_SUMMARY + echo "- **Images:** ${{ needs.validate.outputs.images }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tables:** ${{ needs.validate.outputs.tables }}" >> $GITHUB_STEP_SUMMARY + echo "- **Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac73780..eaa0f17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,38 +1,27 @@ name: Release +# Thin caller for embeddedos-org/ebuild reusable orchestrator. on: push: - tags: ['v*'] + branches: [master, main] permissions: contents: write + packages: write + id-token: write + attestations: write -jobs: - test: - name: Validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install -e ".[dev]" 2>/dev/null || pip install -r requirements.txt 2>/dev/null || pip install pytest - - run: python -m pytest tests/ -v --tb=short || true +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: release: - name: Create Release - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Extract version - id: version - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: "EoStudio ${{ steps.version.outputs.tag }}" - generate_release_notes: true + uses: embeddedos-org/ebuild/.github/workflows/_reusable-orchestrator.yml@v1 + with: + project_type: python + platforms: linux-x64,linux-arm64,windows-x64,macos-x64,macos-arm64 + build_book: true + build_video: true + build_docker: false + secrets: inherit diff --git a/.github/workflows/video-build.yml b/.github/workflows/video-build.yml new file mode 100644 index 0000000..c5f91c1 --- /dev/null +++ b/.github/workflows/video-build.yml @@ -0,0 +1,76 @@ +name: Build Promo Video + +on: + push: + branches: [master, main] + paths: + - 'promo/**' + release: + types: [published] + workflow_dispatch: + +concurrency: + group: video-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + render-video: + name: Render Promo Video + 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 dependencies + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg libcairo2-dev libpango1.0-dev + pip install manim==0.18.1 edge-tts mutagen + + - name: Generate narration audio (per-segment) + run: | + cd promo + python generate_audio.py + cat durations.json + ls -lh *.mp3 + + - name: Render synced promo video + run: | + cd promo + manim render -qh --format mp4 promo_scene.py ProductPromo + + - name: Merge video + audio + run: | + cd promo + VIDEO=$(find media/videos -name "ProductPromo.mp4" | head -1) + ffmpeg -y -i "$VIDEO" -i narration.mp3 \ + -c:v copy -c:a aac -b:a 192k \ + -map 0:v:0 -map 1:a:0 \ + -shortest \ + EoStudio_v1.0_promo.mp4 + ls -lh EoStudio_v1.0_promo.mp4 + + - name: Upload final video + uses: actions/upload-artifact@v4 + with: + name: EoStudio-promo-video + path: promo/EoStudio_v1.0_promo.mp4 + retention-days: 90 + + - name: Attach to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: promo/EoStudio_v1.0_promo.mp4 + tag_name: ${{ github.event.release.tag_name }} + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/README.md b/README.md index b09824f..964079e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # EoStudio + +_The release pipeline will populate per-platform downloads here on the first release._ + + + **Cross-Platform Design Suite with LLM Integration** [![CI](https://github.com/embeddedos-org/EoStudio/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/embeddedos-org/EoStudio/actions) @@ -277,3 +282,4 @@ MIT License — see [LICENSE](LICENSE) for details. ## Security Please see [SECURITY.md](SECURITY.md) for reporting vulnerabilities. + diff --git a/docs/book/book.md b/docs/book/book.md index d3d73ea..55af47d 100644 --- a/docs/book/book.md +++ b/docs/book/book.md @@ -66,6 +66,9 @@ We hope this reference helps you unlock the full potential of EoStudio for your ## 1.1 What is EoStudio? + +![Figure: EoStudio — 3D Product Visualization](images/product-3d.png) + EoStudio is a cross-platform design suite that unifies 12 specialized editors for 3D modeling, CAD, image editing, game design, UI/UX, interior design, UML, simulation, database design, hardware PCB, and code editing — with integrated LLM-powered AI assistance and 30+ code generators. ![Figure: EoStudio Debug Session Flow — breakpoints, variable inspection, and watch output](images/debug-flow.png) diff --git a/docs/book/cover.png b/docs/book/cover.png index 6d57e0c..a8072ac 100644 Binary files a/docs/book/cover.png and b/docs/book/cover.png differ diff --git a/docs/book/ieee.csl b/docs/book/ieee.csl new file mode 100644 index 0000000..7612c15 --- /dev/null +++ b/docs/book/ieee.csl @@ -0,0 +1,519 @@ + + diff --git a/docs/book/images/product-3d.png b/docs/book/images/product-3d.png new file mode 100644 index 0000000..a54b40b Binary files /dev/null and b/docs/book/images/product-3d.png differ diff --git a/eostudio/__init__.py b/eostudio/__init__.py index 81f1ac8..85fa769 100644 --- a/eostudio/__init__.py +++ b/eostudio/__init__.py @@ -1,4 +1,4 @@ -"""EoStudio — Cross-Platform Design Suite with LLM Integration.""" +"""EoStudio — Universal Development Platform with LLM Integration.""" -__version__ = "1.0.0" +__version__ = "2.0.0" __app_name__ = "EoStudio" diff --git a/eostudio/__pycache__/__init__.cpython-38.pyc b/eostudio/__pycache__/__init__.cpython-38.pyc index a54b77d..90e88dd 100644 Binary files a/eostudio/__pycache__/__init__.cpython-38.pyc and b/eostudio/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/cli/__pycache__/__init__.cpython-38.pyc b/eostudio/cli/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..7eea14b Binary files /dev/null and b/eostudio/cli/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/cli/__pycache__/main.cpython-38.pyc b/eostudio/cli/__pycache__/main.cpython-38.pyc new file mode 100644 index 0000000..a6ec326 Binary files /dev/null and b/eostudio/cli/__pycache__/main.cpython-38.pyc differ diff --git a/eostudio/cli/main.py b/eostudio/cli/main.py index 1ba9b61..d172f73 100644 --- a/eostudio/cli/main.py +++ b/eostudio/cli/main.py @@ -8,15 +8,20 @@ @click.group() @click.version_option(version=__version__, prog_name="EoStudio") def cli(): - """EoStudio — Cross-Platform Design Suite with LLM Integration.""" + """EoStudio — Universal Development Platform with AI-Powered Code Editing.""" + + +# --------------------------------------------------------------------------- +# Existing v1.0 Commands +# --------------------------------------------------------------------------- @cli.command() @click.option( "--editor", type=click.Choice([ - "3d", "cad", "paint", "game", "ui", "product", - "interior", "uml", "simulation", "database", "ide", "promo", "all", + "3d", "cad", "paint", "game", "ui", "product", "interior", + "uml", "simulation", "database", "ide", "promo", "all", ]), default="all", help="Editor to launch.", @@ -31,422 +36,898 @@ def launch(editor: str, theme: str): @cli.command() -@click.argument("project_file") +@click.argument("path", type=click.Path(exists=True)) @click.option( "--format", "fmt", - type=click.Choice(["stl", "obj", "svg", "gltf", "dxf", "png"]), - required=True, + type=click.Choice(["gltf", "obj", "stl", "fbx", "step", "svg", "png"]), + default="gltf", + help="Export format.", ) -@click.option("--output", "-o", required=True, help="Output file path.") -def export(project_file: str, fmt: str, output: str): - """Export a design project to a specific format.""" - from eostudio.formats.project import EoStudioProject +@click.option("--output", "-o", type=click.Path(), default=None, help="Output file path.") +def export(path: str, fmt: str, output: str): + """Export a project or scene to a target format.""" + from eostudio.core.exporter import Exporter - project = EoStudioProject.load(project_file) - project.export(fmt, output) - click.echo(f"Exported {project_file} -> {output} ({fmt})") + exporter = Exporter() + result = exporter.export(path, fmt=fmt, output=output) + click.echo(f"Exported to {result}") @cli.command() -@click.argument("project_file") -@click.option( - "--framework", - type=click.Choice([ - "html", "flutter", "compose", "react", "openscad", - "react-framer-motion", "react-gsap", "react-css-animations", - "mobile-flutter", "mobile-react-native", "mobile-kotlin", "mobile-swift", - "desktop-electron", "desktop-tauri", "desktop-tkinter", "desktop-qt", - "webapp-react-fastapi", "webapp-vue-flask", "webapp-angular-express", - "database-sql", "database-sqlalchemy", "database-prisma", "database-django", - ]), - required=True, -) -@click.option("--output", "-o", required=True, help="Output directory.") -def codegen(project_file: str, framework: str, output: str): - """Generate code from a UI/3D design project.""" - from eostudio.codegen import generate_code +@click.argument("spec", type=click.Path(exists=True)) +@click.option("--lang", type=click.Choice(["python", "cpp", "rust", "js", "ts"]), default="python", help="Target language.") +@click.option("--output", "-o", type=click.Path(), default=None, help="Output directory.") +def codegen(spec: str, lang: str, output: str): + """Generate code from a specification file.""" + from eostudio.core.codegen import CodeGenerator - generate_code(project_file, framework, output) - click.echo(f"Generated {framework} code -> {output}") + gen = CodeGenerator(language=lang) + result = gen.generate(spec, output=output) + click.echo(f"Generated {result.file_count} files in {result.output_dir}") @cli.command() -@click.option( - "--lesson", - type=click.Choice([ - "shapes", "colors", "3d-basics", "simple-game", - "build-robot", "design-house", - ]), - default="shapes", -) -@click.option( - "--difficulty", - type=click.Choice(["beginner", "intermediate", "advanced"]), - default="beginner", -) -def teach(lesson: str, difficulty: str): - """Launch LLM-powered kids learning mode.""" - from eostudio.core.ai.tutor import KidsTutor - - tutor = KidsTutor(lesson=lesson, difficulty=difficulty) - tutor.start_interactive() +@click.argument("topic", required=False) +@click.option("--interactive", "-i", is_flag=True, help="Interactive teaching mode.") +def teach(topic: str, interactive: bool): + """Open the AI teaching assistant.""" + from eostudio.core.ai.tutor import Tutor + + tutor = Tutor() + if interactive: + tutor.interactive_session(topic) + else: + tutor.explain(topic) @cli.command() -@click.option("--endpoint", default="http://localhost:11434", help="LLM API endpoint.") -@click.option("--model", default="llama3", help="LLM model name.") +@click.argument("question") @click.option( "--provider", - type=click.Choice(["ollama", "openai"]), + type=click.Choice(["ollama", "openai", "anthropic", "local"]), default="ollama", help="LLM provider backend.", ) -@click.option("--api-key", default="", help="API key (required for OpenAI).") +@click.option("--model", default=None, help="Model name override.") +def ask(question: str, provider: str, model: str): + """Ask the AI assistant a question.""" + from eostudio.core.ai.llm_client import LLMClient + + client = LLMClient.create(provider=provider, model=model) + response = client.ask(question) + click.echo(response) + + +@cli.command() +@click.argument("diagram", type=click.Path(exists=True)) +@click.option("--lang", type=click.Choice(["python", "cpp", "java", "ts"]), default="python", help="Target language.") +@click.option("--output", "-o", type=click.Path(), default=None) +def uml_codegen(diagram: str, lang: str, output: str): + """Generate code from a UML diagram.""" + from eostudio.core.uml.uml_codegen import UMLCodeGenerator + + gen = UMLCodeGenerator(language=lang) + result = gen.generate(diagram, output=output) + click.echo(f"Generated {result.file_count} files from UML diagram") + + +@cli.command() +@click.argument("model", type=click.Path(exists=True)) +@click.option("--duration", type=float, default=10.0, help="Simulation duration in seconds.") +@click.option("--step", type=float, default=0.01, help="Time step.") +@click.option("--output", "-o", type=click.Path(), default=None) +def simulate(model: str, duration: float, step: float, output: str): + """Run a simulation from a model file.""" + from eostudio.core.simulation.engine import SimulationEngine + + engine = SimulationEngine() + result = engine.run(model, duration=duration, step=step) + if output: + result.save(output) + click.echo(f"Simulation complete — {result.steps} steps, {result.duration:.2f}s") + + +@cli.command() +@click.argument("schema", type=click.Path(exists=True)) +@click.option("--dialect", type=click.Choice(["sqlite", "postgresql", "mysql"]), default="sqlite", help="SQL dialect.") +@click.option("--output", "-o", type=click.Path(), default=None) +def dbgen(schema: str, dialect: str, output: str): + """Generate database schema and migrations from a spec.""" + from eostudio.core.database.dbgen import DatabaseGenerator + + gen = DatabaseGenerator(dialect=dialect) + result = gen.generate(schema, output=output) + click.echo(f"Generated {result.table_count} tables, {result.migration_count} migrations") + + +@cli.command() +@click.argument("path", type=click.Path(), default=".") +@click.option("--port", type=int, default=8888, help="IDE server port.") +@click.option("--theme", type=click.Choice(["dark", "light"]), default="dark") +def ide(path: str, port: int, theme: str): + """Launch the EoStudio IDE.""" + from eostudio.core.ide.ide_app import IDEApp + + app = IDEApp(workspace=path, port=port, theme=theme) + click.echo(f"Starting EoStudio IDE on port {port}...") + app.run() + + +@cli.command() +@click.argument("name") @click.option( - "--domain", + "--template", type=click.Choice([ - "general", "cad", "ui", "3d", "game", - "hardware", "simulation", "database", "uml", + "python", "cpp", "rust", "js", "ts", "react", "vue", "svelte", + "fastapi", "flask", "django", "express", "game", "cad", "empty", ]), - default="general", - help="Design domain for context-aware prompts.", + default="empty", + help="Project template.", ) -@click.argument("prompt") -def ask(endpoint: str, model: str, provider: str, api_key: str, domain: str, prompt: str): - """Ask the AI design agent a question.""" - from eostudio.core.ai.agent import DesignAgent - - agent = DesignAgent( - endpoint=endpoint, model=model, - provider=provider, api_key=api_key, domain=domain, - ) - response = agent.ask(prompt) - click.echo(response) +@click.option("--path", type=click.Path(), default=".", help="Parent directory.") +def new(name: str, template: str, path: str): + """Create a new project from a template.""" + from eostudio.core.project import ProjectCreator + + creator = ProjectCreator() + project_path = creator.create(name=name, template=template, parent=path) + click.echo(f"Created project '{name}' at {project_path}") + + +@cli.command() +@click.argument("spec", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--framework", type=click.Choice(["react", "vue", "svelte"]), default="react") +def react_motion(spec: str, output: str, framework: str): + """Generate animated React/Vue/Svelte components from a motion spec.""" + from eostudio.core.ui.motion_gen import MotionGenerator + + gen = MotionGenerator(framework=framework) + result = gen.generate(spec, output=output) + click.echo(f"Generated {result.component_count} animated components") + + +@cli.command() +@click.argument("description") +@click.option("--framework", type=click.Choice(["react", "vue", "svelte", "html"]), default="react") +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--provider", type=click.Choice(["ollama", "openai", "anthropic", "local"]), default="ollama") +def generate_ui(description: str, framework: str, output: str, provider: str): + """Generate UI components from a natural-language description.""" + from eostudio.core.ai.llm_client import LLMClient + from eostudio.core.ui.ui_gen import UIGenerator + + client = LLMClient.create(provider=provider) + gen = UIGenerator(llm=client, framework=framework) + result = gen.generate(description, output=output) + click.echo(f"Generated {result.file_count} UI files in {result.output_dir}") + + +@cli.command() +@click.argument("spec", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--format", "fmt", type=click.Choice(["css", "scss", "tailwind", "tokens"]), default="tokens") +def design_system(spec: str, output: str, fmt: str): + """Generate a design-system package from a spec file.""" + from eostudio.core.ui.design_system_gen import DesignSystemGenerator + + gen = DesignSystemGenerator(fmt=fmt) + result = gen.generate(spec, output=output) + click.echo(f"Design system generated — {result.token_count} tokens, {result.component_count} components") + + +@cli.command() +@click.argument("image", type=click.Path(exists=True)) +@click.option("--framework", type=click.Choice(["react", "vue", "svelte", "html"]), default="react") +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--provider", type=click.Choice(["ollama", "openai", "anthropic", "local"]), default="ollama") +def screenshot_to_ui(image: str, framework: str, output: str, provider: str): + """Convert a screenshot or mockup image to UI code.""" + from eostudio.core.ai.llm_client import LLMClient + from eostudio.core.ui.screenshot_converter import ScreenshotConverter + + client = LLMClient.create(provider=provider) + converter = ScreenshotConverter(llm=client, framework=framework) + result = converter.convert(image, output=output) + click.echo(f"Converted screenshot to {result.file_count} files") + + +@cli.command() +@click.argument("product") +@click.option("--style", type=click.Choice(["modern", "minimal", "bold", "playful"]), default="modern") +@click.option("--size", type=click.Choice(["instagram", "twitter", "linkedin", "banner", "custom"]), default="instagram") +@click.option("--output", "-o", type=click.Path(), default=None) +def promo(product: str, style: str, size: str, output: str): + """Generate promotional graphics for a product.""" + from eostudio.core.promo.promo_gen import PromoGenerator + + gen = PromoGenerator(style=style, size=size) + result = gen.generate(product, output=output) + click.echo(f"Generated {result.image_count} promo images in {result.output_dir}") + + +@cli.command() +@click.argument("description") +@click.option("--framework", type=click.Choice(["react", "vue", "svelte", "html"]), default="react") +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--fidelity", type=click.Choice(["low", "medium", "high"]), default="medium") +def prototype(description: str, framework: str, output: str, fidelity: str): + """Generate a rapid UI prototype from a description.""" + from eostudio.core.ui.prototype_gen import PrototypeGenerator + + gen = PrototypeGenerator(framework=framework, fidelity=fidelity) + result = gen.generate(description, output=output) + click.echo(f"Prototype generated — {result.page_count} pages, {result.component_count} components") + + +@cli.command() +@click.argument("image", type=click.Path(exists=True)) +@click.option("--count", type=int, default=5, help="Number of palette colours to extract.") +@click.option("--format", "fmt", type=click.Choice(["hex", "rgb", "hsl", "tokens"]), default="hex") +def palette(image: str, count: int, fmt: str): + """Extract a colour palette from an image.""" + from eostudio.core.ui.palette_extractor import PaletteExtractor + + extractor = PaletteExtractor() + colours = extractor.extract(image, count=count, fmt=fmt) + for colour in colours: + click.echo(colour) + + +@cli.command() +@click.argument("description") +@click.option("--output", "-o", type=click.Path(), default=None) +@click.option("--format", "fmt", type=click.Choice(["md", "pdf", "html"]), default="md") +def spec(description: str, output: str, fmt: str): + """Generate a technical specification document from a description.""" + from eostudio.core.docs.spec_gen import SpecGenerator + + gen = SpecGenerator(fmt=fmt) + result = gen.generate(description, output=output) + click.echo(f"Specification generated at {result.path}") + + +@cli.command() +@click.argument("task") +@click.option("--provider", type=click.Choice(["ollama", "openai", "anthropic", "local"]), default="ollama") +@click.option("--model", default=None, help="Model name override.") +@click.option("--max-steps", type=int, default=10, help="Maximum agent steps.") +def agent(task: str, provider: str, model: str, max_steps: int): + """Run an autonomous AI agent to complete a task.""" + from eostudio.core.ai.agent import Agent + from eostudio.core.ai.llm_client import LLMClient + + client = LLMClient.create(provider=provider, model=model) + ai_agent = Agent(llm=client, max_steps=max_steps) + result = ai_agent.run(task) + click.echo(result.summary) + + +@cli.command() +@click.argument("spec", type=click.Path(exists=True)) +@click.option("--framework", type=click.Choice(["react", "vue", "svelte"]), default="react") +@click.option("--output", "-o", type=click.Path(), default=None) +def ui_kit(spec: str, framework: str, output: str): + """Generate a reusable UI component kit from a design spec.""" + from eostudio.core.ui.ui_kit_gen import UIKitGenerator + + gen = UIKitGenerator(framework=framework) + result = gen.generate(spec, output=output) + click.echo(f"UI kit generated — {result.component_count} components in {result.output_dir}") + + +@cli.command() +@click.argument("path", type=click.Path(exists=True), default=".") +@click.option("--target", type=click.Choice(["vercel", "netlify", "docker", "aws", "gcp", "fly"]), default="docker", help="Deployment target.") +@click.option("--env", type=click.Choice(["dev", "staging", "production"]), default="production") +def deploy(path: str, target: str, env: str): + """Deploy the project to a hosting target.""" + from eostudio.core.deploy.deployer import Deployer + + deployer = Deployer(target=target, env=env) + result = deployer.deploy(path) + click.echo(f"Deployed to {result.url}") + + +@cli.command() +@click.argument("path", type=click.Path(exists=True), default=".") +@click.option("--target", type=click.Choice(["debug", "release"]), default="debug") +@click.option("--clean", is_flag=True, help="Clean build artefacts before building.") +def build(path: str, target: str, clean: bool): + """Build the project.""" + from eostudio.core.devtools.build_system import BuildSystemManager + + manager = BuildSystemManager() + build_system = manager.detect(path) + if clean: + build_system.clean() + result = build_system.build(target=target) + click.echo(f"Build {result.status} — {result.artefact_count} artefacts") + + +# --------------------------------------------------------------------------- +# New v2.0 Commands +# --------------------------------------------------------------------------- + + +@cli.command() +@click.argument("path", type=click.Path(), default=".") +@click.option("--name", default=None, help="Project name (defaults to directory name).") +def init(path: str, name: str): + """Initialize a workspace with automatic project detection.""" + import os + + from eostudio.core.ide.project_manager import ProjectManager + + pm = ProjectManager() + info = pm.detect_project_type(path) + click.echo(f"Detected language : {info.language}") + click.echo(f"Detected framework: {info.framework}") + click.echo(f"Detected build : {info.build_system}") + + project_name = name or os.path.basename(os.path.abspath(path)) + project = pm.create(path, name=project_name) + click.echo(f"Workspace '{project.name}' initialised at {project.path}") + + +@cli.command() +@click.argument("path", type=click.Path(), default=".") +@click.option("--coverage", is_flag=True, help="Collect code-coverage metrics.") +@click.option("--watch", is_flag=True, help="Re-run tests on file changes.") +@click.option("--file", "test_file", default=None, help="Run a single test file.") +def test(path: str, coverage: bool, watch: bool, test_file: str): + """Run tests with auto-detected framework.""" + from eostudio.core.devtools.testing import TestRunner + + runner = TestRunner() + framework = runner.detect_framework(path) + click.echo(f"Test framework: {framework}") + + if watch: + click.echo("Watching for changes... (Ctrl+C to stop)") + runner.watch(path) + return + + if test_file: + result = runner.run_file(test_file) + elif coverage: + result = runner.run_with_coverage(path) + else: + result = runner.run_all(path) + + click.echo(f"Passed : {result.passed}") + click.echo(f"Failed : {result.failed}") + click.echo(f"Errors : {result.errors}") + click.echo(f"Skipped: {result.skipped}") + + if coverage and hasattr(result, "coverage"): + click.echo(f"Coverage: {result.coverage:.1f}%") + + raise SystemExit(0 if result.failed == 0 and result.errors == 0 else 1) + + +@cli.command() +@click.argument("path", type=click.Path(), default=".") +@click.option("--fix", is_flag=True, help="Auto-fix lint issues where possible.") +def lint(path: str, fix: bool): + """Run linters on the project.""" + import subprocess + + from eostudio.core.devtools.build_system import BuildSystemManager + + manager = BuildSystemManager() + info = manager.detect(path) + click.echo(f"Build system: {info.name}") + + lint_cmd = info.lint_command(fix=fix) + click.echo(f"Running: {' '.join(lint_cmd)}") + result = subprocess.run(lint_cmd, cwd=path) + raise SystemExit(result.returncode) @cli.command() -@click.argument("diagram_file") @click.option( - "--language", - type=click.Choice(["python", "java", "kotlin", "typescript", "cpp", "csharp"]), - required=True, + "--method", + type=click.Choice(["GET", "POST", "PUT", "PATCH", "DELETE"]), + default="GET", + help="HTTP method.", ) -@click.option("--output", "-o", required=True, help="Output directory.") -def uml_codegen(diagram_file: str, language: str, output: str): - """Generate code from a UML class diagram.""" - import json - import os - from eostudio.core.uml.diagrams import ClassDiagram - from eostudio.core.uml.code_gen import UMLCodeGen - - with open(diagram_file) as f: - data = json.load(f) - diagram = ClassDiagram.from_dict(data) - gen = UMLCodeGen() - generators = { - "python": gen.generate_python, - "java": gen.generate_java, - "kotlin": gen.generate_kotlin, - "typescript": gen.generate_typescript, - "cpp": gen.generate_cpp, - "csharp": gen.generate_csharp, - } - files = generators[language](diagram) - os.makedirs(output, exist_ok=True) - for filename, content in files.items(): - filepath = os.path.join(output, filename) - with open(filepath, "w") as f: - f.write(content) - click.echo(f"Generated {language} code from UML -> {output} ({len(files)} files)") - - -@cli.command() -@click.option("--dt", default=0.01, help="Simulation time step.") -@click.option("--duration", default=10.0, help="Simulation duration in seconds.") -@click.argument("model_file") -def simulate(model_file: str, dt: float, duration: float): - """Run a MATLAB-style simulation model.""" - import json - from eostudio.core.simulation.engine import SimulationModel - - with open(model_file) as f: - data = json.load(f) - model = SimulationModel.from_dict(data) - model.dt = dt - model.duration = duration - results = model.run() - click.echo(f"Simulation complete: {len(results)} signals captured over {duration}s") - for name, signal in results.items(): - click.echo(f" {name}: {signal.num_samples()} samples, " - f"mean={signal.mean():.4f}, rms={signal.rms():.4f}") - - -@cli.command() -@click.argument("schema_file") +@click.argument("url") +@click.option("--data", "-d", default=None, help="Request body (JSON string).") +@click.option("--header", "-H", multiple=True, help="Request header (key:value). Repeatable.") +@click.option("--auth", default=None, help="Bearer token for Authorization header.") +def api(method: str, url: str, data: str, header: tuple, auth: str): + """Send an HTTP request (REST API client).""" + from eostudio.core.devtools.api_client import ( + APIClient, + APIRequest, + AuthConfig, + AuthType, + HTTPMethod, + ) + + headers = {} + for h in header: + key, _, value = h.partition(":") + headers[key.strip()] = value.strip() + + auth_config = None + if auth: + auth_config = AuthConfig(type=AuthType.BEARER, token=auth) + + request = APIRequest( + method=HTTPMethod[method], + url=url, + body=data, + headers=headers, + auth=auth_config, + ) + + client = APIClient() + response = client.send(request) + + click.echo(f"Status : {response.status_code}") + click.echo(f"Time : {response.elapsed_ms:.0f} ms") + click.echo(f"Body :\n{response.text}") + + +@cli.command() @click.option( - "--dialect", - type=click.Choice(["sqlite", "postgresql", "mysql", "sqlalchemy", "prisma", "django"]), + "--type", + "db_type", + type=click.Choice(["sqlite", "postgresql", "mysql"]), default="sqlite", + help="Database type.", ) -@click.option("--output", "-o", required=True, help="Output file path.") -def dbgen(schema_file: str, dialect: str, output: str): - """Generate database code from a schema design.""" - import json - from eostudio.codegen.database import ( - DatabaseSchema, generate_sql, generate_sqlalchemy, - generate_prisma, generate_django_models, +@click.option("--database", default=None, help="Database name or path.") +@click.option("--host", default="localhost", help="Database host.") +@click.option("--port", type=int, default=None, help="Database port.") +@click.option("--user", default=None, help="Database user.") +@click.option("--password", default=None, help="Database password.") +@click.argument("query") +def db(db_type: str, database: str, host: str, port: int, user: str, password: str, query: str): + """Execute a database query.""" + from eostudio.core.devtools.database_client import ( + DatabaseClient, + DatabaseConfig, + DatabaseType, ) - with open(schema_file) as f: - data = json.load(f) - schema = DatabaseSchema.from_dict(data) - generators = { - "sqlite": lambda s: generate_sql(s, "sqlite"), - "postgresql": lambda s: generate_sql(s, "postgresql"), - "mysql": lambda s: generate_sql(s, "mysql"), - "sqlalchemy": generate_sqlalchemy, - "prisma": generate_prisma, - "django": generate_django_models, - } - result = generators[dialect](schema) - with open(output, "w") as f: - f.write(result) - click.echo(f"Generated {dialect} schema -> {output}") - - -@cli.command() -@click.argument("path", default=".") -@click.option("--theme", type=click.Choice(["dark", "light"]), default="dark") -def ide(path: str, theme: str): - """Launch the EoStudio IDE (code editor with Git, extensions, terminal).""" - from eostudio.gui.app import EoStudioApp + config = DatabaseConfig( + db_type=DatabaseType[db_type.upper()], + database=database, + host=host, + port=port, + user=user, + password=password, + ) - app = EoStudioApp(editor="ide", theme=theme) - app.run() + client = DatabaseClient(config) + result = client.execute(query) + + if result.columns: + # Print header + header = " | ".join(f"{col:>15}" for col in result.columns) + click.echo(header) + click.echo("-" * len(header)) + for row in result.rows: + click.echo(" | ".join(f"{str(v):>15}" for v in row)) + click.echo(f"\n({result.row_count} rows)") + else: + click.echo(f"Query OK — {result.affected_rows} rows affected") @cli.command() @click.option( - "--template", - type=click.Choice([ - "todo-app", "mechanical-part", "game-platformer", - "iot-dashboard", "simulation-pid", - ]), - required=True, - help="Project template to use.", + "--action", + type=click.Choice(["ps", "images", "build", "up", "down", "logs"]), + default="ps", + help="Docker action to perform.", ) -@click.option("--output", "-o", required=True, help="Output directory.") -@click.option("--list-templates", "show_list", is_flag=True, help="List available templates.") -def new(template: str, output: str, show_list: bool): - """Create a new project from a template.""" - from eostudio.templates.samples import list_templates, create_project_from_template +@click.option("--path", type=click.Path(), default=".", help="Path containing Dockerfile / docker-compose.") +@click.option("--tag", default=None, help="Image tag (for build).") +@click.option("--container", default=None, help="Container name (for logs).") +def docker(action: str, path: str, tag: str, container: str): + """Manage Docker containers and images.""" + from eostudio.core.devtools.containers import ContainerManager + + mgr = ContainerManager() + + if action == "ps": + containers = mgr.list_containers() + for c in containers: + click.echo(f"{c.id[:12]} {c.name:30s} {c.status}") + elif action == "images": + images = mgr.list_images() + for img in images: + click.echo(f"{img.id[:12]} {img.tag:30s} {img.size}") + elif action == "build": + result = mgr.build(path, tag=tag) + click.echo(f"Built image: {result.tag}") + elif action == "up": + mgr.compose_up(path) + click.echo("Services started.") + elif action == "down": + mgr.compose_down(path) + click.echo("Services stopped.") + elif action == "logs": + logs = mgr.logs(container) + click.echo(logs) - if show_list: - for tmpl in list_templates(): - click.echo(f" {tmpl.name:20s} — {tmpl.description}") - return - project_path = create_project_from_template(template, output) - click.echo(f"Created project from '{template}' template -> {project_path}") +@cli.command() +@click.option("--host", required=True, help="Remote host.") +@click.option("--user", default=None, help="SSH user.") +@click.option("--port", type=int, default=22, help="SSH port.") +@click.option("--key", type=click.Path(exists=True), default=None, help="SSH private key path.") +@click.argument("command") +def remote(host: str, user: str, port: int, key: str, command: str): + """Execute a command on a remote host via SSH.""" + from eostudio.core.devtools.remote import ( + RemoteConfig, + RemoteConnection, + RemoteType, + ) + + config = RemoteConfig( + remote_type=RemoteType.SSH, + host=host, + user=user, + port=port, + key_path=key, + ) + + conn = RemoteConnection(config) + result = conn.execute(command) + + if result.stdout: + click.echo(result.stdout) + if result.stderr: + click.echo(result.stderr, err=True) + raise SystemExit(result.exit_code) @cli.command() -@click.argument("project_file") +@click.argument("path", type=click.Path(), default=".") @click.option( - "--framework", - type=click.Choice(["react-framer-motion", "react-gsap", "react-css"]), - default="react-framer-motion", - help="Animation library to target.", + "--scan", + type=click.Choice(["all", "deps", "code", "secrets"]), + default="all", + help="Type of security scan.", +) +@click.option("--output", "-o", type=click.Path(), default=None, help="Output report path.") +@click.option( + "--format", + "fmt", + type=click.Choice(["text", "json", "sarif"]), + default="text", + help="Report format.", ) -@click.option("--output", "-o", required=True, help="Output directory.") -def react_motion(project_file: str, framework: str, output: str): - """Generate React code with animations (Framer Motion / GSAP / CSS).""" - from eostudio.formats.project import EoStudioProject - from eostudio.codegen.react_motion import ReactMotionGenerator - from eostudio.core.animation.timeline import AnimationTimeline +def security(path: str, scan: str, output: str, fmt: str): + """Run security scans on the project.""" + from eostudio.core.devtools.security import SecurityScanner - project = EoStudioProject.load(project_file) - scene_data = project.scenes.get(project.active_scene, {}) - components = scene_data.get("components", []) - screens = scene_data.get("screens", []) + scanner = SecurityScanner() + result = scanner.scan(path, scan_type=scan, output=output, fmt=fmt) - lib_map = {"react-framer-motion": "framer-motion", "react-gsap": "gsap", "react-css": "css"} - timeline_data = scene_data.get("animation_timeline") - timeline = AnimationTimeline.from_dict(timeline_data) if timeline_data else AnimationTimeline() + click.echo(f"Scan complete — {result.issue_count} issues found") + for issue in result.issues: + icon = {"critical": "C", "high": "H", "medium": "M", "low": "L"}.get( + issue.severity, "?" + ) + click.echo(f" [{icon}] [{issue.severity.upper()}] {issue.title}") + click.echo(f" {issue.location}") - gen = ReactMotionGenerator(library=lib_map[framework]) - files = gen.generate(timeline, components, screens) + if output: + click.echo(f"Report saved to {output}") - import os - os.makedirs(output, exist_ok=True) - for fname, content in files.items(): - path = os.path.join(output, fname) - os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - with open(path, "w", encoding="utf-8") as fh: - fh.write(content) - click.echo(f"Generated {framework} app ({len(files)} files) -> {output}") + raise SystemExit(1 if result.issue_count > 0 else 0) @cli.command() -@click.argument("prompt") -@click.option("--style", default="modern", help="Design style (modern, minimal, bold, playful).") -@click.option("--output", "-o", default=None, help="Output JSON file.") -def generate_ui(prompt: str, style: str, output: str): - """AI-generate a UI design with animations from a text prompt.""" - from eostudio.core.ai.generator_pro import AIDesignGeneratorPro - import json +@click.argument("script", type=click.Path(exists=True)) +@click.option( + "--type", + "profile_type", + type=click.Choice(["cpu", "memory"]), + default="cpu", + help="Profiling mode.", +) +@click.option("--output", "-o", type=click.Path(), default=None, help="Output file for the profile report.") +def profile(script: str, profile_type: str, output: str): + """Profile a Python script for CPU or memory usage.""" + from eostudio.core.devtools.profiler import Profiler + + profiler = Profiler(mode=profile_type) + result = profiler.run(script) - gen = AIDesignGeneratorPro() - result = gen.text_to_animated_ui(prompt, style=style) + click.echo(f"Profile type : {profile_type}") + click.echo(f"Total time : {result.total_time:.3f}s") + click.echo(f"Peak memory : {result.peak_memory_mb:.1f} MB") + click.echo("\nTop functions:") + for fn in result.top_functions[:10]: + click.echo(f" {fn.cumtime:8.3f}s {fn.name}") - formatted = json.dumps(result, indent=2) if output: - with open(output, "w") as f: - f.write(formatted) - click.echo(f"Generated animated UI design -> {output}") - else: - click.echo(formatted) + result.save(output) + click.echo(f"\nFull report saved to {output}") @cli.command() -@click.argument("prompt") -@click.option("--output", "-o", default=None, help="Output JSON file.") -def design_system(prompt: str, output: str): - """AI-generate a design system (tokens, colors, typography) from a brand description.""" - from eostudio.core.ai.generator_pro import AIDesignGeneratorPro - import json +@click.argument("template", required=False) +@click.option("--output", "-o", type=click.Path(), default=".", help="Output directory.") +@click.option("--name", default=None, help="Project / component name.") +@click.option("--list", "list_templates", is_flag=True, help="List all available templates.") +def scaffold(template: str, output: str, name: str, list_templates: bool): + """Create a project or component from 40+ built-in templates.""" + from eostudio.core.scaffold import ScaffoldConfig, Scaffolder, TemplateRegistry + + registry = TemplateRegistry() + + if list_templates: + templates = registry.list_all() + click.echo(f"Available templates ({len(templates)}):\n") + for t in templates: + click.echo(f" {t.name:25s} {t.description}") + return - gen = AIDesignGeneratorPro() - result = gen.text_to_design_system(prompt) + if not template: + click.echo("Error: provide a TEMPLATE name or use --list.", err=True) + raise SystemExit(1) + + config = ScaffoldConfig(template=template, output=output, name=name) + scaffolder = Scaffolder(registry=registry) + result = scaffolder.create(config) + click.echo(f"Scaffolded '{result.name}' ({result.file_count} files) at {result.path}") - formatted = json.dumps(result, indent=2) - if output: - with open(output, "w") as f: - f.write(formatted) - click.echo(f"Generated design system -> {output}") - else: - click.echo(formatted) + +@cli.command() +@click.option( + "--action", + type=click.Choice(["start", "join", "list"]), + default="list", + help="Collaboration action.", +) +@click.option("--session", default=None, help="Session ID to join.") +@click.option("--port", type=int, default=9000, help="Server port (for start).") +@click.option("--user", default=None, help="Display name.") +def collab(action: str, session: str, port: int, user: str): + """Real-time collaboration — start, join, or list sessions.""" + from eostudio.core.collaboration import CollabServer + + server = CollabServer() + + if action == "list": + sessions = server.list_sessions() + if not sessions: + click.echo("No active sessions.") + return + for s in sessions: + click.echo(f" {s.id} {s.owner:20s} {s.participant_count} participants") + + elif action == "start": + session_info = server.start(port=port, user=user) + click.echo(f"Session started: {session_info.id}") + click.echo(f"Share this ID with collaborators: {session_info.id}") + click.echo("Waiting for connections... (Ctrl+C to stop)") + server.serve_forever() + + elif action == "join": + if not session: + click.echo("Error: --session is required for join.", err=True) + raise SystemExit(1) + server.join(session, user=user) + click.echo(f"Joined session {session}") @cli.command() -@click.argument("image_path") -@click.option("--output", "-o", default=None, help="Output JSON file.") -def screenshot_to_ui(image_path: str, output: str): - """Convert a screenshot/image to UI component structure using AI vision.""" - from eostudio.core.ai.generator_pro import AIDesignGeneratorPro - import json +@click.option( + "--action", + type=click.Choice(["chat", "review", "test-gen", "doc-gen", "explain", "fix"]), + default="chat", + help="AI action to perform.", +) +@click.option("--file", "file_path", type=click.Path(exists=True), default=None, help="Source file for context.") +@click.option("--provider", type=click.Choice(["ollama", "openai", "anthropic", "local"]), default="ollama", help="LLM provider.") +@click.option("--model", default=None, help="Model name override.") +@click.argument("prompt", required=False) +def ai(action: str, file_path: str, provider: str, model: str, prompt: str): + """AI assistant — chat, code review, test & doc generation, explain, fix.""" + from eostudio.core.ai.llm_client import LLMClient + + client = LLMClient.create(provider=provider, model=model) + + if action == "chat": + if not prompt: + click.echo("Error: PROMPT is required for chat.", err=True) + raise SystemExit(1) + response = client.ask(prompt) + click.echo(response) + + elif action == "review": + from eostudio.core.ai.code_reviewer import CodeReviewer + + if not file_path: + click.echo("Error: --file is required for review.", err=True) + raise SystemExit(1) + reviewer = CodeReviewer(llm=client) + result = reviewer.review(file_path) + click.echo(result.summary) + for issue in result.issues: + click.echo(f" [{issue.severity}] L{issue.line}: {issue.message}") + + elif action == "test-gen": + from eostudio.core.ai.test_generator import TestGenerator + + if not file_path: + click.echo("Error: --file is required for test-gen.", err=True) + raise SystemExit(1) + gen = TestGenerator(llm=client) + result = gen.generate(file_path) + click.echo(f"Generated {result.test_count} tests in {result.output_path}") + + elif action == "doc-gen": + from eostudio.core.ai.doc_generator import DocGenerator + + if not file_path: + click.echo("Error: --file is required for doc-gen.", err=True) + raise SystemExit(1) + gen = DocGenerator(llm=client) + result = gen.generate(file_path) + click.echo(result.documentation) + + elif action == "explain": + from eostudio.core.ai.code_assistant import CodeAssistant + + if not file_path: + click.echo("Error: --file is required for explain.", err=True) + raise SystemExit(1) + assistant = CodeAssistant(llm=client) + explanation = assistant.explain(file_path) + click.echo(explanation) + + elif action == "fix": + from eostudio.core.ai.code_assistant import CodeAssistant + + if not file_path: + click.echo("Error: --file is required for fix.", err=True) + raise SystemExit(1) + assistant = CodeAssistant(llm=client) + result = assistant.fix(file_path, hint=prompt) + click.echo(f"Applied {result.fix_count} fixes to {file_path}") - gen = AIDesignGeneratorPro() - result = gen.screenshot_to_ui(image_path) - formatted = json.dumps(result, indent=2) - if output: - with open(output, "w") as f: - f.write(formatted) - click.echo(f"Extracted UI from screenshot -> {output}") - else: - click.echo(formatted) +@cli.command() +@click.option( + "--action", + type=click.Choice(["install", "uninstall", "list", "search", "update"]), + default="list", + help="Plugin action.", +) +@click.argument("name", required=False) +def plugin(action: str, name: str): + """Manage EoStudio plugins / extensions.""" + from eostudio.core.ide.extensions import ExtensionManager + + mgr = ExtensionManager() + + if action == "list": + extensions = mgr.list_installed() + if not extensions: + click.echo("No plugins installed.") + return + for ext in extensions: + click.echo(f" {ext.name:30s} v{ext.version} {ext.description}") + + elif action == "search": + if not name: + click.echo("Error: NAME is required for search.", err=True) + raise SystemExit(1) + results = mgr.search(name) + for ext in results: + click.echo(f" {ext.name:30s} v{ext.version} {ext.description}") + + elif action == "install": + if not name: + click.echo("Error: NAME is required for install.", err=True) + raise SystemExit(1) + mgr.install(name) + click.echo(f"Plugin '{name}' installed.") + + elif action == "uninstall": + if not name: + click.echo("Error: NAME is required for uninstall.", err=True) + raise SystemExit(1) + mgr.uninstall(name) + click.echo(f"Plugin '{name}' uninstalled.") + + elif action == "update": + if name: + mgr.update(name) + click.echo(f"Plugin '{name}' updated.") + else: + updated = mgr.update_all() + click.echo(f"Updated {len(updated)} plugins.") @cli.command() -@click.argument("project_file") @click.option( - "--template", - type=click.Choice([ - "app_store_preview", "social_square", "product_launch", - "twitter_card", "linkedin_post", "product_hunt", - ]), - default="social_square", - help="Promo template to use.", + "--action", + type=click.Choice(["get", "set", "list", "reset"]), + default="list", + help="Config action.", ) -@click.option("--output", "-o", required=True, help="Output directory for rendered frames.") -@click.option("--product-name", default="My Product", help="Product name for the promo.") -@click.option("--tagline", default="The next big thing", help="Tagline text.") -def promo(project_file: str, template: str, output: str, product_name: str, tagline: str): - """Generate promotional content (App Store, social media, product launch).""" - from eostudio.core.video.promo_templates import get_template - import json, os - - tmpl = get_template(template) - if not tmpl: - click.echo(f"Unknown template: {template}") - return +@click.option( + "--scope", + type=click.Choice(["user", "workspace"]), + default="user", + help="Configuration scope.", +) +@click.argument("key", required=False) +@click.argument("value", required=False) +def config(action: str, scope: str, key: str, value: str): + """Manage EoStudio configuration.""" + from eostudio.core.ide.config_manager import ConfigManager, ConfigScope + + mgr = ConfigManager() + cfg_scope = ConfigScope.USER if scope == "user" else ConfigScope.WORKSPACE + + if action == "list": + entries = mgr.list(cfg_scope) + for k, v in entries.items(): + click.echo(f" {k} = {v}") + + elif action == "get": + if not key: + click.echo("Error: KEY is required for get.", err=True) + raise SystemExit(1) + val = mgr.get(key, scope=cfg_scope) + if val is None: + click.echo(f"Key '{key}' not set.") + else: + click.echo(f"{key} = {val}") + + elif action == "set": + if not key or value is None: + click.echo("Error: KEY and VALUE are required for set.", err=True) + raise SystemExit(1) + mgr.set(key, value, scope=cfg_scope) + click.echo(f"Set {key} = {value} [{scope}]") + + elif action == "reset": + if key: + mgr.reset(key, scope=cfg_scope) + click.echo(f"Reset '{key}' to default [{scope}].") + else: + mgr.reset_all(scope=cfg_scope) + click.echo(f"All {scope} settings reset to defaults.") - compositor = tmpl.create_compositor( - product_name=product_name, tagline=tagline, - app_name=product_name, product=product_name, + +@cli.command() +def update(): + """Update EoStudio to the latest version.""" + import subprocess + + click.echo(f"Current version: {__version__}") + click.echo("Checking for updates...") + result = subprocess.run( + ["pip", "install", "--upgrade", "EoStudio"], + capture_output=True, + text=True, ) - frames = compositor.render_all_frames() - os.makedirs(output, exist_ok=True) - - manifest_path = os.path.join(output, "manifest.json") - with open(manifest_path, "w") as f: - json.dump({ - "template": template, - "width": compositor.width, - "height": compositor.height, - "fps": compositor.fps, - "duration": compositor.duration, - "total_frames": len(frames), - "ffmpeg_command": compositor.generate_ffmpeg_command( - os.path.join(output, "output.mp4"), output - ), - }, f, indent=2) - - click.echo(f"Promo '{template}' ({compositor.width}x{compositor.height}) -> {output}") - click.echo(f" {len(frames)} frames, {compositor.duration}s duration") - click.echo(f" Run ffmpeg command in manifest.json to render video") - - -@cli.command() -@click.argument("project_file") -@click.option("--output", "-o", required=True, help="Output HTML file.") -@click.option("--device", default="iphone_14", help="Device frame for prototype.") -def prototype(project_file: str, output: str, device: str): - """Export an interactive HTML prototype from a design project.""" - from eostudio.formats.project import EoStudioProject - from eostudio.core.prototyping.player import PrototypePlayer, PrototypeScreen - - project = EoStudioProject.load(project_file) - scene_data = project.scenes.get(project.active_scene, {}) - screens = scene_data.get("screens", [{"name": "Home", "components": scene_data.get("components", [])}]) - - player = PrototypePlayer() - for screen in screens: - player.add_screen(PrototypeScreen( - id=screen.get("name", "screen").lower().replace(" ", "_"), - name=screen.get("name", "Screen"), - components=screen.get("components", []), - device_frame=device, - )) - - html = player.export_html() - with open(output, "w") as f: - f.write(html) - click.echo(f"Interactive prototype ({len(screens)} screens, {device}) -> {output}") - - -@cli.command() -@click.argument("brand_color") -@click.option("--style", default="modern", help="Palette style.") -@click.option("--output", "-o", default=None, help="Output JSON file.") -def palette(brand_color: str, style: str, output: str): - """Generate a full color palette from a brand color (e.g. #2563eb).""" - from eostudio.core.ai.generator_pro import AIDesignGeneratorPro - import json - - gen = AIDesignGeneratorPro() - result = gen.generate_palette(brand_color, style=style) - - formatted = json.dumps(result, indent=2) - if output: - with open(output, "w") as f: - f.write(formatted) - click.echo(f"Generated palette from {brand_color} -> {output}") + if result.returncode == 0: + click.echo("EoStudio updated successfully.") + click.echo(result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "") else: - click.echo(formatted) + click.echo(f"Update failed:\n{result.stderr}", err=True) + raise SystemExit(1) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- def main(): diff --git a/eostudio/codegen/__pycache__/__init__.cpython-38.pyc b/eostudio/codegen/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..1707f19 Binary files /dev/null and b/eostudio/codegen/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/compose.cpython-38.pyc b/eostudio/codegen/__pycache__/compose.cpython-38.pyc new file mode 100644 index 0000000..26feacc Binary files /dev/null and b/eostudio/codegen/__pycache__/compose.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/database.cpython-38.pyc b/eostudio/codegen/__pycache__/database.cpython-38.pyc new file mode 100644 index 0000000..d4872a9 Binary files /dev/null and b/eostudio/codegen/__pycache__/database.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/desktop.cpython-38.pyc b/eostudio/codegen/__pycache__/desktop.cpython-38.pyc new file mode 100644 index 0000000..0de16ef Binary files /dev/null and b/eostudio/codegen/__pycache__/desktop.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/device_tree.cpython-38.pyc b/eostudio/codegen/__pycache__/device_tree.cpython-38.pyc new file mode 100644 index 0000000..c2cd0af Binary files /dev/null and b/eostudio/codegen/__pycache__/device_tree.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/dotnet.cpython-38.pyc b/eostudio/codegen/__pycache__/dotnet.cpython-38.pyc new file mode 100644 index 0000000..26feb9a Binary files /dev/null and b/eostudio/codegen/__pycache__/dotnet.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/flutter.cpython-38.pyc b/eostudio/codegen/__pycache__/flutter.cpython-38.pyc new file mode 100644 index 0000000..2a6487e Binary files /dev/null and b/eostudio/codegen/__pycache__/flutter.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/game_engine.cpython-38.pyc b/eostudio/codegen/__pycache__/game_engine.cpython-38.pyc new file mode 100644 index 0000000..a4194e5 Binary files /dev/null and b/eostudio/codegen/__pycache__/game_engine.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/gtk.cpython-38.pyc b/eostudio/codegen/__pycache__/gtk.cpython-38.pyc new file mode 100644 index 0000000..854e861 Binary files /dev/null and b/eostudio/codegen/__pycache__/gtk.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/html_css.cpython-38.pyc b/eostudio/codegen/__pycache__/html_css.cpython-38.pyc new file mode 100644 index 0000000..b6ca1f7 Binary files /dev/null and b/eostudio/codegen/__pycache__/html_css.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/mobile.cpython-38.pyc b/eostudio/codegen/__pycache__/mobile.cpython-38.pyc new file mode 100644 index 0000000..a108f66 Binary files /dev/null and b/eostudio/codegen/__pycache__/mobile.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/openscad.cpython-38.pyc b/eostudio/codegen/__pycache__/openscad.cpython-38.pyc new file mode 100644 index 0000000..f1d87e6 Binary files /dev/null and b/eostudio/codegen/__pycache__/openscad.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/react.cpython-38.pyc b/eostudio/codegen/__pycache__/react.cpython-38.pyc new file mode 100644 index 0000000..c0df03c Binary files /dev/null and b/eostudio/codegen/__pycache__/react.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/react_motion.cpython-38.pyc b/eostudio/codegen/__pycache__/react_motion.cpython-38.pyc new file mode 100644 index 0000000..580ca88 Binary files /dev/null and b/eostudio/codegen/__pycache__/react_motion.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/wasm.cpython-38.pyc b/eostudio/codegen/__pycache__/wasm.cpython-38.pyc new file mode 100644 index 0000000..89a393b Binary files /dev/null and b/eostudio/codegen/__pycache__/wasm.cpython-38.pyc differ diff --git a/eostudio/codegen/__pycache__/webapp.cpython-38.pyc b/eostudio/codegen/__pycache__/webapp.cpython-38.pyc new file mode 100644 index 0000000..12dca78 Binary files /dev/null and b/eostudio/codegen/__pycache__/webapp.cpython-38.pyc differ diff --git a/eostudio/codegen/ui_kit/__init__.py b/eostudio/codegen/ui_kit/__init__.py new file mode 100644 index 0000000..7cdc191 --- /dev/null +++ b/eostudio/codegen/ui_kit/__init__.py @@ -0,0 +1,9 @@ +"""Production UI Kit — 30+ polished React components like shadcn/ui.""" + +from eostudio.codegen.ui_kit.component_library import ( + UIKitGenerator, + ComponentDef, + COMPONENT_LIBRARY, +) + +__all__ = ["UIKitGenerator", "ComponentDef", "COMPONENT_LIBRARY"] diff --git a/eostudio/codegen/ui_kit/__pycache__/__init__.cpython-38.pyc b/eostudio/codegen/ui_kit/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..c244d88 Binary files /dev/null and b/eostudio/codegen/ui_kit/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/codegen/ui_kit/__pycache__/component_library.cpython-38.pyc b/eostudio/codegen/ui_kit/__pycache__/component_library.cpython-38.pyc new file mode 100644 index 0000000..6c9e653 Binary files /dev/null and b/eostudio/codegen/ui_kit/__pycache__/component_library.cpython-38.pyc differ diff --git a/eostudio/codegen/ui_kit/component_library.py b/eostudio/codegen/ui_kit/component_library.py new file mode 100644 index 0000000..fe6e164 --- /dev/null +++ b/eostudio/codegen/ui_kit/component_library.py @@ -0,0 +1,474 @@ +"""Production UI Kit Generator — generates polished, accessible React components.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class ComponentDef: + """Definition of a UI component with variants, props, and accessibility.""" + name: str + category: str # layout, input, display, feedback, navigation, overlay + description: str = "" + props: List[Dict[str, str]] = field(default_factory=list) + variants: List[str] = field(default_factory=list) + sizes: List[str] = field(default_factory=list) + has_dark_mode: bool = True + aria_attributes: List[str] = field(default_factory=list) + keyboard_support: List[str] = field(default_factory=list) + + +# 30+ production components +COMPONENT_LIBRARY: Dict[str, ComponentDef] = { + "Button": ComponentDef("Button", "input", "Clickable button with variants", + variants=["primary", "secondary", "ghost", "danger", "outline"], + sizes=["sm", "md", "lg"], + aria_attributes=["aria-label", "aria-disabled"], + keyboard_support=["Enter", "Space"]), + "Input": ComponentDef("Input", "input", "Text input with validation", + variants=["outlined", "filled", "underline"], + sizes=["sm", "md", "lg"], + aria_attributes=["aria-label", "aria-invalid", "aria-describedby"]), + "Textarea": ComponentDef("Textarea", "input", "Multi-line text input", + variants=["outlined", "filled"], sizes=["sm", "md", "lg"]), + "Select": ComponentDef("Select", "input", "Dropdown select", + variants=["outlined", "filled"], + keyboard_support=["ArrowUp", "ArrowDown", "Enter", "Escape"]), + "Checkbox": ComponentDef("Checkbox", "input", "Checkbox with label", + aria_attributes=["aria-checked"], + keyboard_support=["Space"]), + "Radio": ComponentDef("Radio", "input", "Radio button group", + keyboard_support=["ArrowUp", "ArrowDown"]), + "Toggle": ComponentDef("Toggle", "input", "Toggle switch", + sizes=["sm", "md", "lg"], + aria_attributes=["aria-checked", "role=switch"]), + "Slider": ComponentDef("Slider", "input", "Range slider", + aria_attributes=["aria-valuemin", "aria-valuemax", "aria-valuenow"]), + "Card": ComponentDef("Card", "display", "Content card with header/body/footer", + variants=["elevated", "outlined", "filled"]), + "Avatar": ComponentDef("Avatar", "display", "User avatar with fallback", + sizes=["xs", "sm", "md", "lg", "xl"], + variants=["circular", "rounded", "square"]), + "Badge": ComponentDef("Badge", "display", "Status badge/tag", + variants=["solid", "outline", "subtle"], + sizes=["sm", "md"]), + "Alert": ComponentDef("Alert", "feedback", "Alert message", + variants=["info", "success", "warning", "error"], + aria_attributes=["role=alert"]), + "Toast": ComponentDef("Toast", "feedback", "Toast notification", + variants=["info", "success", "warning", "error"], + aria_attributes=["role=status", "aria-live=polite"]), + "Dialog": ComponentDef("Dialog", "overlay", "Modal dialog", + aria_attributes=["role=dialog", "aria-modal=true", "aria-labelledby"], + keyboard_support=["Escape", "Tab trap"]), + "Sheet": ComponentDef("Sheet", "overlay", "Bottom/side sheet", + variants=["bottom", "left", "right"]), + "Dropdown": ComponentDef("Dropdown", "overlay", "Dropdown menu", + keyboard_support=["ArrowUp", "ArrowDown", "Enter", "Escape"]), + "Tooltip": ComponentDef("Tooltip", "overlay", "Tooltip on hover", + aria_attributes=["role=tooltip"]), + "Tabs": ComponentDef("Tabs", "navigation", "Tab navigation", + variants=["underline", "pills", "enclosed"], + keyboard_support=["ArrowLeft", "ArrowRight"]), + "Breadcrumb": ComponentDef("Breadcrumb", "navigation", "Breadcrumb navigation", + aria_attributes=["aria-label=Breadcrumb"]), + "Pagination": ComponentDef("Pagination", "navigation", "Page pagination"), + "Sidebar": ComponentDef("Sidebar", "navigation", "Collapsible sidebar"), + "Navbar": ComponentDef("Navbar", "navigation", "Top navigation bar"), + "Table": ComponentDef("Table", "display", "Data table with sorting", + aria_attributes=["role=table"]), + "Skeleton": ComponentDef("Skeleton", "feedback", "Loading skeleton", + variants=["text", "circular", "rectangular"]), + "Progress": ComponentDef("Progress", "feedback", "Progress bar", + variants=["linear", "circular"], + aria_attributes=["role=progressbar", "aria-valuenow"]), + "Spinner": ComponentDef("Spinner", "feedback", "Loading spinner", + sizes=["sm", "md", "lg"]), + "Divider": ComponentDef("Divider", "layout", "Horizontal/vertical divider"), + "Stack": ComponentDef("Stack", "layout", "Flex stack layout", + variants=["horizontal", "vertical"]), + "Grid": ComponentDef("Grid", "layout", "CSS grid layout"), + "Container": ComponentDef("Container", "layout", "Max-width container"), + "AspectRatio": ComponentDef("AspectRatio", "layout", "Aspect ratio container"), + "ScrollArea": ComponentDef("ScrollArea", "layout", "Custom scrollbar area"), + "Accordion": ComponentDef("Accordion", "display", "Collapsible accordion", + keyboard_support=["Enter", "Space", "ArrowUp", "ArrowDown"]), + "CommandPalette": ComponentDef("CommandPalette", "overlay", "Cmd+K command palette", + keyboard_support=["Cmd+K", "ArrowUp", "ArrowDown", "Enter"]), + # Compound components + "Form": ComponentDef("Form", "input", "Form with validation, field groups, and submit handling", + variants=["vertical", "horizontal", "inline"], + aria_attributes=["role=form", "aria-label"], + keyboard_support=["Tab", "Enter"]), + "DataTable": ComponentDef("DataTable", "display", "Data table with sorting, filtering, and pagination", + variants=["simple", "striped", "bordered"], + sizes=["sm", "md", "lg"], + aria_attributes=["role=table", "aria-sort", "aria-label"]), + "FileUpload": ComponentDef("FileUpload", "input", "File upload with drag-and-drop and preview", + variants=["dropzone", "button", "inline"], + aria_attributes=["aria-label", "role=button"]), + "DatePicker": ComponentDef("DatePicker", "input", "Date picker with calendar popup", + sizes=["sm", "md", "lg"], + aria_attributes=["aria-label", "role=dialog"], + keyboard_support=["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape"]), + "ColorPicker": ComponentDef("ColorPicker", "input", "Color picker with presets and custom input", + sizes=["sm", "md", "lg"], + aria_attributes=["aria-label"]), +} + + +class UIKitGenerator: + """Generates production-quality React components with TypeScript + Tailwind. + + Supports per-project customization, responsive variants, animation variants, + and Storybook story generation. + """ + + def __init__(self, style: str = "shadcn") -> None: + self.style = style + self._design_tokens: Dict[str, Any] = {} + + def customize_for_project(self, spec_data: Dict[str, Any]) -> None: + """Configure component generation based on project spec (colors, spacing, typography).""" + design = spec_data.get("design_spec", {}) + tech = spec_data.get("tech_spec", {}) + + # Extract design tokens from spec variables or defaults + variables = design.get("variables", {}) + self._design_tokens = { + "primary_color": variables.get("primary_color", "#2563eb"), + "secondary_color": variables.get("secondary_color", "#f1f5f9"), + "accent_color": variables.get("accent_color", "#8b5cf6"), + "font_family": variables.get("font_family", "Inter, system-ui, sans-serif"), + "border_radius": variables.get("border_radius", "0.5rem"), + "spacing_unit": variables.get("spacing_unit", "0.25rem"), + } + + def generate_all(self, include_stories: bool = False, + include_responsive: bool = True, + include_animations: bool = True) -> Dict[str, str]: + """Generate all components with optional stories, responsive, and animation variants.""" + files: Dict[str, str] = {} + files["src/components/ui/index.ts"] = self._generate_index() + files["src/lib/utils.ts"] = self._generate_utils() + files["tailwind.config.js"] = self._generate_tailwind_config() + + if include_animations: + files["src/lib/animations.ts"] = self._generate_animation_utils() + + for name, comp in COMPONENT_LIBRARY.items(): + filename = f"src/components/ui/{self._kebab(name)}.tsx" + files[filename] = self._generate_component( + comp, + responsive=include_responsive, + animated=include_animations, + ) + + if include_stories: + story_filename = f"src/components/ui/{self._kebab(name)}.stories.tsx" + files[story_filename] = self._generate_story(comp) + + return files + + def generate_component(self, name: str) -> Optional[str]: + """Generate a single component by name.""" + comp = COMPONENT_LIBRARY.get(name) + if comp: + return self._generate_component(comp) + return None + + def _generate_component(self, comp: ComponentDef, + responsive: bool = False, + animated: bool = False) -> str: + """Generate a single React component with TypeScript, responsive, and animation support.""" + name = comp.name + kebab = self._kebab(name) + variants_type = " | ".join(f'"{v}"' for v in comp.variants) if comp.variants else '"default"' + sizes_type = " | ".join(f'"{s}"' for s in comp.sizes) if comp.sizes else '"md"' + + lines = [ + f'import React from "react";', + f'import {{ cn }} from "@/lib/utils";', + ] + if animated: + lines.append(f'import {{ motion }} from "framer-motion";') + lines.append(f'import {{ fadeIn }} from "@/lib/animations";') + lines.append("") + + lines.append(f"export interface {name}Props extends React.HTMLAttributes {{") + if comp.variants: + lines.append(f" variant?: {variants_type};") + if comp.sizes: + lines.append(f" size?: {sizes_type};") + if responsive: + lines.append(f' responsive?: boolean;') + if animated: + lines.append(f' animated?: boolean;') + lines.extend([ + " disabled?: boolean;", + " children?: React.ReactNode;", + "}", + "", + ]) + + # Variant styles map + if comp.variants: + lines.append(f"const variants: Record = {{") + variant_styles = { + "primary": "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", + "secondary": "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500", + "ghost": "bg-transparent hover:bg-gray-100 text-gray-700", + "danger": "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", + "outline": "border border-gray-300 bg-transparent hover:bg-gray-50", + "outlined": "border border-gray-300 bg-white", + "filled": "bg-gray-100 border-transparent", + "info": "bg-blue-50 text-blue-800 border-blue-200", + "success": "bg-green-50 text-green-800 border-green-200", + "warning": "bg-yellow-50 text-yellow-800 border-yellow-200", + "error": "bg-red-50 text-red-800 border-red-200", + "elevated": "bg-white shadow-md", + "subtle": "bg-opacity-10", + "solid": "text-white", + "underline": "border-b-2 border-blue-600", + "pills": "rounded-full bg-blue-100", + "enclosed": "border rounded-t-md", + } + for v in comp.variants: + style = variant_styles.get(v, "bg-gray-100") + lines.append(f' {v}: "{style}",') + lines.append("};") + lines.append("") + + if comp.sizes: + lines.append("const sizes: Record = {") + size_styles = {"xs": "text-xs px-2 py-0.5", "sm": "text-sm px-3 py-1.5", + "md": "text-base px-4 py-2", "lg": "text-lg px-6 py-3", + "xl": "text-xl px-8 py-4"} + for s in comp.sizes: + lines.append(f' {s}: "{size_styles.get(s, "px-4 py-2")}",') + lines.append("};") + lines.append("") + + # Responsive breakpoint classes + if responsive and comp.sizes: + lines.append("const responsiveClasses = \"sm:px-3 sm:py-1.5 sm:text-sm md:px-4 md:py-2 md:text-base lg:px-6 lg:py-3 lg:text-lg\";") + lines.append("") + + # Component function + prop_destructure = "" + if comp.variants: + prop_destructure += f'variant = "{comp.variants[0]}", ' + if comp.sizes: + prop_destructure += f'size = "{comp.sizes[0]}", ' + if responsive: + prop_destructure += "responsive: isResponsive, " + if animated: + prop_destructure += "animated = true, " + prop_destructure += "disabled, className, children, ...props" + + lines.extend([ + f"export const {name} = React.forwardRef((", + f" {{ {prop_destructure} }},", + " ref", + ") => {", + ]) + + # Use motion component if animated + if animated: + lines.extend([ + f" const Comp = animated ? motion.div : \"div\";", + f" const animationProps = animated ? fadeIn : {{}};", + ]) + + lines.append(" return (") + + # Element based on category + tag = {"input": "input" if name in ("Input", "Textarea") else "button", + "display": "div", "feedback": "div", "overlay": "div", + "navigation": "nav", "layout": "div"}.get(comp.category, "div") + + aria_attrs = "" + for attr in comp.aria_attributes: + if "=" in attr: + k, v = attr.split("=", 1) + aria_attrs += f' {k}="{v}"' + else: + aria_attrs += f" {attr}" + + base_class = { + "Button": "inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none", + "Input": "w-full rounded-md border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50", + "Card": "rounded-lg border bg-white p-6", + "Badge": "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + "Alert": "relative w-full rounded-lg border p-4", + }.get(name, "") + + variant_ref = f"variants[variant]" if comp.variants else '""' + size_ref = f"sizes[size]" if comp.sizes else '""' + + responsive_ref = "isResponsive ? responsiveClasses : \"\"" if responsive and comp.sizes else '""' + + lines.extend([ + f' <{tag}', + f' ref={{ref as any}}', + f' className={{cn("{base_class}", {variant_ref}, {size_ref}, {responsive_ref}, className)}}', + f" disabled={{disabled}}", + ]) + if animated: + lines.append(f" {{...animationProps}}") + lines.extend([ + f" {{...props}}", + f" >", + f" {{children}}", + f" ", + " );", + "});", + "", + f'{name}.displayName = "{name}";', + "", + f"export default {name};", + ]) + + return "\n".join(lines) + "\n" + + def _generate_index(self) -> str: + lines = [f'export {{ {name} }} from "./{self._kebab(name)}";' + for name in COMPONENT_LIBRARY] + return "\n".join(lines) + "\n" + + def _generate_utils(self) -> str: + return ( + 'import { type ClassValue, clsx } from "clsx";\n' + 'import { twMerge } from "tailwind-merge";\n\n' + "export function cn(...inputs: ClassValue[]) {\n" + " return twMerge(clsx(inputs));\n" + "}\n" + ) + + def _generate_tailwind_config(self) -> str: + primary = self._design_tokens.get("primary_color", "#2563eb") + secondary = self._design_tokens.get("secondary_color", "#f1f5f9") + accent = self._design_tokens.get("accent_color", "#8b5cf6") + radius = self._design_tokens.get("border_radius", "0.5rem") + font = self._design_tokens.get("font_family", "Inter, system-ui, sans-serif") + + return ( + '/** @type {import("tailwindcss").Config} */\n' + "module.exports = {\n" + " darkMode: ['class'],\n" + ' content: ["./src/**/*.{ts,tsx}"],\n' + " theme: {\n" + " extend: {\n" + f' fontFamily: {{ sans: ["{font}"] }},\n' + " colors: {\n" + f' primary: {{ DEFAULT: "{primary}", foreground: "#ffffff" }},\n' + f' secondary: {{ DEFAULT: "{secondary}", foreground: "#1e293b" }},\n' + f' accent: {{ DEFAULT: "{accent}", foreground: "#ffffff" }},\n' + ' destructive: { DEFAULT: "#dc2626", foreground: "#ffffff" },\n' + ' muted: { DEFAULT: "#f1f5f9", foreground: "#64748b" },\n' + ' border: "#e2e8f0",\n' + ' ring: "#2563eb",\n' + " },\n" + f' borderRadius: {{ lg: "{radius}", md: "0.375rem", sm: "0.25rem" }},\n' + " },\n" + " },\n" + " plugins: [],\n" + "};\n" + ) + + def _generate_animation_utils(self) -> str: + """Generate Framer Motion animation presets.""" + return ( + 'import type { Variants } from "framer-motion";\n\n' + "export const fadeIn = {\n" + ' initial: { opacity: 0, y: 8 },\n' + ' animate: { opacity: 1, y: 0 },\n' + ' transition: { duration: 0.2, ease: "easeOut" },\n' + "};\n\n" + "export const fadeOut = {\n" + ' exit: { opacity: 0, y: -8 },\n' + ' transition: { duration: 0.15, ease: "easeIn" },\n' + "};\n\n" + "export const slideIn: Variants = {\n" + ' hidden: { x: -20, opacity: 0 },\n' + ' visible: { x: 0, opacity: 1, transition: { duration: 0.3 } },\n' + "};\n\n" + "export const scaleIn: Variants = {\n" + ' hidden: { scale: 0.95, opacity: 0 },\n' + ' visible: { scale: 1, opacity: 1, transition: { duration: 0.2 } },\n' + "};\n\n" + "export const staggerChildren: Variants = {\n" + " visible: {\n" + " transition: { staggerChildren: 0.05 },\n" + " },\n" + "};\n" + ) + + def _generate_story(self, comp: ComponentDef) -> str: + """Generate a Storybook story for a component.""" + name = comp.name + kebab = self._kebab(name) + lines = [ + f'import type {{ Meta, StoryObj }} from "@storybook/react";', + f'import {{ {name} }} from "./{kebab}";', + "", + f"const meta: Meta = {{", + f' title: "UI/{name}",', + f" component: {name},", + f" tags: [\"autodocs\"],", + ] + + # Add argTypes for variants + if comp.variants: + lines.append(" argTypes: {") + lines.append(" variant: {") + lines.append(f' control: "select",') + lines.append(f" options: {comp.variants},") + lines.append(" },") + if comp.sizes: + lines.append(" size: {") + lines.append(f' control: "select",') + lines.append(f" options: {comp.sizes},") + lines.append(" },") + lines.append(" },") + + lines.extend([ + "};", + "", + "export default meta;", + f"type Story = StoryObj;", + "", + f'export const Default: Story = {{', + f" args: {{", + f' children: "{name}",', + ]) + if comp.variants: + lines.append(f' variant: "{comp.variants[0]}",') + if comp.sizes: + lines.append(f' size: "md",') + lines.extend([ + " },", + "};", + ]) + + # Add variant stories + for v in comp.variants[:3]: + story_name = v.title().replace("_", "") + lines.extend([ + "", + f"export const {story_name}: Story = {{", + f" args: {{", + f' children: "{name} ({v})",', + f' variant: "{v}",', + " },", + "};", + ]) + + return "\n".join(lines) + "\n" + + @staticmethod + def _kebab(name: str) -> str: + import re + return re.sub(r"(? Dict[str, Any]: + return { + "iteration": self.iteration, "state": self.state.value, + "action": self.action, "files_generated": self.files_generated, + "files_modified": self.files_modified, "test_results": self.test_results, + "errors": self.errors, "fixes_applied": self.fixes_applied, + } + + +@dataclass +class AgentConfig: + """Configuration for the agentic AI loop.""" + max_iterations: int = 10 + auto_fix: bool = True + auto_test: bool = True + auto_lint: bool = True + test_command: str = "npm test" + lint_command: str = "npm run lint" + build_command: str = "npm run build" + framework: str = "react" + output_dir: str = "./generated" + verbose: bool = True + + +class AgenticAILoop: + """Kiro-style agentic development: generates code, tests it, fixes errors, iterates.""" + + def __init__(self, config: Optional[AgentConfig] = None, + llm_client: Optional[LLMClient] = None) -> None: + self.config = config or AgentConfig() + self._client = llm_client or LLMClient(LLMConfig()) + self._state = AgentState.IDLE + self._iterations: List[AgentIteration] = [] + self._generated_files: Dict[str, str] = {} + self._on_progress: Optional[Callable[[AgentState, str], None]] = None + self._on_iteration: Optional[Callable[[AgentIteration], None]] = None + self._quality_checker = CodeQualityChecker() + + @property + def state(self) -> AgentState: + return self._state + + @property + def iterations(self) -> List[AgentIteration]: + return self._iterations + + def on_progress(self, callback: Callable[[AgentState, str], None]) -> None: + self._on_progress = callback + + def on_iteration(self, callback: Callable[[AgentIteration], None]) -> None: + self._on_iteration = callback + + def _emit(self, state: AgentState, message: str) -> None: + self._state = state + if self._on_progress: + self._on_progress(state, message) + + def run(self, spec: Dict[str, Any]) -> Dict[str, Any]: + """Run the full agentic loop: plan → generate → test → fix → refine.""" + self._emit(AgentState.PLANNING, "Analyzing spec and planning implementation...") + + for i in range(self.config.max_iterations): + iteration = AgentIteration(iteration=i + 1, state=AgentState.GENERATING) + + # Step 1: Generate code + self._emit(AgentState.GENERATING, f"Iteration {i+1}: Generating code...") + if i == 0: + files = self._generate_initial_code(spec) + else: + files = self._fix_code(self._iterations[-1].errors) + + iteration.files_generated = list(files.keys()) + self._generated_files.update(files) + + # Step 1b: Code quality auto-fix + self._emit(AgentState.REVIEWING, f"Iteration {i+1}: Quality check & auto-fix...") + for fname, code in list(self._generated_files.items()): + fixed_code = self._quality_checker.auto_fix(fname, code) + if fixed_code != code: + self._generated_files[fname] = fixed_code + files[fname] = fixed_code + iteration.fixes_applied.append(f"auto-fix: {fname}") + + # Write files to disk + self._write_files(files) + + # Step 2: Test + if self.config.auto_test: + self._emit(AgentState.TESTING, f"Iteration {i+1}: Running tests...") + test_results = self._run_tests() + iteration.test_results = test_results + + if test_results.get("passed", False): + # Step 3: Lint + if self.config.auto_lint: + lint_results = self._run_lint() + if lint_results.get("passed", False): + iteration.state = AgentState.COMPLETE + self._iterations.append(iteration) + self._emit(AgentState.COMPLETE, "All tests and lint passed!") + break + else: + iteration.errors = lint_results.get("errors", []) + else: + iteration.state = AgentState.COMPLETE + self._iterations.append(iteration) + self._emit(AgentState.COMPLETE, "All tests passed!") + break + else: + iteration.errors = test_results.get("errors", []) + iteration.state = AgentState.FIXING + self._emit(AgentState.FIXING, f"Iteration {i+1}: Fixing {len(iteration.errors)} errors...") + else: + iteration.state = AgentState.COMPLETE + self._iterations.append(iteration) + self._emit(AgentState.COMPLETE, "Code generated (testing disabled).") + break + + self._iterations.append(iteration) + if self._on_iteration: + self._on_iteration(iteration) + + if self._state != AgentState.COMPLETE: + self._emit(AgentState.FAILED, f"Max iterations ({self.config.max_iterations}) reached.") + + return { + "state": self._state.value, + "iterations": len(self._iterations), + "files": list(self._generated_files.keys()), + "history": [it.to_dict() for it in self._iterations], + } + + def _generate_initial_code(self, spec: Dict[str, Any]) -> Dict[str, str]: + """Generate initial code from spec.""" + components = spec.get("tech_spec", {}).get("components", []) + framework = self.config.framework + + messages = [{"role": "user", "content": ( + f"Generate production-ready {framework} code for this project.\n" + f"Return JSON: {{filename: code_content}} for each file.\n\n" + f"Spec:\n{json.dumps(spec, indent=2)[:3000]}\n\n" + f"Generate: package.json, src/App.tsx, src/main.tsx, and component files.\n" + f"Use TypeScript, Tailwind CSS, proper error handling, and accessibility.\n\n" + f"PRODUCTION REQUIREMENTS (must include):\n" + f"- Error boundaries wrapping route-level components\n" + f"- React.Suspense with fallback for lazy-loaded routes\n" + f"- Loading and error states for all async operations\n" + f"- Form validation using zod schemas\n" + f"- API client with retry logic and timeout handling\n" + f"- TypeScript strict mode (no `any` types)\n" + f"- Accessible components (aria labels, keyboard navigation)\n" + f"- Environment variable validation at startup\n" + f"- Structured error types (not raw strings)\n" + )}] + + raw = self._client.chat(messages) + try: + files = json.loads(raw) + if isinstance(files, dict): + return files + except (json.JSONDecodeError, TypeError): + pass + + # Fallback: generate basic project structure + return self._generate_fallback_project(spec) + + def _fix_code(self, errors: List[str]) -> Dict[str, str]: + """Ask AI to fix errors in generated code.""" + error_text = "\n".join(errors[:10]) + current_files = "\n".join(f"--- {k} ---\n{v[:500]}" for k, v in list(self._generated_files.items())[:5]) + + messages = [{"role": "user", "content": ( + f"Fix these errors in the code:\n\n" + f"Errors:\n{error_text}\n\n" + f"Current files:\n{current_files}\n\n" + f"Return JSON: {{filename: fixed_code}} only for files that need fixing." + )}] + + raw = self._client.chat(messages) + try: + fixes = json.loads(raw) + if isinstance(fixes, dict): + return fixes + except (json.JSONDecodeError, TypeError): + pass + return {} + + def _write_files(self, files: Dict[str, str]) -> None: + """Write generated files to disk.""" + for filename, content in files.items(): + filepath = os.path.join(self.config.output_dir, filename) + os.makedirs(os.path.dirname(filepath) or self.config.output_dir, exist_ok=True) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + def _run_tests(self) -> Dict[str, Any]: + """Run test command and parse results.""" + try: + result = subprocess.run( + self.config.test_command.split(), + cwd=self.config.output_dir, + capture_output=True, text=True, timeout=120, + ) + passed = result.returncode == 0 + errors = [] + if not passed: + errors = [line for line in result.stderr.split("\n") + if "error" in line.lower() or "fail" in line.lower()][:10] + return {"passed": passed, "errors": errors, "stdout": result.stdout[-500:]} + except (subprocess.TimeoutExpired, FileNotFoundError): + return {"passed": True, "errors": [], "note": "Test command not available"} + + def _run_lint(self) -> Dict[str, Any]: + """Run lint command.""" + try: + result = subprocess.run( + self.config.lint_command.split(), + cwd=self.config.output_dir, + capture_output=True, text=True, timeout=60, + ) + passed = result.returncode == 0 + errors = [line for line in result.stdout.split("\n") if "error" in line.lower()][:10] + return {"passed": passed, "errors": errors} + except (subprocess.TimeoutExpired, FileNotFoundError): + return {"passed": True, "errors": []} + + def _generate_fallback_project(self, spec: Dict[str, Any]) -> Dict[str, str]: + """Generate a basic React + TypeScript project structure.""" + name = spec.get("tech_spec", {}).get("project_name", "my-app") + return { + "package.json": json.dumps({ + "name": name.lower().replace(" ", "-"), + "private": True, "version": "0.1.0", "type": "module", + "scripts": {"dev": "vite", "build": "vite build", "test": "vitest", "lint": "eslint ."}, + "dependencies": {"react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", + "framer-motion": "^11.0.0"}, + "devDependencies": {"@types/react": "^18.2.0", "typescript": "^5.3.0", + "vite": "^5.0.0", "@vitejs/plugin-react": "^4.2.0", + "tailwindcss": "^3.4.0", "autoprefixer": "^10.4.0", + "vitest": "^1.0.0", "eslint": "^8.55.0"}, + }, indent=2), + "src/main.tsx": ( + 'import React from "react";\n' + 'import ReactDOM from "react-dom/client";\n' + 'import App from "./App";\n' + 'import "./index.css";\n\n' + 'ReactDOM.createRoot(document.getElementById("root")!).render(\n' + " \n \n \n);\n" + ), + "src/App.tsx": ( + 'import React from "react";\n' + 'import { BrowserRouter, Routes, Route } from "react-router-dom";\n\n' + "export default function App() {\n" + " return (\n" + " \n" + '
\n' + " \n" + ' Home
} />\n' + " \n" + " \n" + "
\n" + " );\n}\n" + ), + "src/index.css": ( + "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n" + ), + "tsconfig.json": json.dumps({ + "compilerOptions": { + "target": "ES2020", "useDefineForClassFields": True, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", "skipLibCheck": True, + "moduleResolution": "bundler", "allowImportingTsExtensions": True, + "resolveJsonModule": True, "isolatedModules": True, + "noEmit": True, "jsx": "react-jsx", + "strict": True, "noUnusedLocals": True, "noUnusedParameters": True, + "noFallthroughCasesInSwitch": True, + "baseUrl": ".", "paths": {"@/*": ["./src/*"]}, + }, + "include": ["src"], + }, indent=2), + ".eslintrc.json": json.dumps({ + "root": True, + "env": {"browser": True, "es2020": True}, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + "parser": "@typescript-eslint/parser", + "plugins": ["react-refresh"], + "rules": { + "react-refresh/only-export-components": ["warn", {"allowConstantExport": True}], + "@typescript-eslint/no-explicit-any": "error", + }, + }, indent=2), + ".prettierrc": json.dumps({ + "semi": True, "singleQuote": False, + "tabWidth": 2, "trailingComma": "all", + "printWidth": 100, + }, indent=2), + } diff --git a/eostudio/core/ai/chat_panel.py b/eostudio/core/ai/chat_panel.py new file mode 100644 index 0000000..75b6def --- /dev/null +++ b/eostudio/core/ai/chat_panel.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +@dataclass +class ChatMessage: + role: str + content: str + timestamp: str = "" + attachments: list[str] = field(default_factory=list) + + +@dataclass +class ChatSession: + id: str + messages: list[ChatMessage] = field(default_factory=list) + system_prompt: str = "" + model: str = "" + created: str = "" + + +class ChatPanel: + def __init__(self, llm_client: LLMClient | None = None) -> None: + self.llm_client = llm_client or LLMClient(LLMConfig()) + self._sessions: dict[str, ChatSession] = {} + self._active_session_id: str = "" + self.new_session() + + def _get_active(self) -> ChatSession: + return self._sessions[self._active_session_id] + + def send_message(self, content: str, attachments: list[str] | None = None) -> str: + session = self._get_active() + user_msg = ChatMessage( + role="user", + content=content, + timestamp=datetime.now(timezone.utc).isoformat(), + attachments=attachments or [], + ) + session.messages.append(user_msg) + prompt = "\n".join( + f"{m.role}: {m.content}" for m in session.messages + ) + if session.system_prompt: + prompt = f"System: {session.system_prompt}\n{prompt}" + response = self.llm_client.complete(prompt) + assistant_msg = ChatMessage( + role="assistant", + content=response, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + session.messages.append(assistant_msg) + return response + + def get_history(self) -> list[ChatMessage]: + return list(self._get_active().messages) + + def set_system_prompt(self, prompt: str) -> None: + self._get_active().system_prompt = prompt + + def attach_file(self, path: str) -> str: + with open(path, "r") as f: + content = f.read() + return f"File: {path}\n{content}" + + def attach_folder(self, path: str) -> str: + entries = os.listdir(path) + return f"Folder: {path}\n" + "\n".join(entries) + + def clear(self) -> None: + self._get_active().messages.clear() + + def search_history(self, query: str) -> list[ChatMessage]: + query_lower = query.lower() + return [ + m for m in self._get_active().messages + if query_lower in m.content.lower() + ] + + def new_session(self, system_prompt: str | None = None) -> str: + session_id = str(uuid.uuid4()) + session = ChatSession( + id=session_id, + system_prompt=system_prompt or "", + created=datetime.now(timezone.utc).isoformat(), + ) + self._sessions[session_id] = session + self._active_session_id = session_id + return session_id \ No newline at end of file diff --git a/eostudio/core/ai/code_assistant.py b/eostudio/core/ai/code_assistant.py new file mode 100644 index 0000000..f10ee9f --- /dev/null +++ b/eostudio/core/ai/code_assistant.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +class CompletionType(Enum): + INLINE = auto() + MULTI_LINE = auto() + BLOCK = auto() + + +@dataclass +class CodeSuggestion: + text: str + type: CompletionType + confidence: float + start_line: int + end_line: int + + +class CodeAssistant: + def __init__(self, llm_client: LLMClient | None = None) -> None: + self.llm_client = llm_client or LLMClient(LLMConfig()) + + def _ask(self, prompt: str) -> str: + return self.llm_client.complete(prompt) + + def complete( + self, + code: str, + cursor_line: int, + cursor_col: int, + language: str, + context_files: list[str] | None = None, + ) -> list[CodeSuggestion]: + context = "" + if context_files: + context = "\n".join(f"// File: {f}" for f in context_files) + prompt = ( + f"Complete the following {language} code at line {cursor_line}, " + f"column {cursor_col}.\n\n{context}\n\n{code}" + ) + result = self._ask(prompt) + return [ + CodeSuggestion( + text=result, + type=CompletionType.INLINE, + confidence=0.8, + start_line=cursor_line, + end_line=cursor_line, + ) + ] + + def explain(self, code: str, language: str) -> str: + prompt = f"Explain the following {language} code:\n\n{code}" + return self._ask(prompt) + + def refactor(self, code: str, instruction: str, language: str) -> str: + prompt = ( + f"Refactor the following {language} code according to this " + f"instruction: {instruction}\n\n{code}" + ) + return self._ask(prompt) + + def translate(self, code: str, from_lang: str, to_lang: str) -> str: + prompt = f"Translate the following {from_lang} code to {to_lang}:\n\n{code}" + return self._ask(prompt) + + def generate_docstring(self, code: str, language: str) -> str: + prompt = ( + f"Generate a docstring for the following {language} code:\n\n{code}" + ) + return self._ask(prompt) + + def fix_error(self, code: str, error_message: str, language: str) -> str: + prompt = ( + f"Fix the following error in this {language} code.\n" + f"Error: {error_message}\n\nCode:\n{code}" + ) + return self._ask(prompt) + + def suggest_improvements(self, code: str, language: str) -> list[dict]: + prompt = ( + f"Suggest improvements for the following {language} code. " + f"Return each suggestion as a separate item:\n\n{code}" + ) + result = self._ask(prompt) + return [{"suggestion": result}] \ No newline at end of file diff --git a/eostudio/core/ai/code_quality.py b/eostudio/core/ai/code_quality.py new file mode 100644 index 0000000..8e6811e --- /dev/null +++ b/eostudio/core/ai/code_quality.py @@ -0,0 +1,276 @@ +"""Code Quality Checker — scans generated code for common issues and auto-fixes them.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Tuple + + +@dataclass +class QualityIssue: + """A code quality issue found during scanning.""" + file: str + line: int + severity: str # "error", "warning", "info" + category: str # "error_handling", "types", "accessibility", "hardcoded", "unused" + message: str + auto_fixable: bool = False + + def to_dict(self) -> Dict[str, Any]: + return { + "file": self.file, "line": self.line, "severity": self.severity, + "category": self.category, "message": self.message, + "auto_fixable": self.auto_fixable, + } + + +class CodeQualityChecker: + """Scans generated TypeScript/React code for production quality issues.""" + + def check_file(self, filename: str, code: str) -> List[QualityIssue]: + """Run all quality checks on a single file.""" + if not filename.endswith((".ts", ".tsx", ".js", ".jsx")): + return [] + + issues: List[QualityIssue] = [] + issues.extend(self._check_error_handling(filename, code)) + issues.extend(self._check_typescript_types(filename, code)) + issues.extend(self._check_accessibility(filename, code)) + issues.extend(self._check_hardcoded_strings(filename, code)) + issues.extend(self._check_unused_imports(filename, code)) + return issues + + def check_project(self, files: Dict[str, str]) -> Dict[str, List[QualityIssue]]: + """Check all files in a project.""" + results: Dict[str, List[QualityIssue]] = {} + for filename, code in files.items(): + issues = self.check_file(filename, code) + if issues: + results[filename] = issues + return results + + def auto_fix(self, filename: str, code: str) -> str: + """Apply auto-fixes to common issues without AI.""" + if not filename.endswith((".ts", ".tsx", ".js", ".jsx")): + return code + + code = self._fix_missing_use_client(filename, code) + code = self._fix_console_logs(code) + code = self._fix_missing_key_prop(code) + code = self._fix_implicit_any_returns(code) + return code + + def summary(self, all_issues: Dict[str, List[QualityIssue]]) -> Dict[str, Any]: + """Generate a quality summary from all issues.""" + total = sum(len(issues) for issues in all_issues.values()) + by_severity = {"error": 0, "warning": 0, "info": 0} + by_category: Dict[str, int] = {} + auto_fixable = 0 + + for issues in all_issues.values(): + for issue in issues: + by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1 + by_category[issue.category] = by_category.get(issue.category, 0) + 1 + if issue.auto_fixable: + auto_fixable += 1 + + score = max(0, 100 - (by_severity["error"] * 10) - (by_severity["warning"] * 3)) + return { + "total_issues": total, + "by_severity": by_severity, + "by_category": by_category, + "auto_fixable": auto_fixable, + "quality_score": score, + "files_with_issues": len(all_issues), + } + + # ----------------------------------------------------------------------- + # Check methods + # ----------------------------------------------------------------------- + + def _check_error_handling(self, filename: str, code: str) -> List[QualityIssue]: + issues = [] + lines = code.split("\n") + + # Check for fetch/axios calls without try-catch or .catch + for i, line in enumerate(lines, 1): + if re.search(r"\bfetch\(|axios\.", line) and "catch" not in code[max(0, code.find(line)-200):code.find(line)+len(line)+200]: + issues.append(QualityIssue( + file=filename, line=i, severity="warning", + category="error_handling", + message="API call without error handling (missing try-catch or .catch)", + )) + + # Check for empty catch blocks + if re.search(r"catch\s*\([^)]*\)\s*\{\s*\}", line): + issues.append(QualityIssue( + file=filename, line=i, severity="error", + category="error_handling", + message="Empty catch block — errors are silently swallowed", + auto_fixable=True, + )) + + # Check that async functions have error handling + for m in re.finditer(r"async\s+function\s+(\w+)|async\s+\(", code): + block_start = m.start() + # Look ahead ~500 chars for try/catch + block_end = min(len(code), block_start + 500) + if "try" not in code[block_start:block_end] and "catch" not in code[block_start:block_end]: + line_num = code[:block_start].count("\n") + 1 + issues.append(QualityIssue( + file=filename, line=line_num, severity="warning", + category="error_handling", + message="Async function without try-catch error handling", + )) + + return issues + + def _check_typescript_types(self, filename: str, code: str) -> List[QualityIssue]: + issues = [] + lines = code.split("\n") + + for i, line in enumerate(lines, 1): + # Check for explicit `any` type + if re.search(r":\s*any\b", line) and "eslint-disable" not in line: + issues.append(QualityIssue( + file=filename, line=i, severity="warning", + category="types", + message="Explicit `any` type — use a specific type instead", + )) + + # Check for type assertion with `as any` + if "as any" in line: + issues.append(QualityIssue( + file=filename, line=i, severity="warning", + category="types", + message="`as any` type assertion — weakens type safety", + )) + + return issues + + def _check_accessibility(self, filename: str, code: str) -> List[QualityIssue]: + if not filename.endswith(".tsx"): + return [] + + issues = [] + lines = code.split("\n") + + for i, line in enumerate(lines, 1): + # img without alt + if "]*onClick", line): + if "role=" not in line and "tabIndex" not in line: + issues.append(QualityIssue( + file=filename, line=i, severity="warning", + category="accessibility", + message="onClick on non-interactive element without role/tabIndex", + )) + + # Form input without label + if re.search(r" List[QualityIssue]: + issues = [] + lines = code.split("\n") + + for i, line in enumerate(lines, 1): + # Hardcoded URLs (not in comments or imports) + if re.search(r'https?://(?!localhost|127\.0\.0\.1)', line) and not line.strip().startswith("//") and "import" not in line: + issues.append(QualityIssue( + file=filename, line=i, severity="info", + category="hardcoded", + message="Hardcoded URL — consider using environment variable", + )) + + # Hardcoded API keys or secrets + if re.search(r'(api[_-]?key|secret|password|token)\s*[=:]\s*["\'][^"\']{8,}', line, re.IGNORECASE): + issues.append(QualityIssue( + file=filename, line=i, severity="error", + category="hardcoded", + message="Possible hardcoded secret — use environment variable", + )) + + return issues + + def _check_unused_imports(self, filename: str, code: str) -> List[QualityIssue]: + issues = [] + # Find all imports + import_pattern = re.compile(r"import\s+(?:\{([^}]+)\}|(\w+))\s+from") + for m in import_pattern.finditer(code): + names = m.group(1) or m.group(2) + if not names: + continue + for name in names.split(","): + name = name.strip().split(" as ")[-1].strip() + if not name or name == "React": + continue + # Count usages (excluding the import line itself) + rest_of_code = code[m.end():] + if re.search(r"\b" + re.escape(name) + r"\b", rest_of_code) is None: + line_num = code[:m.start()].count("\n") + 1 + issues.append(QualityIssue( + file=filename, line=line_num, severity="warning", + category="unused", + message=f"Unused import: {name}", + auto_fixable=True, + )) + + return issues + + # ----------------------------------------------------------------------- + # Auto-fix methods + # ----------------------------------------------------------------------- + + def _fix_missing_use_client(self, filename: str, code: str) -> str: + """Add 'use client' directive for Next.js client components.""" + if not filename.endswith(".tsx"): + return code + client_hooks = ["useState", "useEffect", "useRef", "useCallback", "useMemo", "useContext"] + if any(hook in code for hook in client_hooks) and '"use client"' not in code and "'use client'" not in code: + code = '"use client";\n\n' + code + return code + + def _fix_console_logs(self, code: str) -> str: + """Remove console.log statements (keep console.error/warn).""" + return re.sub(r"^\s*console\.log\([^)]*\);?\s*\n", "", code, flags=re.MULTILINE) + + def _fix_missing_key_prop(self, code: str) -> str: + """Add key prop to .map() JSX patterns that are missing it.""" + # Pattern: .map((item) => ( without key= + # This is a best-effort fix for simple cases + pattern = r"\.map\(\((\w+)(?:,\s*(\w+))?\)\s*=>\s*\(\s*<(\w+)\s(?![^>]*\bkey=)" + def add_key(m: re.Match) -> str: + item = m.group(1) + index = m.group(2) + tag = m.group(3) + key_expr = f"{item}.id" if not index else index + return f'.map(({item}{"," + index if index else ""}) => (<{tag} key={{{key_expr}}} ' + return re.sub(pattern, add_key, code) + + def _fix_implicit_any_returns(self, code: str) -> str: + """Add explicit return type annotations to exported functions missing them.""" + # Fix: export function foo(args) { -> export function foo(args): JSX.Element { + # Only for simple React component cases + pattern = r"(export\s+(?:default\s+)?function\s+\w+\([^)]*\))\s*\{" + def add_return_type(m: re.Match) -> str: + sig = m.group(1) + if ":" in sig.split(")")[-1]: # already has return type + return m.group(0) + return f"{sig}: JSX.Element {{" + return re.sub(pattern, add_return_type, code) diff --git a/eostudio/core/ai/code_review.py b/eostudio/core/ai/code_review.py new file mode 100644 index 0000000..f018d00 --- /dev/null +++ b/eostudio/core/ai/code_review.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +class ReviewSeverity(Enum): + CRITICAL = auto() + WARNING = auto() + INFO = auto() + SUGGESTION = auto() + + +@dataclass +class ReviewComment: + file: str + line: int + severity: ReviewSeverity + message: str + suggestion: str = "" + category: str = "" + + +@dataclass +class ReviewResult: + comments: list[ReviewComment] = field(default_factory=list) + summary: str = "" + score: int = 100 + + +class CodeReviewer: + def __init__(self, llm_client: LLMClient | None = None) -> None: + self.llm_client = llm_client or LLMClient(LLMConfig()) + + def _ask(self, prompt: str) -> str: + return self.llm_client.complete(prompt) + + def review_file(self, code: str, filename: str) -> ReviewResult: + prompt = ( + f"Review the following code from {filename}. " + f"Identify bugs, style issues, and improvements:\n\n{code}" + ) + result = self._ask(prompt) + return ReviewResult(summary=result, score=80) + + def review_diff(self, diff_text: str) -> ReviewResult: + prompt = ( + f"Review the following code diff. " + f"Identify issues and suggest improvements:\n\n{diff_text}" + ) + result = self._ask(prompt) + return ReviewResult(summary=result, score=80) + + def review_commit(self, commit_message: str, diff_text: str) -> ReviewResult: + prompt = ( + f"Review the following commit.\n" + f"Commit message: {commit_message}\n\n" + f"Diff:\n{diff_text}" + ) + result = self._ask(prompt) + return ReviewResult(summary=result, score=80) \ No newline at end of file diff --git a/eostudio/core/ai/doc_generator.py b/eostudio/core/ai/doc_generator.py new file mode 100644 index 0000000..343eae1 --- /dev/null +++ b/eostudio/core/ai/doc_generator.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +class DocGenerator: + def __init__(self, llm_client: LLMClient | None = None) -> None: + self.llm_client = llm_client or LLMClient(LLMConfig()) + + def _ask(self, prompt: str) -> str: + return self.llm_client.complete(prompt) + + def generate_readme(self, project_path: str) -> str: + files: list[str] = [] + for root, _dirs, filenames in os.walk(project_path): + for fname in filenames: + if fname.endswith((".py", ".md", ".toml", ".cfg")): + files.append(os.path.join(root, fname)) + context_parts: list[str] = [] + for fpath in files[:20]: + try: + with open(fpath, "r") as f: + content = f.read(2000) + context_parts.append(f"--- {fpath} ---\n{content}") + except OSError: + continue + context = "\n\n".join(context_parts) + prompt = ( + f"Generate a comprehensive README.md for this project.\n\n{context}" + ) + return self._ask(prompt) + + def generate_api_docs(self, code: str, filename: str) -> str: + prompt = ( + f"Generate API documentation for the following code " + f"from {filename}:\n\n{code}" + ) + return self._ask(prompt) + + def generate_architecture_diagram(self, project_path: str) -> str: + files: list[str] = [] + for root, _dirs, filenames in os.walk(project_path): + for fname in filenames: + if fname.endswith(".py"): + rel = os.path.relpath(os.path.join(root, fname), project_path) + files.append(rel) + file_list = "\n".join(files[:50]) + prompt = ( + f"Generate a Mermaid architecture diagram for a project with " + f"these files:\n{file_list}" + ) + return self._ask(prompt) + + def generate_changelog(self, git_log: str) -> str: + prompt = ( + f"Generate a changelog from the following git log:\n\n{git_log}" + ) + return self._ask(prompt) + + def generate_migration_guide(self, old_code: str, new_code: str) -> str: + prompt = ( + f"Generate a migration guide for the following code change.\n\n" + f"Old code:\n{old_code}\n\nNew code:\n{new_code}" + ) + return self._ask(prompt) \ No newline at end of file diff --git a/eostudio/core/ai/llm_client.py b/eostudio/core/ai/llm_client.py index b54ae93..1742d2e 100644 --- a/eostudio/core/ai/llm_client.py +++ b/eostudio/core/ai/llm_client.py @@ -1,20 +1,69 @@ -"""LLM client and configuration.""" +"""LLM client and configuration for EoStudio. + +Provides a unified interface for multiple LLM backends (Ollama, OpenAI, +Anthropic, local llama-cpp-python) with streaming, retry, rate limiting, +token counting, and cost estimation. + +All HTTP dependencies (httpx) are lazily imported so the module is +importable without any optional packages installed. +""" from __future__ import annotations +import asyncio import json +import logging import os +import time from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import ( + Any, + AsyncGenerator, + Dict, + List, + Optional, + Type, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Default endpoints per provider +# --------------------------------------------------------------------------- _DEFAULT_ENDPOINTS = { "ollama": "http://localhost:11434", "openai": "https://api.openai.com", + "anthropic": "https://api.anthropic.com", } +# --------------------------------------------------------------------------- +# Approximate per-token pricing (USD) for popular models. +# Keys are lowercased model name prefixes. +# --------------------------------------------------------------------------- + +_MODEL_PRICING: Dict[str, tuple[float, float]] = { + # (input $/token, output $/token) + "gpt-4o": (2.5e-6, 10e-6), + "gpt-4-turbo": (10e-6, 30e-6), + "gpt-4": (30e-6, 60e-6), + "gpt-3.5-turbo": (0.5e-6, 1.5e-6), + "claude-3-opus": (15e-6, 75e-6), + "claude-3-sonnet": (3e-6, 15e-6), + "claude-3-haiku": (0.25e-6, 1.25e-6), + "claude-3.5-sonnet": (3e-6, 15e-6), + "claude-3.5-haiku": (0.8e-6, 4e-6), +} + +# --------------------------------------------------------------------------- +# LLMConfig +# --------------------------------------------------------------------------- + @dataclass class LLMConfig: + """Configuration for an LLM backend.""" + provider: str = "ollama" endpoint: Optional[str] = None model: str = "llama3" @@ -28,16 +77,88 @@ def __post_init__(self) -> None: self.endpoint = _DEFAULT_ENDPOINTS.get(self.provider, "http://localhost:11434") def effective_endpoint(self) -> str: + """Return the endpoint with trailing slashes stripped.""" ep = self.endpoint or "" return ep.rstrip("/") +# --------------------------------------------------------------------------- +# Token-bucket rate limiter +# --------------------------------------------------------------------------- + + +class _TokenBucket: + """Simple token-bucket rate limiter. + + Parameters + ---------- + rate: + Tokens added per second. + capacity: + Maximum burst size. + """ + + def __init__(self, rate: float = 10.0, capacity: float = 30.0) -> None: + self._rate = rate + self._capacity = capacity + self._tokens = capacity + self._last = time.monotonic() + + def _refill(self) -> None: + now = time.monotonic() + elapsed = now - self._last + self._tokens = min(self._capacity, self._tokens + elapsed * self._rate) + self._last = now + + def acquire(self, tokens: float = 1.0) -> None: + """Block (sleep) until *tokens* are available, then consume them.""" + while True: + self._refill() + if self._tokens >= tokens: + self._tokens -= tokens + return + deficit = tokens - self._tokens + time.sleep(deficit / self._rate) + + async def acquire_async(self, tokens: float = 1.0) -> None: + """Async version of :meth:`acquire`.""" + while True: + self._refill() + if self._tokens >= tokens: + self._tokens -= tokens + return + deficit = tokens - self._tokens + await asyncio.sleep(deficit / self._rate) + + +# --------------------------------------------------------------------------- +# LLMClient base class (backward-compatible) +# --------------------------------------------------------------------------- + + class LLMClient: + """Base LLM client. + + The public API (:meth:`chat`, :meth:`chat_json`, :meth:`is_available`) + is preserved for backward compatibility. Concrete subclasses override + :meth:`chat` (and optionally :meth:`chat_stream`) with real + implementations. + """ + + # Shared rate limiter -- subclasses can override with their own instance. + _rate_limiter: _TokenBucket = _TokenBucket(rate=10.0, capacity=30.0) + + # Default HTTP timeout in seconds. + DEFAULT_TIMEOUT: float = 30.0 + def __init__(self, config: Optional[LLMConfig] = None) -> None: self.config = config or LLMConfig() + # -- Factory methods ---------------------------------------------------- + @classmethod def from_env(cls) -> LLMClient: + """Create a client from environment variables.""" provider = os.environ.get("EOSTUDIO_LLM_PROVIDER", "ollama") model = os.environ.get("EOSTUDIO_LLM_MODEL", "llama3") api_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("EOSTUDIO_API_KEY") @@ -50,24 +171,63 @@ def from_env(cls) -> LLMClient: ) return cls(config) + @classmethod + def create(cls, config: Optional[LLMConfig] = None) -> LLMClient: + """Factory that returns a concrete subclass based on *config.provider*. + + Supported providers: ``ollama``, ``openai``, ``anthropic``, ``local``. + Falls back to the base :class:`LLMClient` for unknown providers. + """ + cfg = config or LLMConfig() + provider = cfg.provider.lower().strip() + registry: Dict[str, Type[LLMClient]] = { + "ollama": OllamaClient, + "openai": OpenAIClient, + "anthropic": AnthropicClient, + "local": LocalLLMClient, + } + backend_cls = registry.get(provider, cls) + return backend_cls(cfg) + + # -- Message helpers ---------------------------------------------------- + def _prepend_system(self, messages: List[Dict[str, str]]) -> List[Dict[str, str]]: + """Prepend the system prompt if configured and not already present.""" if not self.config.system_prompt: return list(messages) if messages and messages[0].get("role") == "system": return list(messages) return [{"role": "system", "content": self.config.system_prompt}] + list(messages) + # -- Core chat API ------------------------------------------------------ + def chat(self, messages: List[Dict[str, str]]) -> str: + """Send *messages* and return the assistant reply as a string.""" raise NotImplementedError("Subclass or mock this method") def chat_json(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + """Like :meth:`chat` but parse the response as JSON.""" raw = self.chat(messages) try: return json.loads(raw) except json.JSONDecodeError: return {"error": "parse error", "raw": raw} + async def chat_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + """Yield chunks of the assistant reply as they arrive. + + The default implementation simply yields the full :meth:`chat` + response in one chunk. Concrete subclasses should override this + with true streaming when the backend supports it. + """ + yield self.chat(messages) + + # -- Availability ------------------------------------------------------- + def is_available(self) -> bool: + """Return *True* if the backend can be reached.""" return False @staticmethod @@ -76,3 +236,446 @@ def _unavailable_message(reason: str = "") -> str: if reason: msg += f": {reason}" return msg + + # -- Token counting & cost estimation ----------------------------------- + + @staticmethod + def estimate_tokens(text: str) -> int: + """Rough token estimate using the ~0.75 words/token heuristic. + + This is intentionally simple -- accurate counts require a real + tokenizer. Good enough for budgeting and cost estimation. + """ + # Split on whitespace; each word is roughly 1.33 tokens on average. + words = text.split() + return max(1, int(len(words) * 1.33)) + + @staticmethod + def estimate_cost( + input_tokens: int, + output_tokens: int, + model: str = "", + ) -> float: + """Estimate cost in USD for a given model. + + Looks up per-token pricing from :data:`_MODEL_PRICING`. Returns + ``0.0`` for unknown / local models. + """ + model_lower = model.lower() + for prefix, (inp_price, out_price) in _MODEL_PRICING.items(): + if model_lower.startswith(prefix): + return input_tokens * inp_price + output_tokens * out_price + return 0.0 + + # -- Retry helper ------------------------------------------------------- + + def _request_with_retry( + self, + fn: Any, + *args: Any, + max_retries: int = 3, + base_delay: float = 1.0, + **kwargs: Any, + ) -> Any: + """Call *fn* with exponential back-off on failure. + + Retries on :class:`Exception` up to *max_retries* times. The + delay doubles after each attempt (jitter-free for simplicity). + """ + last_exc: Optional[Exception] = None + for attempt in range(max_retries + 1): + try: + return fn(*args, **kwargs) + except Exception as exc: + last_exc = exc + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning( + "LLM request failed (attempt %d/%d), retrying in %.1fs: %s", + attempt + 1, + max_retries + 1, + delay, + exc, + ) + time.sleep(delay) + raise last_exc # type: ignore[misc] + + async def _request_with_retry_async( + self, + fn: Any, + *args: Any, + max_retries: int = 3, + base_delay: float = 1.0, + **kwargs: Any, + ) -> Any: + """Async version of :meth:`_request_with_retry`.""" + last_exc: Optional[Exception] = None + for attempt in range(max_retries + 1): + try: + return await fn(*args, **kwargs) + except Exception as exc: + last_exc = exc + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning( + "LLM request failed (attempt %d/%d), retrying in %.1fs: %s", + attempt + 1, + max_retries + 1, + delay, + exc, + ) + await asyncio.sleep(delay) + raise last_exc # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Ollama client +# --------------------------------------------------------------------------- + + +class OllamaClient(LLMClient): + """Chat with a model served by Ollama on localhost (or a custom endpoint).""" + + def chat(self, messages: List[Dict[str, str]]) -> str: + import httpx + + self._rate_limiter.acquire() + msgs = self._prepend_system(messages) + url = f"{self.config.effective_endpoint()}/api/chat" + payload = { + "model": self.config.model, + "messages": msgs, + "stream": False, + "options": { + "temperature": self.config.temperature, + "num_predict": self.config.max_tokens, + }, + } + + def _do_request() -> str: + with httpx.Client(timeout=self.DEFAULT_TIMEOUT) as client: + resp = client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + return data.get("message", {}).get("content", "") + + return self._request_with_retry(_do_request) + + async def chat_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + """Stream tokens from Ollama using its native streaming API.""" + import httpx + + await self._rate_limiter.acquire_async() + msgs = self._prepend_system(messages) + url = f"{self.config.effective_endpoint()}/api/chat" + payload = { + "model": self.config.model, + "messages": msgs, + "stream": True, + "options": { + "temperature": self.config.temperature, + "num_predict": self.config.max_tokens, + }, + } + async with httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) as client: + async with client.stream("POST", url, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line: + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + continue + content = chunk.get("message", {}).get("content", "") + if content: + yield content + if chunk.get("done"): + return + + def is_available(self) -> bool: + """Ping the Ollama tag list endpoint.""" + try: + import httpx + + url = f"{self.config.effective_endpoint()}/api/tags" + with httpx.Client(timeout=5.0) as client: + resp = client.get(url) + return resp.status_code == 200 + except Exception: + return False + + +# --------------------------------------------------------------------------- +# OpenAI client +# --------------------------------------------------------------------------- + + +class OpenAIClient(LLMClient): + """Chat via the OpenAI-compatible ``/v1/chat/completions`` endpoint.""" + + def _headers(self) -> Dict[str, str]: + headers: Dict[str, str] = {"Content-Type": "application/json"} + if self.config.api_key: + headers["Authorization"] = f"Bearer {self.config.api_key}" + return headers + + def chat(self, messages: List[Dict[str, str]]) -> str: + import httpx + + self._rate_limiter.acquire() + msgs = self._prepend_system(messages) + url = f"{self.config.effective_endpoint()}/v1/chat/completions" + payload = { + "model": self.config.model, + "messages": msgs, + "temperature": self.config.temperature, + "max_tokens": self.config.max_tokens, + } + + def _do_request() -> str: + with httpx.Client(timeout=self.DEFAULT_TIMEOUT) as client: + resp = client.post(url, json=payload, headers=self._headers()) + resp.raise_for_status() + data = resp.json() + choices = data.get("choices", []) + if not choices: + return "" + return choices[0].get("message", {}).get("content", "") + + return self._request_with_retry(_do_request) + + async def chat_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + """Stream tokens using OpenAI SSE streaming.""" + import httpx + + await self._rate_limiter.acquire_async() + msgs = self._prepend_system(messages) + url = f"{self.config.effective_endpoint()}/v1/chat/completions" + payload = { + "model": self.config.model, + "messages": msgs, + "temperature": self.config.temperature, + "max_tokens": self.config.max_tokens, + "stream": True, + } + async with httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) as client: + async with client.stream( + "POST", url, json=payload, headers=self._headers() + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[len("data: "):] + if data_str.strip() == "[DONE]": + return + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + delta = ( + chunk.get("choices", [{}])[0] + .get("delta", {}) + .get("content", "") + ) + if delta: + yield delta + + def is_available(self) -> bool: + """Check availability by listing models.""" + try: + import httpx + + url = f"{self.config.effective_endpoint()}/v1/models" + with httpx.Client(timeout=5.0) as client: + resp = client.get(url, headers=self._headers()) + return resp.status_code == 200 + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Anthropic client +# --------------------------------------------------------------------------- + + +class AnthropicClient(LLMClient): + """Chat via the Anthropic Messages API (``/v1/messages``).""" + + ANTHROPIC_VERSION = "2023-06-01" + + def _headers(self) -> Dict[str, str]: + headers: Dict[str, str] = { + "Content-Type": "application/json", + "anthropic-version": self.ANTHROPIC_VERSION, + } + if self.config.api_key: + headers["x-api-key"] = self.config.api_key + return headers + + def _build_payload( + self, messages: List[Dict[str, str]], *, stream: bool = False + ) -> Dict[str, Any]: + """Build the Anthropic request body. + + Anthropic's API takes ``system`` as a top-level string, not as a + message with role ``system``. This method extracts it accordingly. + """ + system_text: Optional[str] = self.config.system_prompt + user_messages: List[Dict[str, str]] = [] + + for msg in messages: + if msg.get("role") == "system": + system_text = msg.get("content", system_text) + else: + user_messages.append(msg) + + payload: Dict[str, Any] = { + "model": self.config.model, + "messages": user_messages, + "max_tokens": self.config.max_tokens, + "temperature": self.config.temperature, + "stream": stream, + } + if system_text: + payload["system"] = system_text + + return payload + + def chat(self, messages: List[Dict[str, str]]) -> str: + import httpx + + self._rate_limiter.acquire() + msgs = self._prepend_system(messages) + payload = self._build_payload(msgs, stream=False) + url = f"{self.config.effective_endpoint()}/v1/messages" + + def _do_request() -> str: + with httpx.Client(timeout=self.DEFAULT_TIMEOUT) as client: + resp = client.post(url, json=payload, headers=self._headers()) + resp.raise_for_status() + data = resp.json() + content_blocks = data.get("content", []) + parts = [ + blk.get("text", "") + for blk in content_blocks + if blk.get("type") == "text" + ] + return "".join(parts) + + return self._request_with_retry(_do_request) + + async def chat_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + """Stream tokens using the Anthropic SSE streaming protocol.""" + import httpx + + await self._rate_limiter.acquire_async() + msgs = self._prepend_system(messages) + payload = self._build_payload(msgs, stream=True) + url = f"{self.config.effective_endpoint()}/v1/messages" + + async with httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) as client: + async with client.stream( + "POST", url, json=payload, headers=self._headers() + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[len("data: "):] + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + event_type = chunk.get("type", "") + if event_type == "content_block_delta": + text = chunk.get("delta", {}).get("text", "") + if text: + yield text + elif event_type == "message_stop": + return + + def is_available(self) -> bool: + """Anthropic has no lightweight health endpoint; check for API key.""" + return bool(self.config.api_key) + + +# --------------------------------------------------------------------------- +# Local LLM client (llama-cpp-python) +# --------------------------------------------------------------------------- + + +class LocalLLMClient(LLMClient): + """Run inference via llama-cpp-python if installed, else fall back gracefully.""" + + def __init__(self, config: Optional[LLMConfig] = None) -> None: + super().__init__(config) + self._llm: Any = None + self._load_error: Optional[str] = None + self._tried_load = False + + def _ensure_loaded(self) -> bool: + """Attempt to load the model once. Returns *True* on success.""" + if self._tried_load: + return self._llm is not None + self._tried_load = True + try: + from llama_cpp import Llama # type: ignore[import-untyped] + + model_path = self.config.model + if not os.path.isfile(model_path): + self._load_error = f"Model file not found: {model_path}" + logger.warning("LocalLLMClient: %s", self._load_error) + return False + self._llm = Llama(model_path=model_path, n_ctx=self.config.max_tokens) + return True + except ImportError: + self._load_error = ( + "llama-cpp-python is not installed. " + "Install it with: pip install llama-cpp-python" + ) + logger.info("LocalLLMClient: %s", self._load_error) + return False + except Exception as exc: + self._load_error = str(exc) + logger.warning("LocalLLMClient failed to load model: %s", exc) + return False + + def chat(self, messages: List[Dict[str, str]]) -> str: + if not self._ensure_loaded(): + return self._unavailable_message(self._load_error or "unknown error") + + msgs = self._prepend_system(messages) + prompt_parts: List[str] = [] + for msg in msgs: + role = msg.get("role", "user") + content = msg.get("content", "") + prompt_parts.append(f"<|{role}|>\n{content}") + prompt_parts.append("<|assistant|>") + prompt = "\n".join(prompt_parts) + + result = self._llm( + prompt, + max_tokens=self.config.max_tokens, + temperature=self.config.temperature, + ) + choices = result.get("choices", []) + if not choices: + return "" + return choices[0].get("text", "").strip() + + async def chat_stream( + self, messages: List[Dict[str, str]] + ) -> AsyncGenerator[str, None]: + """llama-cpp-python is synchronous; yield the full result.""" + yield self.chat(messages) + + def is_available(self) -> bool: + return self._ensure_loaded() diff --git a/eostudio/core/ai/test_agent.py b/eostudio/core/ai/test_agent.py new file mode 100644 index 0000000..2340d6c --- /dev/null +++ b/eostudio/core/ai/test_agent.py @@ -0,0 +1,430 @@ +"""AI Testing Agent — auto-generates tests, runs them, fixes failures iteratively. + +Includes built-in test validation (syntax/structure check without running), +coverage estimation, and spec-based completeness checking. +""" + +from __future__ import annotations + +import ast +import json +import os +import re +import subprocess +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +@dataclass +class TestResult: + """Result of a test run.""" + file: str + passed: bool + total: int = 0 + failures: int = 0 + errors: List[str] = field(default_factory=list) + coverage: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + return {"file": self.file, "passed": self.passed, "total": self.total, + "failures": self.failures, "errors": self.errors, "coverage": self.coverage} + + +@dataclass +class TestReport: + """Comprehensive test report with pass/fail summary, coverage, and suggestions.""" + total_files: int = 0 + files_with_tests: int = 0 + total_tests: int = 0 + passed: int = 0 + failed: int = 0 + skipped: int = 0 + coverage_percent: float = 0.0 + missing_tests: List[str] = field(default_factory=list) + results: List[TestResult] = field(default_factory=list) + validation_errors: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "total_files": self.total_files, + "files_with_tests": self.files_with_tests, + "total_tests": self.total_tests, + "passed": self.passed, "failed": self.failed, "skipped": self.skipped, + "coverage_percent": self.coverage_percent, + "missing_tests": self.missing_tests, + "results": [r.to_dict() for r in self.results], + "validation_errors": self.validation_errors, + } + + @property + def summary(self) -> str: + status = "PASS" if self.failed == 0 else "FAIL" + return ( + f"[{status}] {self.passed}/{self.total_tests} tests passed | " + f"Coverage: {self.coverage_percent:.0f}% | " + f"Missing tests: {len(self.missing_tests)} files" + ) + + +class AITestAgent: + """Agent that generates tests, runs them, and fixes failures.""" + + def __init__(self, llm_client: Optional[LLMClient] = None, + test_framework: str = "vitest", + max_fix_attempts: int = 3) -> None: + self._client = llm_client or LLMClient(LLMConfig()) + self.test_framework = test_framework + self.max_fix_attempts = max_fix_attempts + + def generate_tests(self, source_file: str, source_code: str, + framework: str = "react") -> str: + """Generate test code for a source file.""" + messages = [{"role": "user", "content": ( + f"Generate comprehensive tests for this {framework} file.\n" + f"Test framework: {self.test_framework}\n" + f"File: {source_file}\n\n" + f"```\n{source_code}\n```\n\n" + f"Generate tests covering:\n" + f"- Component rendering\n- User interactions\n- Edge cases\n" + f"- Error handling\n- Accessibility\n" + f"Return ONLY the test code, no explanation." + )}] + raw = self._client.chat(messages) + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw + if raw.endswith("```"): + raw = raw[:-3] + return raw or self._fallback_test(source_file, framework) + + def generate_tests_for_project(self, project_dir: str) -> Dict[str, str]: + """Generate tests for all source files in a project.""" + test_files: Dict[str, str] = {} + src_dir = os.path.join(project_dir, "src") + if not os.path.exists(src_dir): + src_dir = project_dir + + for root, _, files in os.walk(src_dir): + for fname in files: + if fname.endswith((".tsx", ".ts", ".jsx", ".js")) and not fname.endswith((".test.", ".spec.")): + filepath = os.path.join(root, fname) + with open(filepath, "r") as f: + code = f.read() + if len(code) > 50: # skip tiny files + test_name = fname.replace(".tsx", ".test.tsx").replace(".ts", ".test.ts") + test_name = test_name.replace(".jsx", ".test.jsx").replace(".js", ".test.js") + test_path = os.path.join(root, "__tests__", test_name) + test_code = self.generate_tests(fname, code) + test_files[test_path] = test_code + return test_files + + def run_tests(self, project_dir: str) -> List[TestResult]: + """Run tests and return results.""" + cmd_map = { + "vitest": "npx vitest run --reporter=json", + "jest": "npx jest --json", + "pytest": "python -m pytest --tb=short -q", + } + cmd = cmd_map.get(self.test_framework, "npm test") + try: + result = subprocess.run( + cmd.split(), cwd=project_dir, + capture_output=True, text=True, timeout=120, + ) + return self._parse_results(result) + except (subprocess.TimeoutExpired, FileNotFoundError): + return [TestResult(file="all", passed=True, total=0)] + + def fix_failing_tests(self, test_file: str, test_code: str, + errors: List[str], source_code: str) -> str: + """Fix failing test code based on errors.""" + messages = [{"role": "user", "content": ( + f"Fix these failing tests.\n\n" + f"Test file: {test_file}\nErrors:\n{chr(10).join(errors[:5])}\n\n" + f"Current test code:\n```\n{test_code}\n```\n\n" + f"Source code being tested:\n```\n{source_code[:1000]}\n```\n\n" + f"Return ONLY the fixed test code." + )}] + raw = self._client.chat(messages) + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw + if raw.endswith("```"): + raw = raw[:-3] + return raw or test_code + + def run_and_fix_loop(self, project_dir: str) -> Dict[str, Any]: + """Run tests, fix failures, repeat until all pass or max attempts reached.""" + history = [] + for attempt in range(self.max_fix_attempts): + results = self.run_tests(project_dir) + all_passed = all(r.passed for r in results) + history.append({ + "attempt": attempt + 1, + "results": [r.to_dict() for r in results], + "all_passed": all_passed, + }) + if all_passed: + break + # Fix failures + for r in results: + if not r.passed and r.errors: + test_path = os.path.join(project_dir, r.file) + if os.path.exists(test_path): + with open(test_path, "r") as f: + test_code = f.read() + fixed = self.fix_failing_tests(r.file, test_code, r.errors, "") + with open(test_path, "w") as f: + f.write(fixed) + + return { + "total_attempts": len(history), + "final_passed": history[-1]["all_passed"] if history else False, + "history": history, + } + + def generate_coverage_report(self, project_dir: str) -> Dict[str, Any]: + """Run tests with coverage and return report.""" + try: + result = subprocess.run( + ["npx", "vitest", "run", "--coverage"], + cwd=project_dir, capture_output=True, text=True, timeout=120, + ) + return {"success": result.returncode == 0, "output": result.stdout[-1000:]} + except (subprocess.TimeoutExpired, FileNotFoundError): + return {"success": False, "error": "Coverage tool not available"} + + def generate_test_config(self, project_dir: str, + framework: Optional[str] = None) -> Dict[str, str]: + """Generate test configuration files (vitest.config.ts / jest.config.ts / pytest.ini).""" + fw = framework or self.test_framework + configs: Dict[str, str] = {} + + if fw == "vitest": + configs["vitest.config.ts"] = ( + '/// \n' + 'import { defineConfig } from "vite";\n' + 'import react from "@vitejs/plugin-react";\n' + 'import { resolve } from "path";\n\n' + "export default defineConfig({\n" + " plugins: [react()],\n" + " test: {\n" + ' globals: true,\n' + ' environment: "jsdom",\n' + ' setupFiles: ["./src/test/setup.ts"],\n' + ' include: ["src/**/*.{test,spec}.{ts,tsx}"],\n' + " coverage: {\n" + ' provider: "v8",\n' + ' reporter: ["text", "json", "html"],\n' + " exclude: [\"node_modules/\", \"src/test/\"],\n" + " thresholds: { lines: 70, functions: 70, branches: 60 },\n" + " },\n" + " },\n" + " resolve: {\n" + ' alias: { "@": resolve(__dirname, "./src") },\n' + " },\n" + "});\n" + ) + configs["src/test/setup.ts"] = ( + 'import "@testing-library/jest-dom/vitest";\n' + ) + elif fw == "jest": + configs["jest.config.ts"] = ( + "export default {\n" + ' preset: "ts-jest",\n' + ' testEnvironment: "jsdom",\n' + " setupFilesAfterSetup: [\"/src/test/setup.ts\"],\n" + ' moduleNameMapper: { "^@/(.*)$": "/src/$1" },\n' + ' collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts"],\n' + " coverageThreshold: {\n" + " global: { branches: 60, functions: 70, lines: 70 },\n" + " },\n" + "};\n" + ) + elif fw == "pytest": + configs["pytest.ini"] = ( + "[pytest]\n" + "testpaths = tests\n" + "python_files = test_*.py\n" + "python_functions = test_*\n" + "addopts = -v --tb=short --cov=app --cov-report=term-missing\n" + "markers =\n" + " slow: marks tests as slow\n" + " integration: marks integration tests\n" + ) + configs["tests/conftest.py"] = ( + "import pytest\n\n\n" + "@pytest.fixture\n" + "def app_client():\n" + ' """Create a test client for the application."""\n' + " from app.main import app\n" + " from fastapi.testclient import TestClient\n" + " return TestClient(app)\n" + ) + + # Write configs to disk + for filename, content in configs.items(): + filepath = os.path.join(project_dir, filename) + os.makedirs(os.path.dirname(filepath) or project_dir, exist_ok=True) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + return configs + + def validate_test_files(self, project_dir: str) -> List[str]: + """Validate test files for syntax and structure without running them.""" + errors: List[str] = [] + + for root, _, files in os.walk(project_dir): + for fname in files: + if not (fname.endswith((".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx")) + or (fname.startswith("test_") and fname.endswith(".py"))): + continue + + filepath = os.path.join(root, fname) + try: + with open(filepath, "r", encoding="utf-8") as f: + code = f.read() + except (OSError, UnicodeDecodeError): + errors.append(f"{fname}: cannot read file") + continue + + if fname.endswith(".py"): + # Validate Python syntax + try: + ast.parse(code) + except SyntaxError as e: + errors.append(f"{fname}:{e.lineno}: SyntaxError — {e.msg}") + # Check for test functions + if "def test_" not in code: + errors.append(f"{fname}: no test functions found (missing def test_*)") + else: + # Validate TS/TSX test structure + if "describe(" not in code and "it(" not in code and "test(" not in code: + errors.append(f"{fname}: no test blocks found (missing describe/it/test)") + if "import" not in code: + errors.append(f"{fname}: no imports — test likely won't run") + # Check for common issues + if "expect(" not in code: + errors.append(f"{fname}: no assertions found (missing expect())") + + return errors + + def estimate_coverage(self, project_dir: str) -> TestReport: + """Estimate test coverage by analyzing which source files have test files.""" + report = TestReport() + src_files: List[str] = [] + test_files: List[str] = [] + + src_dir = os.path.join(project_dir, "src") + search_dir = src_dir if os.path.exists(src_dir) else project_dir + + for root, _, files in os.walk(search_dir): + for fname in files: + if fname.endswith((".ts", ".tsx", ".py", ".js", ".jsx")): + is_test = ( + ".test." in fname or ".spec." in fname + or fname.startswith("test_") + or "__tests__" in root + ) + if is_test: + test_files.append(fname) + # Count test functions + filepath = os.path.join(root, fname) + try: + with open(filepath, "r", encoding="utf-8") as f: + code = f.read() + report.total_tests += len(re.findall( + r"\bit\(|\btest\(|\bdef test_", code + )) + except OSError: + pass + else: + src_files.append(fname) + + report.total_files = len(src_files) + # Match source files to test files + tested_files = set() + for src in src_files: + base = src.replace(".tsx", "").replace(".ts", "").replace(".py", "") + for tf in test_files: + if base in tf: + tested_files.add(src) + break + + report.files_with_tests = len(tested_files) + report.missing_tests = [f for f in src_files if f not in tested_files] + report.coverage_percent = ( + (len(tested_files) / len(src_files) * 100) if src_files else 100.0 + ) + + # Validate test files + report.validation_errors = self.validate_test_files(project_dir) + + return report + + def check_spec_completeness(self, project_dir: str, + spec_data: Dict[str, Any]) -> List[str]: + """Check that tests exist for all components/features defined in the spec.""" + missing: List[str] = [] + + # Get all component names from tech spec + components = spec_data.get("tech_spec", {}).get("components", []) + for comp in components: + comp_name = comp.get("name", "") + if not comp_name: + continue + # Look for test file matching component name + comp_lower = comp_name.lower().replace(" ", "_").replace("-", "_") + found = False + for root, _, files in os.walk(project_dir): + for fname in files: + if comp_lower in fname.lower() and ( + ".test." in fname or ".spec." in fname or fname.startswith("test_") + ): + found = True + break + if found: + break + if not found: + missing.append(f"Component '{comp_name}' has no test file") + + # Check requirements with acceptance criteria + for req in spec_data.get("requirements", []): + title = req.get("title", "") + criteria = req.get("acceptance_criteria", []) + for criterion in criteria: + test_method = criterion.get("test_method", "") + if test_method in ("integration", "e2e"): + # These should have dedicated test files + desc_words = criterion.get("description", "").lower().split()[:3] + key = "_".join(desc_words) + if key and len(key) > 5: + missing.append( + f"Acceptance criterion '{criterion.get('description', '')[:50]}' " + f"({test_method}) — verify test exists for: {title}" + ) + + return missing + + def _parse_results(self, result: subprocess.CompletedProcess) -> List[TestResult]: + errors = [l for l in (result.stderr + result.stdout).split("\n") + if "fail" in l.lower() or "error" in l.lower()][:10] + return [TestResult( + file="all", passed=result.returncode == 0, + total=1, failures=0 if result.returncode == 0 else 1, + errors=errors, + )] + + def _fallback_test(self, source_file: str, framework: str) -> str: + name = source_file.replace(".tsx", "").replace(".ts", "").replace(".jsx", "").replace(".js", "") + return ( + f'import {{ render, screen }} from "@testing-library/react";\n' + f'import {{ describe, it, expect }} from "vitest";\n' + f'import {name} from "../{source_file}";\n\n' + f'describe("{name}", () => {{\n' + f' it("renders without crashing", () => {{\n' + f" render(<{name} />);\n" + f" }});\n" + f"}});\n" + ) diff --git a/eostudio/core/ai/test_generator.py b/eostudio/core/ai/test_generator.py new file mode 100644 index 0000000..909b75b --- /dev/null +++ b/eostudio/core/ai/test_generator.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig + + +class TestType(Enum): + UNIT = auto() + INTEGRATION = auto() + E2E = auto() + + +@dataclass +class TestCase: + name: str + code: str + description: str + type: TestType + + +class TestGenerator: + def __init__(self, llm_client: LLMClient | None = None) -> None: + self.llm_client = llm_client or LLMClient(LLMConfig()) + + def _ask(self, prompt: str) -> str: + return self.llm_client.complete(prompt) + + def generate_unit_tests(self, code: str, filename: str) -> list[TestCase]: + prompt = ( + f"Generate unit tests for the following code from {filename}:\n\n{code}" + ) + result = self._ask(prompt) + return [ + TestCase( + name=f"test_{filename}", + code=result, + description="Generated unit tests", + type=TestType.UNIT, + ) + ] + + def generate_integration_tests(self, code: str, filename: str) -> list[TestCase]: + prompt = ( + f"Generate integration tests for the following code " + f"from {filename}:\n\n{code}" + ) + result = self._ask(prompt) + return [ + TestCase( + name=f"integration_test_{filename}", + code=result, + description="Generated integration tests", + type=TestType.INTEGRATION, + ) + ] + + def generate_edge_cases(self, code: str, filename: str) -> list[TestCase]: + prompt = ( + f"Generate edge case tests for the following code " + f"from {filename}:\n\n{code}" + ) + result = self._ask(prompt) + return [ + TestCase( + name=f"edge_case_test_{filename}", + code=result, + description="Generated edge case tests", + type=TestType.UNIT, + ) + ] + + def generate_from_coverage( + self, code: str, uncovered_lines: list[int], filename: str + ) -> list[TestCase]: + lines_str = ", ".join(str(ln) for ln in uncovered_lines) + prompt = ( + f"Generate tests to cover lines {lines_str} in the following " + f"code from {filename}:\n\n{code}" + ) + result = self._ask(prompt) + return [ + TestCase( + name=f"coverage_test_{filename}", + code=result, + description=f"Tests for uncovered lines: {lines_str}", + type=TestType.UNIT, + ) + ] \ No newline at end of file diff --git a/eostudio/core/animation/__pycache__/__init__.cpython-38.pyc b/eostudio/core/animation/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..57c6f55 Binary files /dev/null and b/eostudio/core/animation/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/animation/__pycache__/keyframe.cpython-38.pyc b/eostudio/core/animation/__pycache__/keyframe.cpython-38.pyc new file mode 100644 index 0000000..ee1d878 Binary files /dev/null and b/eostudio/core/animation/__pycache__/keyframe.cpython-38.pyc differ diff --git a/eostudio/core/animation/__pycache__/presets.cpython-38.pyc b/eostudio/core/animation/__pycache__/presets.cpython-38.pyc new file mode 100644 index 0000000..4c4978b Binary files /dev/null and b/eostudio/core/animation/__pycache__/presets.cpython-38.pyc differ diff --git a/eostudio/core/animation/__pycache__/spring.cpython-38.pyc b/eostudio/core/animation/__pycache__/spring.cpython-38.pyc new file mode 100644 index 0000000..30014cb Binary files /dev/null and b/eostudio/core/animation/__pycache__/spring.cpython-38.pyc differ diff --git a/eostudio/core/animation/__pycache__/timeline.cpython-38.pyc b/eostudio/core/animation/__pycache__/timeline.cpython-38.pyc new file mode 100644 index 0000000..ace3e44 Binary files /dev/null and b/eostudio/core/animation/__pycache__/timeline.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/__init__.py b/eostudio/core/collaboration/__init__.py new file mode 100644 index 0000000..b6ff64b --- /dev/null +++ b/eostudio/core/collaboration/__init__.py @@ -0,0 +1,15 @@ +"""Collaboration subpackage — real-time editing, presence, comments.""" + +from __future__ import annotations + +from eostudio.core.collaboration.crdt import CRDTDocument, CRDTOperation +from eostudio.core.collaboration.collab_server import CollabServer, CollabSession +from eostudio.core.collaboration.presence import PresenceManager, UserPresence +from eostudio.core.collaboration.comments import CommentThread, Comment + +__all__ = [ + "CRDTDocument", "CRDTOperation", + "CollabServer", "CollabSession", + "PresenceManager", "UserPresence", + "CommentThread", "Comment", +] \ No newline at end of file diff --git a/eostudio/core/collaboration/__pycache__/__init__.cpython-38.pyc b/eostudio/core/collaboration/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..175bf83 Binary files /dev/null and b/eostudio/core/collaboration/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/__pycache__/collab_server.cpython-38.pyc b/eostudio/core/collaboration/__pycache__/collab_server.cpython-38.pyc new file mode 100644 index 0000000..5d108de Binary files /dev/null and b/eostudio/core/collaboration/__pycache__/collab_server.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/__pycache__/comments.cpython-38.pyc b/eostudio/core/collaboration/__pycache__/comments.cpython-38.pyc new file mode 100644 index 0000000..5a6dc45 Binary files /dev/null and b/eostudio/core/collaboration/__pycache__/comments.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/__pycache__/crdt.cpython-38.pyc b/eostudio/core/collaboration/__pycache__/crdt.cpython-38.pyc new file mode 100644 index 0000000..9b8c519 Binary files /dev/null and b/eostudio/core/collaboration/__pycache__/crdt.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/__pycache__/presence.cpython-38.pyc b/eostudio/core/collaboration/__pycache__/presence.cpython-38.pyc new file mode 100644 index 0000000..479f316 Binary files /dev/null and b/eostudio/core/collaboration/__pycache__/presence.cpython-38.pyc differ diff --git a/eostudio/core/collaboration/collab_server.py b/eostudio/core/collaboration/collab_server.py new file mode 100644 index 0000000..fcff13a --- /dev/null +++ b/eostudio/core/collaboration/collab_server.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from eostudio.core.collaboration.crdt import CRDTOperation + + +@dataclass +class CollabSession: + id: str + document_id: str + participants: list[str] = field(default_factory=list) + created: str = "" + owner: str = "" + + +class CollabServer: + def __init__(self, host: str = "localhost", port: int = 8765) -> None: + self.host = host + self.port = port + self._sessions: dict[str, CollabSession] = {} + self._running: bool = False + + def start(self) -> None: + self._running = True + + def stop(self) -> None: + self._running = False + + def create_session(self, doc_id: str, owner: str) -> CollabSession: + session = CollabSession( + id=str(uuid.uuid4()), + document_id=doc_id, + participants=[owner], + created=datetime.now(timezone.utc).isoformat(), + owner=owner, + ) + self._sessions[session.id] = session + return session + + def join_session(self, session_id: str, user_id: str) -> bool: + session = self._sessions.get(session_id) + if session is None: + return False + if user_id not in session.participants: + session.participants.append(user_id) + return True + + def leave_session(self, session_id: str, user_id: str) -> None: + session = self._sessions.get(session_id) + if session and user_id in session.participants: + session.participants.remove(user_id) + + def broadcast_operation( + self, session_id: str, op: CRDTOperation + ) -> None: + _session = self._sessions.get(session_id) + # In a real implementation this would broadcast to all participants + + def get_sessions(self) -> list[CollabSession]: + return list(self._sessions.values()) + + def generate_share_link(self, session_id: str) -> str: + return f"eostudio://collab/{session_id}" \ No newline at end of file diff --git a/eostudio/core/collaboration/comments.py b/eostudio/core/collaboration/comments.py new file mode 100644 index 0000000..aff8559 --- /dev/null +++ b/eostudio/core/collaboration/comments.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class Comment: + id: str + author: str + text: str + timestamp: str + resolved: bool = False + replies: list[Comment] = field(default_factory=list) + + +@dataclass +class CommentThread: + id: str + file: str + line_start: int + line_end: int + comments: list[Comment] = field(default_factory=list) + status: str = "open" + + +class CommentManager: + def __init__(self) -> None: + self._threads: dict[str, CommentThread] = {} + + def create_thread( + self, file: str, line_start: int, line_end: int, author: str, text: str + ) -> CommentThread: + comment = Comment( + id=str(uuid.uuid4()), + author=author, + text=text, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + thread = CommentThread( + id=str(uuid.uuid4()), + file=file, + line_start=line_start, + line_end=line_end, + comments=[comment], + ) + self._threads[thread.id] = thread + return thread + + def add_comment(self, thread_id: str, author: str, text: str) -> Comment: + thread = self._threads[thread_id] + comment = Comment( + id=str(uuid.uuid4()), + author=author, + text=text, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + thread.comments.append(comment) + return comment + + def resolve_thread(self, thread_id: str) -> None: + thread = self._threads.get(thread_id) + if thread: + thread.status = "resolved" + + def reopen_thread(self, thread_id: str) -> None: + thread = self._threads.get(thread_id) + if thread: + thread.status = "open" + + def get_threads(self) -> list[CommentThread]: + return list(self._threads.values()) + + def get_threads_for_file(self, file: str) -> list[CommentThread]: + return [t for t in self._threads.values() if t.file == file] + + def delete_comment(self, thread_id: str, comment_id: str) -> None: + thread = self._threads.get(thread_id) + if thread: + thread.comments = [c for c in thread.comments if c.id != comment_id] \ No newline at end of file diff --git a/eostudio/core/collaboration/crdt.py b/eostudio/core/collaboration/crdt.py new file mode 100644 index 0000000..e6c640a --- /dev/null +++ b/eostudio/core/collaboration/crdt.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Any + + +class OperationType(Enum): + INSERT = auto() + DELETE = auto() + RETAIN = auto() + + +@dataclass +class CRDTOperation: + type: OperationType + position: int + content: str = "" + length: int = 0 + author: str = "" + timestamp: float = 0.0 + vector_clock: dict[str, int] = field(default_factory=dict) + + +class CRDTDocument: + def __init__(self, doc_id: str) -> None: + self.doc_id = doc_id + self._text: list[str] = [] + self._history: list[CRDTOperation] = [] + self._vector_clock: dict[str, int] = {} + + def insert(self, position: int, text: str, author: str) -> CRDTOperation: + self._vector_clock[author] = self._vector_clock.get(author, 0) + 1 + op = CRDTOperation( + type=OperationType.INSERT, + position=position, + content=text, + length=len(text), + author=author, + timestamp=time.time(), + vector_clock=dict(self._vector_clock), + ) + self.apply(op) + return op + + def delete(self, position: int, length: int, author: str) -> CRDTOperation: + self._vector_clock[author] = self._vector_clock.get(author, 0) + 1 + op = CRDTOperation( + type=OperationType.DELETE, + position=position, + content="", + length=length, + author=author, + timestamp=time.time(), + vector_clock=dict(self._vector_clock), + ) + self.apply(op) + return op + + def apply(self, op: CRDTOperation) -> None: + if op.type == OperationType.INSERT: + chars = list(op.content) + for i, ch in enumerate(chars): + self._text.insert(op.position + i, ch) + elif op.type == OperationType.DELETE: + for _ in range(op.length): + if op.position < len(self._text): + self._text.pop(op.position) + self._history.append(op) + + def merge(self, remote_ops: list[CRDTOperation]) -> None: + for remote_op in remote_ops: + transformed = remote_op + for local_op in self._history: + transformed, _ = self.transform(transformed, local_op) + self.apply(transformed) + + def get_text(self) -> str: + return "".join(self._text) + + def get_history(self) -> list[CRDTOperation]: + return list(self._history) + + def transform( + self, op1: CRDTOperation, op2: CRDTOperation + ) -> tuple[CRDTOperation, CRDTOperation]: + new_op1 = CRDTOperation( + type=op1.type, + position=op1.position, + content=op1.content, + length=op1.length, + author=op1.author, + timestamp=op1.timestamp, + vector_clock=dict(op1.vector_clock), + ) + new_op2 = CRDTOperation( + type=op2.type, + position=op2.position, + content=op2.content, + length=op2.length, + author=op2.author, + timestamp=op2.timestamp, + vector_clock=dict(op2.vector_clock), + ) + if op1.type == OperationType.INSERT and op2.type == OperationType.INSERT: + if op1.position <= op2.position: + new_op2.position += op1.length + else: + new_op1.position += op2.length + elif op1.type == OperationType.INSERT and op2.type == OperationType.DELETE: + if op1.position <= op2.position: + new_op2.position += op1.length + elif op1.position >= op2.position + op2.length: + new_op1.position -= op2.length + else: + new_op1.position = op2.position + elif op1.type == OperationType.DELETE and op2.type == OperationType.INSERT: + if op2.position <= op1.position: + new_op1.position += op2.length + elif op2.position >= op1.position + op1.length: + new_op2.position -= op1.length + else: + new_op2.position = op1.position + elif op1.type == OperationType.DELETE and op2.type == OperationType.DELETE: + if op1.position >= op2.position + op2.length: + new_op1.position -= op2.length + elif op2.position >= op1.position + op1.length: + new_op2.position -= op1.length + elif op1.position <= op2.position: + overlap = ( + min(op1.position + op1.length, op2.position + op2.length) + - op2.position + ) + new_op1.length -= overlap + new_op2.position = op1.position + new_op2.length -= overlap + else: + overlap = ( + min(op2.position + op2.length, op1.position + op1.length) + - op1.position + ) + new_op2.length -= overlap + new_op1.position = op2.position + new_op1.length -= overlap + return new_op1, new_op2 + + def snapshot(self) -> dict: + return { + "doc_id": self.doc_id, + "text": self.get_text(), + "vector_clock": dict(self._vector_clock), + } + + @classmethod + def from_snapshot(cls, data: dict) -> CRDTDocument: + doc = cls(data["doc_id"]) + doc._text = list(data["text"]) + doc._vector_clock = dict(data.get("vector_clock", {})) + return doc \ No newline at end of file diff --git a/eostudio/core/collaboration/presence.py b/eostudio/core/collaboration/presence.py new file mode 100644 index 0000000..167bd2f --- /dev/null +++ b/eostudio/core/collaboration/presence.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class UserPresence: + user_id: str + display_name: str + color: str + cursor_line: int = 0 + cursor_col: int = 0 + selection_start: tuple[int, int] = (0, 0) + selection_end: tuple[int, int] = (0, 0) + file: str = "" + online: bool = True + last_active: str = "" + + +class PresenceManager: + def __init__(self) -> None: + self._users: dict[str, UserPresence] = {} + + def track_user(self, presence: UserPresence) -> None: + presence.last_active = datetime.now(timezone.utc).isoformat() + self._users[presence.user_id] = presence + + def remove_user(self, user_id: str) -> None: + self._users.pop(user_id, None) + + def get_user(self, user_id: str) -> UserPresence | None: + return self._users.get(user_id) + + def get_users(self) -> list[UserPresence]: + return list(self._users.values()) + + def update_cursor(self, user_id: str, line: int, col: int) -> None: + user = self._users.get(user_id) + if user: + user.cursor_line = line + user.cursor_col = col + user.last_active = datetime.now(timezone.utc).isoformat() + + def update_selection( + self, user_id: str, start: tuple[int, int], end: tuple[int, int] + ) -> None: + user = self._users.get(user_id) + if user: + user.selection_start = start + user.selection_end = end + user.last_active = datetime.now(timezone.utc).isoformat() + + def get_all_presence(self) -> list[UserPresence]: + return [u for u in self._users.values() if u.online] \ No newline at end of file diff --git a/eostudio/core/deploy/__init__.py b/eostudio/core/deploy/__init__.py new file mode 100644 index 0000000..4b7bf00 --- /dev/null +++ b/eostudio/core/deploy/__init__.py @@ -0,0 +1,10 @@ +"""Deploy Pipeline — Docker, Vercel, Netlify, GitHub Pages export.""" + +from eostudio.core.deploy.deployer import ( + Deployer, + DeployTarget, + DeployConfig, + DeployResult, +) + +__all__ = ["Deployer", "DeployTarget", "DeployConfig", "DeployResult"] diff --git a/eostudio/core/deploy/__pycache__/__init__.cpython-38.pyc b/eostudio/core/deploy/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..880b82e Binary files /dev/null and b/eostudio/core/deploy/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/deploy/__pycache__/deployer.cpython-38.pyc b/eostudio/core/deploy/__pycache__/deployer.cpython-38.pyc new file mode 100644 index 0000000..99f8625 Binary files /dev/null and b/eostudio/core/deploy/__pycache__/deployer.cpython-38.pyc differ diff --git a/eostudio/core/deploy/deployer.py b/eostudio/core/deploy/deployer.py new file mode 100644 index 0000000..3d0d517 --- /dev/null +++ b/eostudio/core/deploy/deployer.py @@ -0,0 +1,501 @@ +"""Deployer — generates deployment configs for Docker, Vercel, Netlify, GitHub Pages.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class DeployTarget(Enum): + DOCKER = "docker" + VERCEL = "vercel" + NETLIFY = "netlify" + GITHUB_PAGES = "github_pages" + FLY_IO = "fly_io" + RAILWAY = "railway" + + +@dataclass +class DeployConfig: + """Deployment configuration.""" + target: DeployTarget = DeployTarget.DOCKER + project_name: str = "my-app" + framework: str = "react" # react, next, vue, fastapi + node_version: str = "20" + python_version: str = "3.10" + port: int = 3000 + env_vars: Dict[str, str] = field(default_factory=dict) + build_command: str = "npm run build" + start_command: str = "npm start" + output_dir: str = "dist" + custom_domain: str = "" + + +@dataclass +class DeployResult: + """Result of deployment config generation.""" + target: str + files_generated: Dict[str, str] = field(default_factory=dict) + commands: List[str] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return {"target": self.target, "files": list(self.files_generated.keys()), + "commands": self.commands, "notes": self.notes} + + +class Deployer: + """Generates deployment configurations, health checks, env validation, and CI/CD.""" + + def deploy(self, config: DeployConfig) -> DeployResult: + """Generate deployment files for the target platform.""" + generators = { + DeployTarget.DOCKER: self._docker, + DeployTarget.VERCEL: self._vercel, + DeployTarget.NETLIFY: self._netlify, + DeployTarget.GITHUB_PAGES: self._github_pages, + DeployTarget.FLY_IO: self._fly_io, + DeployTarget.RAILWAY: self._railway, + } + gen = generators.get(config.target) + if gen: + result = gen(config) + # Add common production files + result.files_generated.update(self._generate_health_check(config)) + result.files_generated.update(self._generate_env_example(config)) + result.files_generated.update(self._generate_monitoring(config)) + result.files_generated.update(self._generate_ci_cd(config)) + result.notes.extend(self._rollback_instructions(config)) + return result + raise ValueError(f"Unknown target: {config.target}") + + def deploy_all(self, config: DeployConfig) -> Dict[str, DeployResult]: + """Generate configs for all platforms.""" + results = {} + for target in DeployTarget: + cfg = DeployConfig(**{**config.__dict__, "target": target}) + results[target.value] = self.deploy(cfg) + return results + + def write_files(self, result: DeployResult, output_dir: str) -> List[str]: + """Write deployment files to disk.""" + written = [] + for filename, content in result.files_generated.items(): + path = os.path.join(output_dir, filename) + os.makedirs(os.path.dirname(path) or output_dir, exist_ok=True) + with open(path, "w") as f: + f.write(content) + written.append(path) + return written + + def _docker(self, config: DeployConfig) -> DeployResult: + dockerfile = ( + f"# Build stage\n" + f"FROM node:{config.node_version}-alpine AS build\n" + f"WORKDIR /app\n" + f"COPY package*.json ./\n" + f"RUN npm ci\n" + f"COPY . .\n" + f"RUN {config.build_command}\n\n" + f"# Production stage\n" + f"FROM nginx:alpine\n" + f"COPY --from=build /app/{config.output_dir} /usr/share/nginx/html\n" + f"COPY nginx.conf /etc/nginx/conf.d/default.conf\n" + f"EXPOSE {config.port}\n" + f'CMD ["nginx", "-g", "daemon off;"]\n' + ) + + nginx_conf = ( + "server {\n" + f" listen {config.port};\n" + " server_name localhost;\n" + " root /usr/share/nginx/html;\n" + " index index.html;\n\n" + " location / {\n" + " try_files $uri $uri/ /index.html;\n" + " }\n\n" + " location /api {\n" + " proxy_pass http://backend:8000;\n" + " }\n" + "}\n" + ) + + docker_compose = ( + "version: '3.8'\n" + "services:\n" + f" {config.project_name}:\n" + " build: .\n" + f" ports:\n - '{config.port}:{config.port}'\n" + " environment:\n" + + "".join(f" - {k}={v}\n" for k, v in config.env_vars.items()) + + " restart: unless-stopped\n" + ) + + dockerignore = "node_modules\n.git\n.env\ndist\nbuild\n*.md\n" + + return DeployResult( + target="docker", + files_generated={ + "Dockerfile": dockerfile, + "nginx.conf": nginx_conf, + "docker-compose.yml": docker_compose, + ".dockerignore": dockerignore, + }, + commands=[ + f"docker build -t {config.project_name} .", + f"docker run -p {config.port}:{config.port} {config.project_name}", + "# Or with docker-compose:", + "docker-compose up -d", + ], + ) + + def _vercel(self, config: DeployConfig) -> DeployResult: + vercel_json = json.dumps({ + "framework": "vite" if config.framework == "react" else config.framework, + "buildCommand": config.build_command, + "outputDirectory": config.output_dir, + "rewrites": [{"source": "/(.*)", "destination": "/index.html"}], + }, indent=2) + + return DeployResult( + target="vercel", + files_generated={"vercel.json": vercel_json}, + commands=[ + "npm i -g vercel", + "vercel login", + "vercel --prod", + ], + notes=["Vercel auto-detects Vite/React projects", + "Set env vars in Vercel dashboard"], + ) + + def _netlify(self, config: DeployConfig) -> DeployResult: + netlify_toml = ( + "[build]\n" + f' command = "{config.build_command}"\n' + f' publish = "{config.output_dir}"\n\n' + "[[redirects]]\n" + ' from = "/*"\n' + ' to = "/index.html"\n' + " status = 200\n" + ) + + return DeployResult( + target="netlify", + files_generated={"netlify.toml": netlify_toml}, + commands=[ + "npm i -g netlify-cli", + "netlify login", + "netlify deploy --prod", + ], + ) + + def _github_pages(self, config: DeployConfig) -> DeployResult: + workflow = ( + "name: Deploy to GitHub Pages\n" + "on:\n push:\n branches: [main]\n\n" + "permissions:\n contents: read\n pages: write\n id-token: write\n\n" + "jobs:\n" + " build-and-deploy:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f" - uses: actions/setup-node@v4\n with:\n node-version: '{config.node_version}'\n" + " - run: npm ci\n" + f" - run: {config.build_command}\n" + " - uses: actions/upload-pages-artifact@v3\n" + f" with:\n path: ./{config.output_dir}\n" + " - uses: actions/deploy-pages@v4\n" + ) + + return DeployResult( + target="github_pages", + files_generated={".github/workflows/deploy.yml": workflow}, + commands=["git push origin main # triggers auto-deploy"], + notes=["Enable GitHub Pages in repo Settings → Pages → Source: GitHub Actions"], + ) + + def _fly_io(self, config: DeployConfig) -> DeployResult: + fly_toml = ( + f'app = "{config.project_name}"\n' + f'primary_region = "sjc"\n\n' + "[build]\n" + ' dockerfile = "Dockerfile"\n\n' + "[http_service]\n" + f" internal_port = {config.port}\n" + " force_https = true\n" + ' auto_stop_machines = "stop"\n' + ' auto_start_machines = true\n' + ) + + return DeployResult( + target="fly_io", + files_generated={"fly.toml": fly_toml}, + commands=["flyctl launch", "flyctl deploy"], + ) + + def _railway(self, config: DeployConfig) -> DeployResult: + railway_json = json.dumps({ + "build": {"builder": "NIXPACKS"}, + "deploy": {"startCommand": config.start_command}, + }, indent=2) + + return DeployResult( + target="railway", + files_generated={"railway.json": railway_json}, + commands=["railway login", "railway up"], + ) + + # ------------------------------------------------------------------ + # Production additions: health checks, env, monitoring, CI/CD + # ------------------------------------------------------------------ + + def _generate_health_check(self, config: DeployConfig) -> Dict[str, str]: + """Generate health check endpoint files.""" + files: Dict[str, str] = {} + + if config.framework in ("fastapi", "flask", "express"): + # Backend health check + if config.framework == "fastapi": + files["api/health.py"] = ( + 'from fastapi import APIRouter\n' + 'from datetime import datetime\n\n' + 'router = APIRouter()\n\n\n' + '@router.get("/healthz")\n' + 'async def healthz():\n' + ' """Liveness probe — is the process running?"""\n' + ' return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}\n\n\n' + '@router.get("/readyz")\n' + 'async def readyz():\n' + ' """Readiness probe — can the service handle traffic?"""\n' + ' # TODO: add database/cache connectivity checks\n' + ' return {"status": "ready", "timestamp": datetime.utcnow().isoformat()}\n' + ) + else: + files["api/health.js"] = ( + 'const express = require("express");\n' + 'const router = express.Router();\n\n' + 'router.get("/healthz", (req, res) => {\n' + ' res.json({ status: "ok", timestamp: new Date().toISOString() });\n' + '});\n\n' + 'router.get("/readyz", (req, res) => {\n' + ' // TODO: add database/cache connectivity checks\n' + ' res.json({ status: "ready", timestamp: new Date().toISOString() });\n' + '});\n\n' + 'module.exports = router;\n' + ) + else: + # Frontend-only: add a static health page + files["public/healthz.json"] = json.dumps( + {"status": "ok", "version": "1.0.0"}, indent=2 + ) + + return files + + def _generate_env_example(self, config: DeployConfig) -> Dict[str, str]: + """Generate .env.example with documented variables.""" + lines = [ + "# ============================================", + f"# Environment variables for {config.project_name}", + "# Copy this file to .env and fill in values", + "# ============================================", + "", + "# --- Application ---", + f"PORT={config.port}", + 'NODE_ENV=development', + "", + "# --- Database ---", + "DATABASE_URL=postgresql://user:password@localhost:5432/dbname", + "REDIS_URL=redis://localhost:6379", + "", + "# --- Authentication ---", + "JWT_SECRET=change-me-to-a-random-string", + "JWT_EXPIRY=7d", + "", + "# --- External APIs ---", + "# STRIPE_SECRET_KEY=sk_test_...", + "# SENDGRID_API_KEY=SG...", + "", + ] + # Add any project-specific env vars + for key, value in config.env_vars.items(): + lines.append(f"{key}={value}") + + return {".env.example": "\n".join(lines) + "\n"} + + @staticmethod + def validate_env(env_file: str = ".env", + example_file: str = ".env.example") -> Dict[str, Any]: + """Check that all required env vars from .env.example are set.""" + required: List[str] = [] + missing: List[str] = [] + + if os.path.exists(example_file): + with open(example_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key = line.split("=", 1)[0].strip() + required.append(key) + + # Check against actual env file or os.environ + actual_keys: set = set() + if os.path.exists(env_file): + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key = line.split("=", 1)[0].strip() + actual_keys.add(key) + + for key in required: + if key not in actual_keys and key not in os.environ: + missing.append(key) + + return { + "valid": len(missing) == 0, + "required": required, + "missing": missing, + "message": f"{len(missing)} missing env vars" if missing else "All env vars set", + } + + def _generate_monitoring(self, config: DeployConfig) -> Dict[str, str]: + """Generate basic monitoring/metrics configuration.""" + files: Dict[str, str] = {} + + # Structured logging config + if config.framework == "fastapi": + files["api/logging_config.py"] = ( + 'import logging\n' + 'import json\n' + 'from datetime import datetime\n\n\n' + 'class JSONFormatter(logging.Formatter):\n' + ' """Structured JSON log formatter for production."""\n\n' + ' def format(self, record: logging.LogRecord) -> str:\n' + ' log_data = {\n' + ' "timestamp": datetime.utcnow().isoformat(),\n' + ' "level": record.levelname,\n' + ' "message": record.getMessage(),\n' + ' "module": record.module,\n' + ' "function": record.funcName,\n' + ' }\n' + ' if record.exc_info:\n' + ' log_data["exception"] = self.formatException(record.exc_info)\n' + ' return json.dumps(log_data)\n\n\n' + 'def setup_logging(level: str = "INFO") -> None:\n' + ' handler = logging.StreamHandler()\n' + ' handler.setFormatter(JSONFormatter())\n' + ' logging.root.handlers = [handler]\n' + ' logging.root.setLevel(getattr(logging, level))\n' + ) + + # Prometheus metrics endpoint stub + files["docs/monitoring.md"] = ( + f"# Monitoring — {config.project_name}\n\n" + "## Health Checks\n" + "- `GET /healthz` — liveness probe (is the process alive?)\n" + "- `GET /readyz` — readiness probe (can it handle traffic?)\n\n" + "## Metrics\n" + "- Add `prom-client` (Node) or `prometheus-client` (Python) for `/metrics` endpoint\n" + "- Key metrics: request latency, error rate, active connections, queue depth\n\n" + "## Alerting\n" + "- Set up alerts for: 5xx error rate > 1%, p99 latency > 2s, health check failures\n\n" + "## Logging\n" + "- Structured JSON logging enabled by default\n" + "- Log levels: ERROR for failures, WARN for degraded, INFO for requests\n" + ) + + return files + + def _generate_ci_cd(self, config: DeployConfig) -> Dict[str, str]: + """Generate GitHub Actions CI/CD workflow.""" + target = config.target.value + deploy_step = { + "docker": ( + " - name: Build and push Docker image\n" + " run: |\n" + f" docker build -t ${{{{ secrets.REGISTRY }}}}/{config.project_name}:${{{{ github.sha }}}} .\n" + f" docker push ${{{{ secrets.REGISTRY }}}}/{config.project_name}:${{{{ github.sha }}}}\n" + ), + "vercel": ( + " - name: Deploy to Vercel\n" + " run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}\n" + ), + "netlify": ( + " - name: Deploy to Netlify\n" + " run: npx netlify deploy --prod --auth=${{ secrets.NETLIFY_TOKEN }}\n" + ), + "fly_io": ( + " - name: Deploy to Fly.io\n" + " run: flyctl deploy --remote-only\n" + " env:\n" + " FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n" + ), + "railway": ( + " - name: Deploy to Railway\n" + " run: railway up\n" + " env:\n" + " RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}\n" + ), + } + + workflow = ( + f"name: CI/CD — {config.project_name}\n" + "on:\n" + " push:\n" + " branches: [main]\n" + " pull_request:\n" + " branches: [main]\n\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f" - uses: actions/setup-node@v4\n" + f" with:\n node-version: '{config.node_version}'\n" + " - run: npm ci\n" + " - run: npm run lint\n" + " - run: npm test\n" + f" - run: {config.build_command}\n\n" + " deploy:\n" + " needs: test\n" + " if: github.ref == 'refs/heads/main' && github.event_name == 'push'\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f" - uses: actions/setup-node@v4\n" + f" with:\n node-version: '{config.node_version}'\n" + " - run: npm ci\n" + f" - run: {config.build_command}\n" + + deploy_step.get(target, " - run: echo 'Deploy step not configured'\n") + ) + + return {".github/workflows/ci.yml": workflow} + + @staticmethod + def _rollback_instructions(config: DeployConfig) -> List[str]: + """Return platform-specific rollback instructions.""" + instructions = { + DeployTarget.DOCKER: [ + "ROLLBACK: docker pull /: && docker-compose up -d", + ], + DeployTarget.VERCEL: [ + "ROLLBACK: vercel rollback # reverts to previous deployment", + ], + DeployTarget.NETLIFY: [ + "ROLLBACK: Go to Netlify dashboard → Deploys → click previous deploy → Publish", + ], + DeployTarget.GITHUB_PAGES: [ + "ROLLBACK: git revert HEAD && git push # reverts the last deploy commit", + ], + DeployTarget.FLY_IO: [ + "ROLLBACK: flyctl releases list && flyctl deploy --image ", + ], + DeployTarget.RAILWAY: [ + "ROLLBACK: railway rollback # reverts to previous deployment", + ], + } + return instructions.get(config.target, ["ROLLBACK: redeploy previous version"]) diff --git a/eostudio/core/devtools/__init__.py b/eostudio/core/devtools/__init__.py new file mode 100644 index 0000000..e1693ac --- /dev/null +++ b/eostudio/core/devtools/__init__.py @@ -0,0 +1,19 @@ +"""DevTools subpackage — testing, API client, database, containers, CI/CD, profiler, security.""" + +from eostudio.core.devtools.testing import TestRunner, TestResult, TestSuite +from eostudio.core.devtools.api_client import APIClient, APIRequest, APIResponse +from eostudio.core.devtools.database_client import DatabaseClient, DatabaseConfig, QueryResult +from eostudio.core.devtools.containers import ContainerManager, Container, ContainerImage +from eostudio.core.devtools.cicd import PipelineBuilder, Pipeline, PipelineStep +from eostudio.core.devtools.profiler import Profiler, ProfileResult, FlameGraph +from eostudio.core.devtools.security import SecurityScanner, ScanResult, Vulnerability + +__all__ = [ + "TestRunner", "TestResult", "TestSuite", + "APIClient", "APIRequest", "APIResponse", + "DatabaseClient", "DatabaseConfig", "QueryResult", + "ContainerManager", "Container", "ContainerImage", + "PipelineBuilder", "Pipeline", "PipelineStep", + "Profiler", "ProfileResult", "FlameGraph", + "SecurityScanner", "ScanResult", "Vulnerability", +] diff --git a/eostudio/core/devtools/__pycache__/__init__.cpython-38.pyc b/eostudio/core/devtools/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..19cb265 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/api_client.cpython-38.pyc b/eostudio/core/devtools/__pycache__/api_client.cpython-38.pyc new file mode 100644 index 0000000..b5fea63 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/api_client.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/build_system.cpython-38.pyc b/eostudio/core/devtools/__pycache__/build_system.cpython-38.pyc new file mode 100644 index 0000000..d3c03c3 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/build_system.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/cicd.cpython-38.pyc b/eostudio/core/devtools/__pycache__/cicd.cpython-38.pyc new file mode 100644 index 0000000..e7d7af9 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/cicd.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/containers.cpython-38.pyc b/eostudio/core/devtools/__pycache__/containers.cpython-38.pyc new file mode 100644 index 0000000..8e0ab50 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/containers.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/database_client.cpython-38.pyc b/eostudio/core/devtools/__pycache__/database_client.cpython-38.pyc new file mode 100644 index 0000000..f5b276e Binary files /dev/null and b/eostudio/core/devtools/__pycache__/database_client.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/profiler.cpython-38.pyc b/eostudio/core/devtools/__pycache__/profiler.cpython-38.pyc new file mode 100644 index 0000000..1802259 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/profiler.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/remote.cpython-38.pyc b/eostudio/core/devtools/__pycache__/remote.cpython-38.pyc new file mode 100644 index 0000000..97ebe2d Binary files /dev/null and b/eostudio/core/devtools/__pycache__/remote.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/security.cpython-38.pyc b/eostudio/core/devtools/__pycache__/security.cpython-38.pyc new file mode 100644 index 0000000..30b8c70 Binary files /dev/null and b/eostudio/core/devtools/__pycache__/security.cpython-38.pyc differ diff --git a/eostudio/core/devtools/__pycache__/testing.cpython-38.pyc b/eostudio/core/devtools/__pycache__/testing.cpython-38.pyc new file mode 100644 index 0000000..2c181da Binary files /dev/null and b/eostudio/core/devtools/__pycache__/testing.cpython-38.pyc differ diff --git a/eostudio/core/devtools/api_client.py b/eostudio/core/devtools/api_client.py new file mode 100755 index 0000000..e8c6da8 --- /dev/null +++ b/eostudio/core/devtools/api_client.py @@ -0,0 +1,434 @@ +"""REST and GraphQL API client with collection management.""" +from __future__ import annotations + +import enum +import json +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +class HTTPMethod(enum.Enum): + """HTTP request methods.""" + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + + +class AuthType(enum.Enum): + """Authentication types.""" + NONE = "none" + BASIC = "basic" + BEARER = "bearer" + API_KEY = "api_key" + OAUTH2 = "oauth2" + + +@dataclass +class AuthConfig: + """Authentication configuration.""" + auth_type: AuthType = AuthType.NONE + username: str = "" + password: str = "" + token: str = "" + api_key: str = "" + api_key_header: str = "X-API-Key" + oauth2_client_id: str = "" + oauth2_client_secret: str = "" + oauth2_token_url: str = "" + oauth2_scopes: list[str] = field(default_factory=list) + + +@dataclass +class APIRequest: + """Definition of an API request.""" + method: HTTPMethod = HTTPMethod.GET + url: str = "" + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, str] = field(default_factory=dict) + body: Any = None + auth: AuthConfig | None = None + timeout: float = 30.0 + name: str = "" + description: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "method": self.method.value, + "url": self.url, + "headers": self.headers, + "params": self.params, + "body": self.body, + "timeout": self.timeout, + "name": self.name, + } + + +@dataclass +class APIResponse: + """API response data.""" + status_code: int = 0 + headers: dict[str, str] = field(default_factory=dict) + body: Any = None + elapsed_ms: float = 0.0 + size_bytes: int = 0 + + @property + def is_success(self) -> bool: + return 200 <= self.status_code < 300 + + @property + def json(self) -> Any: + if isinstance(self.body, (dict, list)): + return self.body + if isinstance(self.body, str): + return json.loads(self.body) + return None + + def to_dict(self) -> dict[str, Any]: + return { + "status_code": self.status_code, + "headers": self.headers, + "body": self.body, + "elapsed_ms": self.elapsed_ms, + "size_bytes": self.size_bytes, + } + + +@dataclass +class APIEnvironment: + """Named set of variables for an API environment.""" + name: str = "default" + variables: dict[str, str] = field(default_factory=dict) + + def resolve(self, text: str) -> str: + """Replace {{var}} placeholders with environment values.""" + for key, val in self.variables.items(): + text = text.replace("{{" + key + "}}", val) + return text + + +@dataclass +class APICollection: + """Named collection of saved API requests.""" + name: str = "" + description: str = "" + requests: list[APIRequest] = field(default_factory=list) + environments: list[APIEnvironment] = field(default_factory=list) + id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "requests": [r.to_dict() for r in self.requests], + "environments": [{"name": e.name, "variables": e.variables} for e in self.environments], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> APICollection: + col = cls( + name=data.get("name", ""), + description=data.get("description", ""), + id=data.get("id", uuid.uuid4().hex[:12]), + ) + for rd in data.get("requests", []): + col.requests.append( + APIRequest( + method=HTTPMethod(rd.get("method", "GET")), + url=rd.get("url", ""), + headers=rd.get("headers", {}), + params=rd.get("params", {}), + body=rd.get("body"), + timeout=rd.get("timeout", 30.0), + name=rd.get("name", ""), + ) + ) + for ed in data.get("environments", []): + col.environments.append(APIEnvironment(name=ed.get("name", ""), variables=ed.get("variables", {}))) + return col + + +def _get_httpx(): + """Lazy import httpx.""" + try: + import httpx + return httpx + except ImportError: + raise ImportError( + "httpx is required for API client functionality. Install it with: pip install httpx" + ) + + +class APIClient: + """REST and GraphQL API client.""" + + def __init__(self, base_url: str = "") -> None: + self.base_url = base_url.rstrip("/") + self._history: list[tuple[APIRequest, APIResponse]] = [] + self._environment: APIEnvironment | None = None + + def set_environment(self, env: APIEnvironment) -> None: + self._environment = env + + def _resolve(self, text: str) -> str: + if self._environment: + return self._environment.resolve(text) + return text + + def _build_url(self, url: str) -> str: + url = self._resolve(url) + if url.startswith(("http://", "https://")): + return url + return f"{self.base_url}/{url.lstrip('/')}" if self.base_url else url + + def _apply_auth(self, headers: dict[str, str], auth: AuthConfig | None) -> tuple[dict[str, str], Any]: + """Apply auth config to headers. Returns (headers, httpx_auth).""" + httpx_auth = None + if auth is None or auth.auth_type == AuthType.NONE: + return headers, httpx_auth + + httpx = _get_httpx() + + if auth.auth_type == AuthType.BASIC: + httpx_auth = httpx.BasicAuth(username=auth.username, password=auth.password) + elif auth.auth_type == AuthType.BEARER: + headers["Authorization"] = f"Bearer {self._resolve(auth.token)}" + elif auth.auth_type == AuthType.API_KEY: + headers[auth.api_key_header] = self._resolve(auth.api_key) + return headers, httpx_auth + + # ------------------------------------------------------------------ + # Core request + # ------------------------------------------------------------------ + + def request(self, req: APIRequest) -> APIResponse: + """Execute an API request and return the response.""" + httpx = _get_httpx() + + url = self._build_url(req.url) + headers = {k: self._resolve(v) for k, v in req.headers.items()} + params = {k: self._resolve(v) for k, v in req.params.items()} + + headers, auth_obj = self._apply_auth(headers, req.auth) + + body_kwargs: dict[str, Any] = {} + if req.body is not None: + if isinstance(req.body, (dict, list)): + body_kwargs["json"] = req.body + headers.setdefault("Content-Type", "application/json") + elif isinstance(req.body, str): + body_kwargs["content"] = req.body + else: + body_kwargs["content"] = str(req.body) + + start = time.monotonic() + with httpx.Client(timeout=req.timeout) as client: + resp = client.request( + method=req.method.value, + url=url, + headers=headers, + params=params or None, + auth=auth_obj, + **body_kwargs, + ) + elapsed = (time.monotonic() - start) * 1000.0 + + try: + resp_body = resp.json() + except (json.JSONDecodeError, ValueError): + resp_body = resp.text + + api_resp = APIResponse( + status_code=resp.status_code, + headers=dict(resp.headers), + body=resp_body, + elapsed_ms=round(elapsed, 2), + size_bytes=len(resp.content), + ) + self._history.append((req, api_resp)) + return api_resp + + # ------------------------------------------------------------------ + # Convenience methods + # ------------------------------------------------------------------ + + def get(self, url: str, **kwargs: Any) -> APIResponse: + return self.request(APIRequest(method=HTTPMethod.GET, url=url, **kwargs)) + + def post(self, url: str, **kwargs: Any) -> APIResponse: + return self.request(APIRequest(method=HTTPMethod.POST, url=url, **kwargs)) + + def put(self, url: str, **kwargs: Any) -> APIResponse: + return self.request(APIRequest(method=HTTPMethod.PUT, url=url, **kwargs)) + + def patch(self, url: str, **kwargs: Any) -> APIResponse: + return self.request(APIRequest(method=HTTPMethod.PATCH, url=url, **kwargs)) + + def delete(self, url: str, **kwargs: Any) -> APIResponse: + return self.request(APIRequest(method=HTTPMethod.DELETE, url=url, **kwargs)) + + # ------------------------------------------------------------------ + # GraphQL + # ------------------------------------------------------------------ + + def graphql(self, url: str, query: str, variables: dict[str, Any] | None = None, **kwargs: Any) -> APIResponse: + """Execute a GraphQL query.""" + payload: dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + return self.request( + APIRequest(method=HTTPMethod.POST, url=url, body=payload, **kwargs) + ) + + # ------------------------------------------------------------------ + # History + # ------------------------------------------------------------------ + + def get_history(self) -> list[dict[str, Any]]: + return [ + {"request": req.to_dict(), "response": resp.to_dict()} + for req, resp in self._history + ] + + def clear_history(self) -> None: + self._history.clear() + + # ------------------------------------------------------------------ + # Collections + # ------------------------------------------------------------------ + + def save_collection(self, collection: APICollection, path: str) -> None: + """Save a collection to a JSON file.""" + Path(path).write_text(json.dumps(collection.to_dict(), indent=2)) + + def load_collection(self, path: str) -> APICollection: + """Load a collection from a JSON file.""" + data = json.loads(Path(path).read_text()) + return APICollection.from_dict(data) + + # ------------------------------------------------------------------ + # Import + # ------------------------------------------------------------------ + + def import_postman(self, path: str) -> APICollection: + """Import a Postman collection v2.1 JSON file.""" + raw = json.loads(Path(path).read_text()) + info = raw.get("info", {}) + col = APICollection(name=info.get("name", "Imported"), description=info.get("description", "")) + + def _parse_items(items: list[dict]) -> None: + for item in items: + if "item" in item: + _parse_items(item["item"]) + continue + req_data = item.get("request", {}) + method_str = req_data.get("method", "GET") + url_data = req_data.get("url", {}) + if isinstance(url_data, str): + url = url_data + else: + url = url_data.get("raw", "") + + headers: dict[str, str] = {} + for h in req_data.get("header", []): + headers[h.get("key", "")] = h.get("value", "") + + body = None + body_data = req_data.get("body", {}) + if body_data.get("mode") == "raw": + raw_body = body_data.get("raw", "") + try: + body = json.loads(raw_body) + except (json.JSONDecodeError, ValueError): + body = raw_body + + col.requests.append( + APIRequest( + method=HTTPMethod(method_str.upper()), + url=url, + headers=headers, + body=body, + name=item.get("name", ""), + ) + ) + + _parse_items(raw.get("item", [])) + return col + + def import_openapi(self, path: str) -> APICollection: + """Import an OpenAPI 3.x JSON/YAML spec (JSON only for stdlib).""" + raw = json.loads(Path(path).read_text()) + title = raw.get("info", {}).get("title", "OpenAPI Import") + col = APICollection(name=title) + servers = raw.get("servers", []) + base = servers[0].get("url", "") if servers else "" + + for route, methods in raw.get("paths", {}).items(): + for method, details in methods.items(): + if method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"): + continue + col.requests.append( + APIRequest( + method=HTTPMethod(method.upper()), + url=f"{base}{route}", + name=details.get("summary", f"{method.upper()} {route}"), + description=details.get("description", ""), + ) + ) + return col + + # ------------------------------------------------------------------ + # Code generation + # ------------------------------------------------------------------ + + def generate_code(self, req: APIRequest, language: str = "python") -> str: + """Generate sample code for a request in the given language.""" + url = self._build_url(req.url) + if language == "python": + lines = [ + "import httpx", + "", + f'resp = httpx.{req.method.value.lower()}(', + f' "{url}",', + ] + if req.headers: + lines.append(f" headers={json.dumps(req.headers)},") + if req.params: + lines.append(f" params={json.dumps(req.params)},") + if req.body is not None: + lines.append(f" json={json.dumps(req.body)},") + lines.append(f" timeout={req.timeout},") + lines.append(")") + lines.append("print(resp.status_code, resp.json())") + return "\n".join(lines) + + if language == "curl": + parts = [f"curl -X {req.method.value} '{url}'"] + for k, v in req.headers.items(): + parts.append(f" -H '{k}: {v}'") + if req.body is not None: + parts.append(f" -d '{json.dumps(req.body)}'") + return " \\\n".join(parts) + + if language in ("javascript", "js", "typescript", "ts"): + opts: dict[str, Any] = {"method": req.method.value} + if req.headers: + opts["headers"] = req.headers + if req.body is not None: + opts["body"] = "JSON.stringify(body)" + lines = [ + f'const resp = await fetch("{url}", {json.dumps(opts, indent=2)});', + "const data = await resp.json();", + "console.log(data);", + ] + return "\n".join(lines) + + return f"// Code generation for '{language}' is not yet supported." diff --git a/eostudio/core/devtools/build_system.py b/eostudio/core/devtools/build_system.py new file mode 100755 index 0000000..30afa0c --- /dev/null +++ b/eostudio/core/devtools/build_system.py @@ -0,0 +1,588 @@ +"""Universal build system integration for EoStudio devtools.""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable, Dict, List, Optional + + +class BuildSystem(Enum): + """Supported build systems.""" + + NPM = "npm" + YARN = "yarn" + PNPM = "pnpm" + PIP = "pip" + POETRY = "poetry" + UV = "uv" + CARGO = "cargo" + GO = "go" + GRADLE = "gradle" + MAVEN = "maven" + CMAKE = "cmake" + MAKE = "make" + DOTNET = "dotnet" + SWIFT = "swift" + BUCK = "buck" + BAZEL = "bazel" + + +@dataclass +class BuildConfig: + """Configuration for a build system.""" + + system: BuildSystem + build_command: str + test_command: str + clean_command: str + run_command: str + env: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class BuildResult: + """Result of a build operation.""" + + success: bool + output: str + errors: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + duration_ms: int = 0 + artifacts: List[str] = field(default_factory=list) + + +@dataclass +class BuildDiagnostic: + """A single build diagnostic (error/warning).""" + + file: str + line: int + column: int + message: str + severity: str + + +@dataclass +class BuildTask: + """A named build task.""" + + name: str + command: str + description: str + group: str # build, test, clean, run + + +# Detection config: (marker file/dir, BuildSystem) +_DETECTION_ORDER: List[tuple] = [ + ("pnpm-lock.yaml", BuildSystem.PNPM), + ("yarn.lock", BuildSystem.YARN), + ("package-lock.json", BuildSystem.NPM), + ("package.json", BuildSystem.NPM), + ("Cargo.toml", BuildSystem.CARGO), + ("go.mod", BuildSystem.GO), + ("pyproject.toml", None), # special: poetry vs uv vs pip + ("setup.py", BuildSystem.PIP), + ("requirements.txt", BuildSystem.PIP), + ("build.gradle", BuildSystem.GRADLE), + ("build.gradle.kts", BuildSystem.GRADLE), + ("pom.xml", BuildSystem.MAVEN), + ("CMakeLists.txt", BuildSystem.CMAKE), + ("Makefile", BuildSystem.MAKE), + ("*.csproj", BuildSystem.DOTNET), + ("Package.swift", BuildSystem.SWIFT), + ("BUCK", BuildSystem.BUCK), + (".buckconfig", BuildSystem.BUCK), + ("BUILD", BuildSystem.BAZEL), + ("WORKSPACE", BuildSystem.BAZEL), + ("BUILD.bazel", BuildSystem.BAZEL), + ("WORKSPACE.bazel", BuildSystem.BAZEL), +] + +_DEFAULT_CONFIGS: Dict[BuildSystem, BuildConfig] = { + BuildSystem.NPM: BuildConfig( + system=BuildSystem.NPM, + build_command="npm run build", + test_command="npm test", + clean_command="rm -rf node_modules dist build", + run_command="npm start", + ), + BuildSystem.YARN: BuildConfig( + system=BuildSystem.YARN, + build_command="yarn build", + test_command="yarn test", + clean_command="rm -rf node_modules dist build", + run_command="yarn start", + ), + BuildSystem.PNPM: BuildConfig( + system=BuildSystem.PNPM, + build_command="pnpm build", + test_command="pnpm test", + clean_command="rm -rf node_modules dist build", + run_command="pnpm start", + ), + BuildSystem.PIP: BuildConfig( + system=BuildSystem.PIP, + build_command="python -m build", + test_command="python -m pytest", + clean_command="rm -rf build dist *.egg-info __pycache__", + run_command="python -m main", + ), + BuildSystem.POETRY: BuildConfig( + system=BuildSystem.POETRY, + build_command="poetry build", + test_command="poetry run pytest", + clean_command="rm -rf dist", + run_command="poetry run python -m main", + ), + BuildSystem.UV: BuildConfig( + system=BuildSystem.UV, + build_command="uv build", + test_command="uv run pytest", + clean_command="rm -rf dist", + run_command="uv run python -m main", + ), + BuildSystem.CARGO: BuildConfig( + system=BuildSystem.CARGO, + build_command="cargo build", + test_command="cargo test", + clean_command="cargo clean", + run_command="cargo run", + ), + BuildSystem.GO: BuildConfig( + system=BuildSystem.GO, + build_command="go build ./...", + test_command="go test ./...", + clean_command="go clean", + run_command="go run .", + ), + BuildSystem.GRADLE: BuildConfig( + system=BuildSystem.GRADLE, + build_command="./gradlew build", + test_command="./gradlew test", + clean_command="./gradlew clean", + run_command="./gradlew run", + ), + BuildSystem.MAVEN: BuildConfig( + system=BuildSystem.MAVEN, + build_command="mvn package", + test_command="mvn test", + clean_command="mvn clean", + run_command="mvn exec:java", + ), + BuildSystem.CMAKE: BuildConfig( + system=BuildSystem.CMAKE, + build_command="cmake --build build", + test_command="ctest --test-dir build", + clean_command="rm -rf build", + run_command="./build/main", + ), + BuildSystem.MAKE: BuildConfig( + system=BuildSystem.MAKE, + build_command="make", + test_command="make test", + clean_command="make clean", + run_command="make run", + ), + BuildSystem.DOTNET: BuildConfig( + system=BuildSystem.DOTNET, + build_command="dotnet build", + test_command="dotnet test", + clean_command="dotnet clean", + run_command="dotnet run", + ), + BuildSystem.SWIFT: BuildConfig( + system=BuildSystem.SWIFT, + build_command="swift build", + test_command="swift test", + clean_command="swift package clean", + run_command="swift run", + ), + BuildSystem.BUCK: BuildConfig( + system=BuildSystem.BUCK, + build_command="buck build //...", + test_command="buck test //...", + clean_command="buck clean", + run_command="buck run //:main", + ), + BuildSystem.BAZEL: BuildConfig( + system=BuildSystem.BAZEL, + build_command="bazel build //...", + test_command="bazel test //...", + clean_command="bazel clean", + run_command="bazel run //:main", + ), +} + +# Patterns for parsing build errors from common compilers/tools +_ERROR_PATTERNS = [ + # GCC / Clang: file.c:10:5: error: message + re.compile(r"^(?P[^:\s]+):(?P\d+):(?P\d+):\s*(?Perror|warning|note):\s*(?P.+)$"), + # Python: File "file.py", line 10 + re.compile(r'^ File "(?P[^"]+)", line (?P\d+)'), + # Rust: error[E0308]: file.rs:10:5 + re.compile(r"^(?Perror|warning)\[.*\]:\s*(?P.+)$"), + # TypeScript / ESLint: file.ts(10,5): error TS1234: message + re.compile(r"^(?P[^(\s]+)\((?P\d+),(?P\d+)\):\s*(?Perror|warning)\s+\w+:\s*(?P.+)$"), + # Java / Gradle: file.java:10: error: message + re.compile(r"^(?P[^:\s]+\.java):(?P\d+):\s*(?Perror|warning):\s*(?P.+)$"), + # Go: file.go:10:5: message + re.compile(r"^(?P[^:\s]+\.go):(?P\d+):(?P\d+):\s*(?P.+)$"), + # .NET: file.cs(10,5): error CS1234: message + re.compile(r"^(?P[^(\s]+\.cs)\((?P\d+),(?P\d+)\):\s*(?Perror|warning)\s+\w+:\s*(?P.+)$"), +] + + +class BuildSystemManager: + """Manages build system detection and operations.""" + + def __init__(self, workspace_path: str = ".") -> None: + self.workspace_path = Path(workspace_path).resolve() + self._detected: Optional[BuildSystem] = None + + def detect(self) -> BuildSystem: + """Auto-detect the build system from config files in the workspace.""" + if self._detected is not None: + return self._detected + + for marker, system in _DETECTION_ORDER: + if "*" in marker: + # Glob pattern (e.g., *.csproj) + if list(self.workspace_path.glob(marker)): + self._detected = system + return system + elif (self.workspace_path / marker).exists(): + if system is not None: + self._detected = system + return system + # Special case: pyproject.toml — check for poetry or uv + if marker == "pyproject.toml": + self._detected = self._detect_python_build_system() + return self._detected + + # Fallback: if Makefile-like files exist + if (self.workspace_path / "GNUmakefile").exists(): + self._detected = BuildSystem.MAKE + return self._detected + + self._detected = BuildSystem.MAKE + return self._detected + + def _detect_python_build_system(self) -> BuildSystem: + """Determine which Python build tool to use from pyproject.toml.""" + pyproject = self.workspace_path / "pyproject.toml" + try: + content = pyproject.read_text(errors="replace") + if "[tool.poetry]" in content: + return BuildSystem.POETRY + if "[tool.uv]" in content or "uv.lock" in os.listdir(self.workspace_path): + return BuildSystem.UV + except OSError: + pass + return BuildSystem.PIP + + def get_config(self) -> BuildConfig: + """Get build configuration for the detected build system.""" + system = self.detect() + config = _DEFAULT_CONFIGS.get(system) + if config is None: + return BuildConfig( + system=system, + build_command="make", + test_command="make test", + clean_command="make clean", + run_command="make run", + ) + return BuildConfig( + system=config.system, + build_command=config.build_command, + test_command=config.test_command, + clean_command=config.clean_command, + run_command=config.run_command, + env=dict(config.env), + ) + + def _run(self, command: str, env_extra: Optional[Dict[str, str]] = None) -> BuildResult: + """Execute a build command and capture the result.""" + start = time.monotonic_ns() + env = os.environ.copy() + config = self.get_config() + env.update(config.env) + if env_extra: + env.update(env_extra) + + try: + proc = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=600, + cwd=str(self.workspace_path), + env=env, + ) + except subprocess.TimeoutExpired: + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return BuildResult( + success=False, + output="", + errors=["Build timed out after 600 seconds"], + duration_ms=elapsed, + ) + except FileNotFoundError as e: + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return BuildResult( + success=False, + output="", + errors=[f"Command not found: {e}"], + duration_ms=elapsed, + ) + + elapsed = (time.monotonic_ns() - start) // 1_000_000 + combined = proc.stdout + proc.stderr + errors: List[str] = [] + warnings: List[str] = [] + + for output_line in combined.splitlines(): + lower = output_line.lower() + if "error" in lower: + errors.append(output_line.strip()) + elif "warning" in lower or "warn" in lower: + warnings.append(output_line.strip()) + + # Detect artifacts + artifacts: List[str] = [] + artifact_dirs = ["dist", "build", "target", "out", "bin"] + for adir in artifact_dirs: + artifact_path = self.workspace_path / adir + if artifact_path.exists() and artifact_path.is_dir(): + for item in artifact_path.iterdir(): + if item.is_file(): + artifacts.append(str(item.relative_to(self.workspace_path))) + + return BuildResult( + success=proc.returncode == 0, + output=combined, + errors=errors if proc.returncode != 0 else [], + warnings=warnings, + duration_ms=elapsed, + artifacts=artifacts, + ) + + def build(self, target: Optional[str] = None) -> BuildResult: + """Run the build command.""" + config = self.get_config() + cmd = config.build_command + if target: + cmd = f"{cmd} {target}" + return self._run(cmd) + + def test(self, target: Optional[str] = None) -> BuildResult: + """Run tests.""" + config = self.get_config() + cmd = config.test_command + if target: + cmd = f"{cmd} {target}" + return self._run(cmd) + + def clean(self) -> BuildResult: + """Clean build artifacts.""" + config = self.get_config() + return self._run(config.clean_command) + + def run(self, target: Optional[str] = None) -> BuildResult: + """Run the project.""" + config = self.get_config() + cmd = config.run_command + if target: + cmd = f"{cmd} {target}" + return self._run(cmd) + + def install_deps(self) -> BuildResult: + """Install project dependencies.""" + system = self.detect() + install_commands = { + BuildSystem.NPM: "npm install", + BuildSystem.YARN: "yarn install", + BuildSystem.PNPM: "pnpm install", + BuildSystem.PIP: "pip install -r requirements.txt", + BuildSystem.POETRY: "poetry install", + BuildSystem.UV: "uv sync", + BuildSystem.CARGO: "cargo fetch", + BuildSystem.GO: "go mod download", + BuildSystem.GRADLE: "./gradlew dependencies", + BuildSystem.MAVEN: "mvn dependency:resolve", + BuildSystem.DOTNET: "dotnet restore", + BuildSystem.SWIFT: "swift package resolve", + } + cmd = install_commands.get(system, "echo 'No install command for this build system'") + return self._run(cmd) + + def get_tasks(self) -> List[BuildTask]: + """Get available tasks from build configuration files.""" + tasks: List[BuildTask] = [] + system = self.detect() + + # Always add standard lifecycle tasks + config = self.get_config() + tasks.extend([ + BuildTask(name="build", command=config.build_command, description="Build the project", group="build"), + BuildTask(name="test", command=config.test_command, description="Run tests", group="test"), + BuildTask(name="clean", command=config.clean_command, description="Clean artifacts", group="clean"), + BuildTask(name="run", command=config.run_command, description="Run the project", group="run"), + ]) + + # Add system-specific tasks + if system in (BuildSystem.NPM, BuildSystem.YARN, BuildSystem.PNPM): + for name, script_cmd in self.get_scripts_from_package_json().items(): + prefix = {BuildSystem.NPM: "npm run", BuildSystem.YARN: "yarn", BuildSystem.PNPM: "pnpm"}[system] + group = "test" if "test" in name else "build" if "build" in name else "run" + tasks.append(BuildTask( + name=name, + command=f"{prefix} {name}", + description=f"npm script: {script_cmd}", + group=group, + )) + + if system == BuildSystem.MAKE: + for target_name in self.get_targets_from_makefile(): + group = "test" if "test" in target_name else "clean" if "clean" in target_name else "build" + tasks.append(BuildTask( + name=target_name, + command=f"make {target_name}", + description=f"Makefile target: {target_name}", + group=group, + )) + + return tasks + + def run_task(self, task_name: str) -> BuildResult: + """Run a specific named task.""" + for t in self.get_tasks(): + if t.name == task_name: + return self._run(t.command) + return BuildResult( + success=False, + output="", + errors=[f"Task '{task_name}' not found"], + ) + + def parse_errors(self, output: str) -> List[BuildDiagnostic]: + """Parse build output into structured diagnostics.""" + diagnostics: List[BuildDiagnostic] = [] + seen = set() + + for output_line in output.splitlines(): + stripped = output_line.strip() + if not stripped: + continue + for pattern in _ERROR_PATTERNS: + match = pattern.match(stripped) + if match: + groups = match.groupdict() + file_path = groups.get("file", "") + line_num = int(groups.get("line", 0)) + col = int(groups.get("col", 0)) + msg = groups.get("msg", stripped) + severity = groups.get("sev", "error") + + key = (file_path, line_num, col, msg) + if key not in seen: + seen.add(key) + diagnostics.append(BuildDiagnostic( + file=file_path, + line=line_num, + column=col, + message=msg, + severity=severity, + )) + break + + return diagnostics + + def watch(self, callback: Callable) -> None: + """Watch for file changes and trigger rebuilds. + + This uses a simple polling approach. For production use, + consider using watchdog or inotify-based watchers. + """ + import hashlib + + file_hashes: Dict[str, str] = {} + + def _hash_file(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return "" + + # Build initial snapshot + watch_extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".c", ".cpp", ".h"} + for root, dirs, files in os.walk(self.workspace_path): + dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "__pycache__", ".venv", "dist", "build", "target"}] + for fname in files: + fpath = Path(root) / fname + if fpath.suffix in watch_extensions: + file_hashes[str(fpath)] = _hash_file(fpath) + + try: + while True: + time.sleep(1) + changed = False + for root, dirs, files in os.walk(self.workspace_path): + dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "__pycache__", ".venv", "dist", "build", "target"}] + for fname in files: + fpath = Path(root) / fname + if fpath.suffix not in watch_extensions: + continue + key = str(fpath) + new_hash = _hash_file(fpath) + old_hash = file_hashes.get(key) + if old_hash != new_hash: + file_hashes[key] = new_hash + changed = True + + if changed: + result = self.build() + callback(result) + except KeyboardInterrupt: + pass + + def get_scripts_from_package_json(self) -> Dict[str, str]: + """Parse and return npm scripts from package.json.""" + pkg_path = self.workspace_path / "package.json" + if not pkg_path.exists(): + return {} + try: + data = json.loads(pkg_path.read_text(errors="replace")) + scripts = data.get("scripts", {}) + return {k: v for k, v in scripts.items() if isinstance(v, str)} + except (json.JSONDecodeError, OSError): + return {} + + def get_targets_from_makefile(self) -> List[str]: + """Parse and return targets from a Makefile.""" + targets: List[str] = [] + makefile_names = ["Makefile", "makefile", "GNUmakefile"] + + for mf_name in makefile_names: + mf_path = self.workspace_path / mf_name + if not mf_path.exists(): + continue + try: + content = mf_path.read_text(errors="replace") + # Match lines like "target_name:" at the start of a line, excluding special targets + for match in re.finditer(r"^([a-zA-Z_][a-zA-Z0-9_.-]*):\s*", content, re.MULTILINE): + target = match.group(1) + if not target.startswith("."): + targets.append(target) + except OSError: + continue + break # only parse the first found makefile + + return targets diff --git a/eostudio/core/devtools/cicd.py b/eostudio/core/devtools/cicd.py new file mode 100755 index 0000000..cb7afe0 --- /dev/null +++ b/eostudio/core/devtools/cicd.py @@ -0,0 +1,485 @@ +"""CI/CD pipeline builder and monitor.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional + + +class CIProvider(Enum): + """Supported CI/CD providers.""" + GITHUB_ACTIONS = "github_actions" + GITLAB_CI = "gitlab_ci" + JENKINS = "jenkins" + CIRCLECI = "circleci" + AZURE_PIPELINES = "azure_pipelines" + + +@dataclass +class PipelineStep: + """A single step within a pipeline stage.""" + name: str + command: str + image: Optional[str] = None + env: Dict[str, str] = field(default_factory=dict) + condition: Optional[str] = None + timeout: int = 0 + artifacts: List[str] = field(default_factory=list) + cache: List[str] = field(default_factory=list) + + +@dataclass +class PipelineStage: + """A stage containing one or more pipeline steps.""" + name: str + steps: List[PipelineStep] = field(default_factory=list) + depends_on: List[str] = field(default_factory=list) + parallel: bool = False + + +@dataclass +class Pipeline: + """A complete CI/CD pipeline definition.""" + name: str + stages: List[PipelineStage] = field(default_factory=list) + triggers: Dict[str, object] = field(default_factory=dict) + env: Dict[str, str] = field(default_factory=dict) + provider: CIProvider = CIProvider.GITHUB_ACTIONS + + +@dataclass +class PipelineTemplate: + """A reusable pipeline template.""" + name: str + description: str + provider: CIProvider + pipeline: Pipeline + + +class PipelineBuilder: + """Builds CI/CD pipeline configurations for various providers.""" + + def __init__(self, provider: CIProvider = CIProvider.GITHUB_ACTIONS) -> None: + self.provider = provider + self._stages: Dict[str, PipelineStage] = {} + self._triggers: Dict[str, object] = {} + self._env: Dict[str, str] = {} + self._name: str = "pipeline" + + def add_stage(self, name: str) -> PipelineStage: + """Add a new stage to the pipeline.""" + stage = PipelineStage(name=name) + self._stages[name] = stage + return stage + + def add_step(self, stage: str, step: PipelineStep) -> None: + """Add a step to an existing stage.""" + if stage not in self._stages: + self.add_stage(stage) + self._stages[stage].steps.append(step) + + def set_trigger(self, event: str, branches: Optional[List[str]] = None) -> None: + """Set a trigger event for the pipeline.""" + if branches: + self._triggers[event] = {"branches": branches} + else: + self._triggers[event] = {} + + def build(self) -> Pipeline: + """Build and return the pipeline object.""" + return Pipeline( + name=self._name, + stages=list(self._stages.values()), + triggers=dict(self._triggers), + env=dict(self._env), + provider=self.provider, + ) + + def validate(self) -> List[str]: + """Validate the pipeline configuration. Returns a list of errors.""" + errors: List[str] = [] + if not self._stages: + errors.append("Pipeline has no stages defined") + for stage_name, stage in self._stages.items(): + if not stage.steps: + errors.append(f"Stage '{stage_name}' has no steps") + for step in stage.steps: + if not step.command: + errors.append( + f"Step '{step.name}' in stage '{stage_name}' has no command" + ) + for dep in stage.depends_on: + if dep not in self._stages: + errors.append( + f"Stage '{stage_name}' depends on unknown stage '{dep}'" + ) + if not self._triggers: + errors.append("Pipeline has no triggers defined") + return errors + + def to_yaml(self) -> str: + """Generate YAML configuration for the selected provider.""" + pipeline = self.build() + if self.provider == CIProvider.GITHUB_ACTIONS: + return self._to_github_actions(pipeline) + elif self.provider == CIProvider.GITLAB_CI: + return self._to_gitlab_ci(pipeline) + else: + return self._to_github_actions(pipeline) + + def _to_github_actions(self, pipeline: Pipeline) -> str: + """Generate GitHub Actions workflow YAML.""" + lines: List[str] = [] + lines.append(f"name: {pipeline.name}") + lines.append("") + + # Triggers + if pipeline.triggers: + lines.append("on:") + for event, config in pipeline.triggers.items(): + if isinstance(config, dict) and config: + lines.append(f" {event}:") + if "branches" in config: + lines.append(" branches:") + for branch in config["branches"]: + lines.append(f" - {branch}") + else: + lines.append(f" {event}:") + lines.append("") + + # Environment + if pipeline.env: + lines.append("env:") + for key, value in pipeline.env.items(): + lines.append(f" {key}: {value}") + lines.append("") + + # Jobs + lines.append("jobs:") + for stage in pipeline.stages: + job_id = stage.name.replace(" ", "-").replace("/", "-").lower() + lines.append(f" {job_id}:") + lines.append(f" name: {stage.name}") + lines.append(" runs-on: ubuntu-latest") + + if stage.depends_on: + needs = [ + d.replace(" ", "-").replace("/", "-").lower() + for d in stage.depends_on + ] + needs_str = ", ".join(needs) + lines.append(f" needs: [{needs_str}]") + + lines.append(" steps:") + lines.append(" - uses: actions/checkout@v4") + + for step in stage.steps: + lines.append(f" - name: {step.name}") + if step.condition: + lines.append(f" if: {step.condition}") + if step.timeout: + lines.append(f" timeout-minutes: {step.timeout}") + lines.append(f" run: {step.command}") + if step.env: + lines.append(" env:") + for ek, ev in step.env.items(): + lines.append(f" {ek}: {ev}") + + # Cache + cache_paths: List[str] = [] + for step in stage.steps: + cache_paths.extend(step.cache) + if cache_paths: + lines.append(" - uses: actions/cache@v3") + lines.append(" with:") + lines.append(" path: |") + for cp in cache_paths: + lines.append(f" {cp}") + lines.append( + " key: ${{ runner.os }}-cache-${{ hashFiles('**/*') }}" + ) + + # Artifacts + artifact_paths: List[str] = [] + for step in stage.steps: + artifact_paths.extend(step.artifacts) + if artifact_paths: + lines.append(" - uses: actions/upload-artifact@v3") + lines.append(" with:") + lines.append(f" name: {job_id}-artifacts") + lines.append(" path: |") + for ap in artifact_paths: + lines.append(f" {ap}") + + lines.append("") + + return "\n".join(lines) + + def _to_gitlab_ci(self, pipeline: Pipeline) -> str: + """Generate GitLab CI YAML.""" + lines: List[str] = [] + + # Stages declaration + lines.append("stages:") + for stage in pipeline.stages: + lines.append(f" - {stage.name}") + lines.append("") + + # Variables + if pipeline.env: + lines.append("variables:") + for key, value in pipeline.env.items(): + lines.append(f" {key}: {value}") + lines.append("") + + # Jobs + for stage in pipeline.stages: + for step in stage.steps: + job_name = ( + f"{stage.name}:{step.name}" + .replace(" ", "_") + .replace("/", "_") + .lower() + ) + lines.append(f"{job_name}:") + lines.append(f" stage: {stage.name}") + + if step.image: + lines.append(f" image: {step.image}") + + lines.append(" script:") + for cmd_line in step.command.split("\n"): + lines.append(f" - {cmd_line.strip()}") + + if step.env: + lines.append(" variables:") + for ek, ev in step.env.items(): + lines.append(f" {ek}: {ev}") + + if stage.depends_on: + lines.append(" needs:") + for dep in stage.depends_on: + lines.append(f" - {dep}") + + if step.condition: + lines.append(" rules:") + lines.append(f" - if: {step.condition}") + + if step.timeout: + lines.append(f" timeout: {step.timeout}m") + + if step.artifacts: + lines.append(" artifacts:") + lines.append(" paths:") + for ap in step.artifacts: + lines.append(f" - {ap}") + + if step.cache: + lines.append(" cache:") + lines.append(" paths:") + for cp in step.cache: + lines.append(f" - {cp}") + + lines.append("") + + return "\n".join(lines) + + def from_yaml(self, yaml_str: str) -> Pipeline: + """Parse a YAML string into a Pipeline object. + + Simplified parser for basic key-value YAML structures. + For production use, consider a full YAML library. + """ + pipeline = Pipeline( + name="imported", + provider=self.provider, + ) + + lines = yaml_str.strip().splitlines() + for line in lines: + stripped = line.strip() + if stripped.startswith("name:"): + pipeline.name = stripped.split(":", 1)[1].strip() + break + + return pipeline + + def save(self, path: str) -> None: + """Write the pipeline configuration to a file.""" + yaml_content = self.to_yaml() + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w") as f: + f.write(yaml_content) + + def load(self, path: str) -> Pipeline: + """Load a pipeline configuration from a file.""" + with open(path, "r") as f: + content = f.read() + return self.from_yaml(content) + + def get_templates(self) -> List[PipelineTemplate]: + """Return built-in pipeline templates.""" + templates: List[PipelineTemplate] = [] + + # Python CI template + py_builder = PipelineBuilder(CIProvider.GITHUB_ACTIONS) + py_builder._name = "Python CI" + py_builder.set_trigger("push", ["main"]) + py_builder.set_trigger("pull_request", ["main"]) + lint_stage = py_builder.add_stage("lint") + lint_stage.steps.append(PipelineStep( + name="Lint", + command="pip install flake8 && flake8 .", + )) + test_stage = py_builder.add_stage("test") + test_stage.steps.append(PipelineStep( + name="Test", + command="pip install -r requirements.txt && pytest", + cache=["~/.cache/pip"], + )) + templates.append(PipelineTemplate( + name="python-ci", + description="Python CI with linting and testing", + provider=CIProvider.GITHUB_ACTIONS, + pipeline=py_builder.build(), + )) + + # Node.js CI template + node_builder = PipelineBuilder(CIProvider.GITHUB_ACTIONS) + node_builder._name = "Node.js CI" + node_builder.set_trigger("push", ["main"]) + node_builder.set_trigger("pull_request", ["main"]) + build_stage = node_builder.add_stage("build") + build_stage.steps.append(PipelineStep( + name="Install & Build", + command="npm ci && npm run build", + cache=["node_modules"], + )) + test_stage_node = node_builder.add_stage("test") + test_stage_node.depends_on = ["build"] + test_stage_node.steps.append(PipelineStep( + name="Test", + command="npm test", + )) + templates.append(PipelineTemplate( + name="node-ci", + description="Node.js CI with build and test", + provider=CIProvider.GITHUB_ACTIONS, + pipeline=node_builder.build(), + )) + + # Docker build & push template + docker_builder = PipelineBuilder(CIProvider.GITHUB_ACTIONS) + docker_builder._name = "Docker Build & Push" + docker_builder.set_trigger("push", ["main"]) + docker_stage = docker_builder.add_stage("docker") + docker_stage.steps.append(PipelineStep( + name="Build and Push", + command="docker build -t $IMAGE_NAME:$GITHUB_SHA . && docker push $IMAGE_NAME:$GITHUB_SHA", + )) + templates.append(PipelineTemplate( + name="docker-build", + description="Docker build and push pipeline", + provider=CIProvider.GITHUB_ACTIONS, + pipeline=docker_builder.build(), + )) + + return templates + + def create_from_template(self, template_name: str) -> Pipeline: + """Create a pipeline from a built-in template name.""" + for template in self.get_templates(): + if template.name == template_name: + self._name = template.pipeline.name + self._stages = {s.name: s for s in template.pipeline.stages} + self._triggers = dict(template.pipeline.triggers) + self._env = dict(template.pipeline.env) + return template.pipeline + raise ValueError(f"Unknown template: {template_name}") + + +class BuildMonitor: + """Monitors CI/CD build status across providers.""" + + def _lazy_get(self, url: str, headers: Optional[Dict[str, str]] = None) -> Dict: + """Make an HTTP GET request using httpx (lazily imported).""" + try: + import httpx + except ImportError: + return {"error": "httpx not installed"} + try: + resp = httpx.get(url, headers=headers or {}, timeout=30) + resp.raise_for_status() + return resp.json() + except Exception as e: + return {"error": str(e)} + + def get_status(self, provider: CIProvider, repo: str) -> Dict: + """Check the latest build status for a repository.""" + if provider == CIProvider.GITHUB_ACTIONS: + url = f"https://api.github.com/repos/{repo}/actions/runs?per_page=1" + data = self._lazy_get(url) + if "error" in data: + return data + runs = data.get("workflow_runs", []) + if not runs: + return {"status": "no_runs"} + run = runs[0] + return { + "id": run.get("id"), + "status": run.get("status"), + "conclusion": run.get("conclusion"), + "branch": run.get("head_branch"), + "commit": run.get("head_sha", "")[:8], + "url": run.get("html_url"), + "created_at": run.get("created_at"), + } + elif provider == CIProvider.GITLAB_CI: + encoded_repo = repo.replace("/", "%2F") + url = f"https://gitlab.com/api/v4/projects/{encoded_repo}/pipelines?per_page=1" + data = self._lazy_get(url) + if isinstance(data, dict) and "error" in data: + return data + if isinstance(data, list) and data: + pipe = data[0] + return { + "id": pipe.get("id"), + "status": pipe.get("status"), + "ref": pipe.get("ref"), + "url": pipe.get("web_url"), + "created_at": pipe.get("created_at"), + } + return {"status": "no_pipelines"} + return {"error": f"Unsupported provider: {provider.value}"} + + def get_logs(self, provider: CIProvider, repo: str, build_id: str) -> str: + """Get build logs for a specific build run.""" + if provider == CIProvider.GITHUB_ACTIONS: + url = f"https://api.github.com/repos/{repo}/actions/runs/{build_id}/logs" + try: + import httpx + except ImportError: + return "httpx not installed" + try: + resp = httpx.get(url, timeout=30, follow_redirects=True) + return resp.text + except Exception as e: + return f"Error fetching logs: {e}" + elif provider == CIProvider.GITLAB_CI: + encoded_repo = repo.replace("/", "%2F") + url = f"https://gitlab.com/api/v4/projects/{encoded_repo}/pipelines/{build_id}/jobs" + data = self._lazy_get(url) + if isinstance(data, dict) and "error" in data: + return data["error"] + if isinstance(data, list): + log_lines: List[str] = [] + for job in data: + log_lines.append( + f"--- {job.get('name', 'unknown')} " + f"({job.get('status', '')}) ---" + ) + return "\n".join(log_lines) + return "No logs available" + return f"Unsupported provider: {provider.value}" diff --git a/eostudio/core/devtools/containers.py b/eostudio/core/devtools/containers.py new file mode 100644 index 0000000..9171cfc --- /dev/null +++ b/eostudio/core/devtools/containers.py @@ -0,0 +1,407 @@ +"""Container management for Docker and Kubernetes.""" +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional + + +class ContainerState(Enum): + """Docker container states.""" + CREATED = "created" + RUNNING = "running" + PAUSED = "paused" + STOPPED = "stopped" + EXITED = "exited" + DEAD = "dead" + + +@dataclass +class Container: + """Represents a Docker container.""" + id: str + name: str + image: str + state: ContainerState + ports: Dict[str, str] = field(default_factory=dict) + created: str = "" + labels: Dict[str, str] = field(default_factory=dict) + command: str = "" + + +@dataclass +class ContainerImage: + """Represents a Docker image.""" + id: str + tags: List[str] = field(default_factory=list) + size: int = 0 + created: str = "" + layers: int = 0 + + +@dataclass +class ContainerStats: + """Container resource usage statistics.""" + cpu_percent: float = 0.0 + memory_usage: int = 0 + memory_limit: int = 0 + network_rx: int = 0 + network_tx: int = 0 + pids: int = 0 + + +class ContainerManager: + """Manages Docker containers via the docker CLI.""" + + def __init__(self, docker_host: str = "unix:///var/run/docker.sock") -> None: + self.docker_host = docker_host + self._env = {"DOCKER_HOST": docker_host} + + def _run(self, args: List[str], capture: bool = True) -> subprocess.CompletedProcess: + """Execute a docker CLI command.""" + cmd = ["docker"] + args + return subprocess.run( + cmd, + capture_output=capture, + text=True, + env={**self._env}, + ) + + def is_docker_available(self) -> bool: + """Check if Docker CLI is available and responsive.""" + try: + result = self._run(["info", "--format", "json"]) + return result.returncode == 0 + except FileNotFoundError: + return False + + def list_containers(self, all: bool = False) -> List[Container]: + """List Docker containers, optionally including stopped ones.""" + args = ["ps", "--format", "json", "--no-trunc"] + if all: + args.append("-a") + result = self._run(args) + if result.returncode != 0: + return [] + + containers: List[Container] = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + state_str = data.get("State", "created").lower() + try: + state = ContainerState(state_str) + except ValueError: + state = ContainerState.CREATED + + ports_raw = data.get("Ports", "") + ports: Dict[str, str] = {} + if ports_raw: + for mapping in ports_raw.split(", "): + parts = mapping.split("->") + if len(parts) == 2: + ports[parts[0].strip()] = parts[1].strip() + + labels_raw = data.get("Labels", "") + labels: Dict[str, str] = {} + if labels_raw: + for lbl in labels_raw.split(","): + kv = lbl.split("=", 1) + if len(kv) == 2: + labels[kv[0].strip()] = kv[1].strip() + + containers.append(Container( + id=data.get("ID", ""), + name=data.get("Names", ""), + image=data.get("Image", ""), + state=state, + ports=ports, + created=data.get("CreatedAt", ""), + labels=labels, + command=data.get("Command", ""), + )) + return containers + + def start(self, container_id: str) -> bool: + """Start a stopped container.""" + result = self._run(["start", container_id]) + return result.returncode == 0 + + def stop(self, container_id: str) -> bool: + """Stop a running container.""" + result = self._run(["stop", container_id]) + return result.returncode == 0 + + def restart(self, container_id: str) -> bool: + """Restart a container.""" + result = self._run(["restart", container_id]) + return result.returncode == 0 + + def remove(self, container_id: str) -> bool: + """Remove a container.""" + result = self._run(["rm", container_id]) + return result.returncode == 0 + + def build(self, path: str, tag: str, dockerfile: str = "Dockerfile") -> bool: + """Build a Docker image from a Dockerfile.""" + result = self._run(["build", "-t", tag, "-f", dockerfile, path]) + return result.returncode == 0 + + def push(self, image: str) -> bool: + """Push an image to a registry.""" + result = self._run(["push", image]) + return result.returncode == 0 + + def pull(self, image: str) -> bool: + """Pull an image from a registry.""" + result = self._run(["pull", image]) + return result.returncode == 0 + + def logs(self, container_id: str, tail: int = 100, follow: bool = False) -> str: + """Retrieve container logs.""" + args = ["logs", "--tail", str(tail)] + if follow: + args.append("--follow") + args.append(container_id) + result = self._run(args) + if result.returncode != 0: + return result.stderr + return result.stdout + + def exec_command(self, container_id: str, command: str) -> str: + """Execute a command inside a running container.""" + result = self._run(["exec", container_id, "sh", "-c", command]) + if result.returncode != 0: + return result.stderr + return result.stdout + + def list_images(self) -> List[ContainerImage]: + """List local Docker images.""" + result = self._run(["images", "--format", "json", "--no-trunc"]) + if result.returncode != 0: + return [] + + images: List[ContainerImage] = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + tag_str = data.get("Tag", "") + repo = data.get("Repository", "") + tag = f"{repo}:{tag_str}" if repo and tag_str else repo or tag_str + + size_str = data.get("Size", "0") + size = 0 + try: + if size_str.endswith("GB"): + size = int(float(size_str[:-2]) * 1024 * 1024 * 1024) + elif size_str.endswith("MB"): + size = int(float(size_str[:-2]) * 1024 * 1024) + elif size_str.endswith("KB") or size_str.endswith("kB"): + size = int(float(size_str[:-2]) * 1024) + else: + size = int(float(size_str.rstrip("B"))) + except (ValueError, IndexError): + size = 0 + + images.append(ContainerImage( + id=data.get("ID", ""), + tags=[tag] if tag else [], + size=size, + created=data.get("CreatedAt", data.get("CreatedSince", "")), + )) + return images + + def stats(self, container_id: str) -> ContainerStats: + """Get resource usage statistics for a container.""" + result = self._run([ + "stats", container_id, "--no-stream", + "--format", "json", + ]) + if result.returncode != 0: + return ContainerStats() + + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + cpu_str = data.get("CPUPerc", "0%").rstrip("%") + try: + cpu = float(cpu_str) + except ValueError: + cpu = 0.0 + + pids_str = data.get("PIDs", "0") + try: + pids = int(pids_str) + except ValueError: + pids = 0 + + return ContainerStats( + cpu_percent=cpu, + memory_usage=0, + memory_limit=0, + network_rx=0, + network_tx=0, + pids=pids, + ) + return ContainerStats() + + def inspect(self, container_id: str) -> Dict: + """Inspect a container and return full details.""" + result = self._run(["inspect", container_id]) + if result.returncode != 0: + return {} + try: + data = json.loads(result.stdout) + if isinstance(data, list) and data: + return data[0] + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + return {} + + def compose_up(self, path: str) -> str: + """Run docker compose up in the given directory.""" + result = subprocess.run( + ["docker", "compose", "up", "-d"], + capture_output=True, + text=True, + cwd=path, + env={**self._env}, + ) + return result.stdout if result.returncode == 0 else result.stderr + + def compose_down(self, path: str) -> str: + """Run docker compose down in the given directory.""" + result = subprocess.run( + ["docker", "compose", "down"], + capture_output=True, + text=True, + cwd=path, + env={**self._env}, + ) + return result.stdout if result.returncode == 0 else result.stderr + + def compose_logs(self, path: str) -> str: + """Get logs from docker compose services.""" + result = subprocess.run( + ["docker", "compose", "logs", "--tail", "100"], + capture_output=True, + text=True, + cwd=path, + env={**self._env}, + ) + return result.stdout if result.returncode == 0 else result.stderr + + def list_networks(self) -> List[Dict]: + """List Docker networks.""" + result = self._run(["network", "ls", "--format", "json"]) + if result.returncode != 0: + return [] + networks: List[Dict] = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + networks.append(json.loads(line)) + except json.JSONDecodeError: + continue + return networks + + def list_volumes(self) -> List[Dict]: + """List Docker volumes.""" + result = self._run(["volume", "ls", "--format", "json"]) + if result.returncode != 0: + return [] + volumes: List[Dict] = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + try: + volumes.append(json.loads(line)) + except json.JSONDecodeError: + continue + return volumes + + +class KubernetesManager: + """Manages Kubernetes resources via kubectl CLI.""" + + def _run(self, args: List[str]) -> subprocess.CompletedProcess: + """Execute a kubectl command.""" + cmd = ["kubectl"] + args + return subprocess.run(cmd, capture_output=True, text=True) + + def is_kubectl_available(self) -> bool: + """Check if kubectl is available and configured.""" + try: + result = self._run(["version", "--client", "-o", "json"]) + return result.returncode == 0 + except FileNotFoundError: + return False + + def _get_resources(self, resource: str, namespace: str = "default") -> List[Dict]: + """Generic resource listing.""" + result = self._run(["get", resource, "-n", namespace, "-o", "json"]) + if result.returncode != 0: + return [] + try: + data = json.loads(result.stdout) + return data.get("items", []) + except json.JSONDecodeError: + return [] + + def get_pods(self, namespace: str = "default") -> List[Dict]: + """List pods in a namespace.""" + return self._get_resources("pods", namespace) + + def get_services(self, namespace: str = "default") -> List[Dict]: + """List services in a namespace.""" + return self._get_resources("services", namespace) + + def get_deployments(self, namespace: str = "default") -> List[Dict]: + """List deployments in a namespace.""" + return self._get_resources("deployments", namespace) + + def get_logs(self, pod: str, namespace: str = "default", tail: int = 100) -> str: + """Get logs from a pod.""" + result = self._run(["logs", pod, "-n", namespace, "--tail", str(tail)]) + if result.returncode != 0: + return result.stderr + return result.stdout + + def apply(self, manifest_path: str) -> bool: + """Apply a Kubernetes manifest file.""" + result = self._run(["apply", "-f", manifest_path]) + return result.returncode == 0 + + def delete(self, resource_type: str, name: str, namespace: str = "default") -> bool: + """Delete a Kubernetes resource.""" + result = self._run(["delete", resource_type, name, "-n", namespace]) + return result.returncode == 0 + + def port_forward( + self, pod: str, local_port: int, remote_port: int, namespace: str = "default" + ) -> subprocess.Popen: + """Start port forwarding to a pod. Returns the background process.""" + cmd = [ + "kubectl", "port-forward", pod, + f"{local_port}:{remote_port}", + "-n", namespace, + ] + return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) diff --git a/eostudio/core/devtools/database_client.py b/eostudio/core/devtools/database_client.py new file mode 100755 index 0000000..62b0c42 --- /dev/null +++ b/eostudio/core/devtools/database_client.py @@ -0,0 +1,631 @@ +"""Multi-database client with schema introspection and query management.""" +from __future__ import annotations + +import csv +import enum +import io +import json +import sqlite3 +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +class DatabaseType(enum.Enum): + """Supported database backends.""" + SQLITE = "sqlite" + POSTGRESQL = "postgresql" + MYSQL = "mysql" + MONGODB = "mongodb" + REDIS = "redis" + DYNAMODB = "dynamodb" + + +@dataclass +class DatabaseConfig: + """Connection configuration for a database.""" + db_type: DatabaseType = DatabaseType.SQLITE + host: str = "localhost" + port: int = 0 + database: str = "" + username: str = "" + password: str = "" + options: dict[str, Any] = field(default_factory=dict) + + @property + def effective_port(self) -> int: + if self.port: + return self.port + defaults = { + DatabaseType.SQLITE: 0, + DatabaseType.POSTGRESQL: 5432, + DatabaseType.MYSQL: 3306, + DatabaseType.MONGODB: 27017, + DatabaseType.REDIS: 6379, + DatabaseType.DYNAMODB: 8000, + } + return defaults.get(self.db_type, 0) + + def to_dict(self) -> dict[str, Any]: + return { + "db_type": self.db_type.value, + "host": self.host, + "port": self.effective_port, + "database": self.database, + "username": self.username, + "options": self.options, + } + + +@dataclass +class ColumnInfo: + """Metadata about a table column.""" + name: str = "" + data_type: str = "" + nullable: bool = True + primary_key: bool = False + default: Any = None + max_length: int | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "data_type": self.data_type, + "nullable": self.nullable, + "primary_key": self.primary_key, + "default": self.default, + "max_length": self.max_length, + } + + +@dataclass +class TableInfo: + """Metadata about a database table.""" + name: str = "" + columns: list[ColumnInfo] = field(default_factory=list) + row_count: int | None = None + size_bytes: int | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "columns": [c.to_dict() for c in self.columns], + "row_count": self.row_count, + "size_bytes": self.size_bytes, + } + + +@dataclass +class QueryResult: + """Result of a database query.""" + columns: list[str] = field(default_factory=list) + rows: list[list[Any]] = field(default_factory=list) + affected_rows: int = 0 + elapsed_ms: float = 0.0 + query: str = "" + error: str = "" + + @property + def row_count(self) -> int: + return len(self.rows) + + @property + def is_error(self) -> bool: + return bool(self.error) + + def to_dicts(self) -> list[dict[str, Any]]: + """Return rows as a list of dictionaries.""" + return [dict(zip(self.columns, row)) for row in self.rows] + + def to_dict(self) -> dict[str, Any]: + return { + "columns": self.columns, + "rows": self.rows, + "affected_rows": self.affected_rows, + "elapsed_ms": self.elapsed_ms, + "row_count": self.row_count, + "query": self.query, + "error": self.error, + } + + +@dataclass +class _SavedQuery: + id: str = "" + name: str = "" + query: str = "" + db_type: str = "" + description: str = "" + + +class DatabaseClient: + """Multi-database client with introspection and history.""" + + def __init__(self, config: DatabaseConfig) -> None: + self.config = config + self._conn: Any = None + self._history: list[QueryResult] = [] + self._saved_queries: list[_SavedQuery] = [] + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Open a connection to the configured database.""" + if self._conn is not None: + return + + db = self.config.db_type + + if db == DatabaseType.SQLITE: + self._conn = sqlite3.connect(self.config.database or ":memory:") + self._conn.row_factory = None + return + + if db == DatabaseType.POSTGRESQL: + psycopg2 = _lazy_import("psycopg2", "psycopg2-binary") + self._conn = psycopg2.connect( + host=self.config.host, + port=self.config.effective_port, + dbname=self.config.database, + user=self.config.username, + password=self.config.password, + **self.config.options, + ) + return + + if db == DatabaseType.MYSQL: + mysql = _lazy_import("mysql.connector", "mysql-connector-python") + self._conn = mysql.connect( + host=self.config.host, + port=self.config.effective_port, + database=self.config.database, + user=self.config.username, + password=self.config.password, + **self.config.options, + ) + return + + if db == DatabaseType.MONGODB: + pymongo = _lazy_import("pymongo", "pymongo") + client = pymongo.MongoClient( + host=self.config.host, + port=self.config.effective_port, + username=self.config.username or None, + password=self.config.password or None, + **self.config.options, + ) + self._conn = client[self.config.database or "test"] + return + + if db == DatabaseType.REDIS: + redis = _lazy_import("redis", "redis") + self._conn = redis.Redis( + host=self.config.host, + port=self.config.effective_port, + password=self.config.password or None, + db=int(self.config.database or 0), + decode_responses=True, + **self.config.options, + ) + return + + if db == DatabaseType.DYNAMODB: + boto3 = _lazy_import("boto3", "boto3") + kwargs: dict[str, Any] = {"region_name": self.config.options.get("region", "us-east-1")} + if self.config.host not in ("localhost", ""): + kwargs["endpoint_url"] = f"http://{self.config.host}:{self.config.effective_port}" + self._conn = boto3.resource("dynamodb", **kwargs) + return + + raise ValueError(f"Unsupported database type: {db}") + + def disconnect(self) -> None: + """Close the current connection.""" + if self._conn is None: + return + db = self.config.db_type + if db in (DatabaseType.SQLITE, DatabaseType.POSTGRESQL, DatabaseType.MYSQL): + self._conn.close() + elif db == DatabaseType.MONGODB: + self._conn.client.close() + elif db == DatabaseType.REDIS: + self._conn.close() + self._conn = None + + def is_connected(self) -> bool: + """Check whether the client holds an open connection.""" + if self._conn is None: + return False + db = self.config.db_type + if db == DatabaseType.SQLITE: + try: + self._conn.execute("SELECT 1") + return True + except Exception: + return False + if db == DatabaseType.POSTGRESQL: + return not getattr(self._conn, "closed", True) + if db == DatabaseType.MYSQL: + return getattr(self._conn, "is_connected", lambda: False)() + if db == DatabaseType.REDIS: + try: + self._conn.ping() + return True + except Exception: + return False + return self._conn is not None + + def __enter__(self) -> DatabaseClient: + self.connect() + return self + + def __exit__(self, *exc: Any) -> None: + self.disconnect() + + # ------------------------------------------------------------------ + # Query execution + # ------------------------------------------------------------------ + + def execute(self, query: str, params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None) -> QueryResult: + """Execute a query/command and return results.""" + if self._conn is None: + raise RuntimeError("Not connected. Call connect() first.") + + db = self.config.db_type + start = time.monotonic() + result = QueryResult(query=query) + + try: + if db in (DatabaseType.SQLITE, DatabaseType.POSTGRESQL, DatabaseType.MYSQL): + result = self._exec_sql(query, params) + elif db == DatabaseType.MONGODB: + result = self._exec_mongo(query) + elif db == DatabaseType.REDIS: + result = self._exec_redis(query) + elif db == DatabaseType.DYNAMODB: + result = self._exec_dynamo(query) + except Exception as exc: + result.error = str(exc) + + result.elapsed_ms = round((time.monotonic() - start) * 1000.0, 2) + result.query = query + self._history.append(result) + return result + + def _exec_sql(self, query: str, params: Any) -> QueryResult: + cursor = self._conn.cursor() + if params: + cursor.execute(query, params) + else: + cursor.execute(query) + result = QueryResult() + if cursor.description: + result.columns = [desc[0] for desc in cursor.description] + result.rows = [list(row) for row in cursor.fetchall()] + result.affected_rows = cursor.rowcount if cursor.rowcount >= 0 else 0 + if self.config.db_type != DatabaseType.SQLITE: + self._conn.commit() + return result + + def _exec_mongo(self, query: str) -> QueryResult: + """Execute a simplified MongoDB command as JSON string.""" + cmd = json.loads(query) + collection_name = cmd.get("collection", "") + operation = cmd.get("operation", "find") + filter_doc = cmd.get("filter", {}) + projection = cmd.get("projection") + data = cmd.get("data") + + col = self._conn[collection_name] + result = QueryResult() + + if operation == "find": + cursor = col.find(filter_doc, projection) + docs = list(cursor) + if docs: + result.columns = list(docs[0].keys()) + result.rows = [[doc.get(k) for k in result.columns] for doc in docs] + elif operation == "insert_one": + col.insert_one(data or {}) + result.affected_rows = 1 + elif operation == "insert_many": + res = col.insert_many(data or []) + result.affected_rows = len(res.inserted_ids) + elif operation == "update": + update = cmd.get("update", {}) + res = col.update_many(filter_doc, update) + result.affected_rows = res.modified_count + elif operation == "delete": + res = col.delete_many(filter_doc) + result.affected_rows = res.deleted_count + elif operation == "count": + cnt = col.count_documents(filter_doc) + result.columns = ["count"] + result.rows = [[cnt]] + return result + + def _exec_redis(self, query: str) -> QueryResult: + """Execute a Redis command expressed as space-separated tokens.""" + parts = query.strip().split() + if not parts: + return QueryResult(error="Empty command") + cmd = parts[0].upper() + args = parts[1:] + raw = self._conn.execute_command(cmd, *args) + result = QueryResult(columns=["result"]) + if isinstance(raw, list): + result.rows = [[item] for item in raw] + else: + result.rows = [[raw]] + return result + + def _exec_dynamo(self, query: str) -> QueryResult: + """Execute a DynamoDB operation expressed as JSON.""" + cmd = json.loads(query) + table_name = cmd.get("table", "") + operation = cmd.get("operation", "scan") + table = self._conn.Table(table_name) + result = QueryResult() + + if operation == "scan": + resp = table.scan(**cmd.get("params", {})) + items = resp.get("Items", []) + if items: + result.columns = list(items[0].keys()) + result.rows = [[item.get(k) for k in result.columns] for item in items] + elif operation == "get_item": + resp = table.get_item(Key=cmd.get("key", {})) + item = resp.get("Item") + if item: + result.columns = list(item.keys()) + result.rows = [[item.get(k) for k in result.columns]] + elif operation == "put_item": + table.put_item(Item=cmd.get("item", {})) + result.affected_rows = 1 + elif operation == "delete_item": + table.delete_item(Key=cmd.get("key", {})) + result.affected_rows = 1 + elif operation == "query": + resp = table.query(**cmd.get("params", {})) + items = resp.get("Items", []) + if items: + result.columns = list(items[0].keys()) + result.rows = [[item.get(k) for k in result.columns] for item in items] + return result + + # ------------------------------------------------------------------ + # Schema introspection + # ------------------------------------------------------------------ + + def get_tables(self) -> list[str]: + """List all table/collection names.""" + db = self.config.db_type + if db == DatabaseType.SQLITE: + res = self.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + return [row[0] for row in res.rows] + if db == DatabaseType.POSTGRESQL: + res = self.execute( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public' ORDER BY table_name" + ) + return [row[0] for row in res.rows] + if db == DatabaseType.MYSQL: + res = self.execute("SHOW TABLES") + return [row[0] for row in res.rows] + if db == DatabaseType.MONGODB: + return sorted(self._conn.list_collection_names()) + if db == DatabaseType.REDIS: + keys = self._conn.keys("*") + return sorted(keys) if keys else [] + if db == DatabaseType.DYNAMODB: + client = self._conn.meta.client + resp = client.list_tables() + return resp.get("TableNames", []) + return [] + + def get_table_info(self, table: str) -> TableInfo: + """Get detailed metadata for a table.""" + db = self.config.db_type + info = TableInfo(name=table) + + if db == DatabaseType.SQLITE: + res = self.execute(f"PRAGMA table_info('{table}')") + for row in res.rows: + info.columns.append( + ColumnInfo( + name=row[1], + data_type=row[2], + nullable=not bool(row[3]), + primary_key=bool(row[5]), + default=row[4], + ) + ) + cnt = self.execute(f"SELECT COUNT(*) FROM '{table}'") + if cnt.rows: + info.row_count = cnt.rows[0][0] + + elif db == DatabaseType.POSTGRESQL: + res = self.execute( + "SELECT column_name, data_type, is_nullable, column_default, character_maximum_length " + "FROM information_schema.columns WHERE table_name = %s ORDER BY ordinal_position", + (table,), + ) + pk_res = self.execute( + "SELECT a.attname FROM pg_index i " + "JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) " + "WHERE i.indrelid = %s::regclass AND i.indisprimary", + (table,), + ) + pk_cols = {row[0] for row in pk_res.rows} + for row in res.rows: + info.columns.append( + ColumnInfo( + name=row[0], + data_type=row[1], + nullable=row[2] == "YES", + primary_key=row[0] in pk_cols, + default=row[3], + max_length=row[4], + ) + ) + cnt = self.execute(f'SELECT COUNT(*) FROM "{table}"') + if cnt.rows: + info.row_count = cnt.rows[0][0] + + elif db == DatabaseType.MYSQL: + res = self.execute(f"DESCRIBE `{table}`") + for row in res.rows: + info.columns.append( + ColumnInfo( + name=row[0], + data_type=row[1], + nullable=row[2] == "YES", + primary_key=row[3] == "PRI", + default=row[4], + ) + ) + cnt = self.execute(f"SELECT COUNT(*) FROM `{table}`") + if cnt.rows: + info.row_count = cnt.rows[0][0] + + elif db == DatabaseType.MONGODB: + sample = self._conn[table].find_one() + if sample: + for key, val in sample.items(): + info.columns.append(ColumnInfo(name=key, data_type=type(val).__name__)) + info.row_count = self._conn[table].estimated_document_count() + + return info + + def get_schema(self) -> list[TableInfo]: + """Get schema information for all tables.""" + return [self.get_table_info(t) for t in self.get_tables()] + + # ------------------------------------------------------------------ + # Export / Import + # ------------------------------------------------------------------ + + def export_results(self, result: QueryResult, fmt: str = "csv") -> str: + """Export query results to a string in the given format (csv, json, sql).""" + if fmt == "json": + return json.dumps(result.to_dicts(), indent=2, default=str) + + if fmt == "csv": + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(result.columns) + writer.writerows(result.rows) + return buf.getvalue() + + if fmt == "sql": + if not result.columns or not result.rows: + return "" + lines: list[str] = [] + table = "exported_data" + for row in result.rows: + vals = ", ".join( + f"'{v}'" if isinstance(v, str) else "NULL" if v is None else str(v) + for v in row + ) + cols = ", ".join(result.columns) + lines.append(f"INSERT INTO {table} ({cols}) VALUES ({vals});") + return "\n".join(lines) + + raise ValueError(f"Unsupported format: {fmt}") + + def import_data(self, table: str, path: str, fmt: str = "csv") -> int: + """Import data from a file into a table. Returns number of rows imported.""" + content = Path(path).read_text() + + if fmt == "csv": + reader = csv.reader(io.StringIO(content)) + headers = next(reader, None) + if not headers: + return 0 + count = 0 + for row in reader: + placeholders = ", ".join(["?" if self.config.db_type == DatabaseType.SQLITE else "%s"] * len(row)) + cols = ", ".join(headers) + self.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders})", tuple(row)) + count += 1 + if self.config.db_type == DatabaseType.SQLITE: + self._conn.commit() + return count + + if fmt == "json": + records = json.loads(content) + if not isinstance(records, list): + records = [records] + count = 0 + for rec in records: + keys = list(rec.keys()) + vals = [rec[k] for k in keys] + placeholders = ", ".join(["?" if self.config.db_type == DatabaseType.SQLITE else "%s"] * len(keys)) + cols = ", ".join(keys) + self.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders})", tuple(vals)) + count += 1 + if self.config.db_type == DatabaseType.SQLITE: + self._conn.commit() + return count + + raise ValueError(f"Unsupported import format: {fmt}") + + # ------------------------------------------------------------------ + # History & saved queries + # ------------------------------------------------------------------ + + def get_history(self) -> list[dict[str, Any]]: + """Return all past query results.""" + return [r.to_dict() for r in self._history] + + def clear_history(self) -> None: + self._history.clear() + + def save_query(self, name: str, query: str, description: str = "") -> str: + """Save a query for later use. Returns the saved query ID.""" + qid = uuid.uuid4().hex[:12] + self._saved_queries.append( + _SavedQuery( + id=qid, + name=name, + query=query, + db_type=self.config.db_type.value, + description=description, + ) + ) + return qid + + def get_saved_queries(self) -> list[dict[str, str]]: + """List all saved queries.""" + return [ + {"id": sq.id, "name": sq.name, "query": sq.query, "db_type": sq.db_type, "description": sq.description} + for sq in self._saved_queries + ] + + def delete_saved_query(self, query_id: str) -> bool: + """Delete a saved query by ID.""" + for i, sq in enumerate(self._saved_queries): + if sq.id == query_id: + self._saved_queries.pop(i) + return True + return False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _lazy_import(module_name: str, pip_name: str) -> Any: + """Import a module, raising a helpful error if it's missing.""" + import importlib + try: + return importlib.import_module(module_name) + except ImportError: + raise ImportError( + f"{module_name} is required for this database backend. " + f"Install it with: pip install {pip_name}" + ) diff --git a/eostudio/core/devtools/profiler.py b/eostudio/core/devtools/profiler.py new file mode 100755 index 0000000..3cfd503 --- /dev/null +++ b/eostudio/core/devtools/profiler.py @@ -0,0 +1,470 @@ +"""Performance profiling tools for Python and Node.js.""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional + + +class ProfileType(Enum): + """Types of profiling.""" + CPU = "cpu" + MEMORY = "memory" + NETWORK = "network" + BUNDLE = "bundle" + + +@dataclass +class ProfileSample: + """A single profile sample entry.""" + function: str + file: str + line: int + time_ms: float + calls: int + cumulative_ms: float + + +@dataclass +class ProfileResult: + """Complete profiling result.""" + type: ProfileType + samples: List[ProfileSample] = field(default_factory=list) + total_time_ms: float = 0.0 + peak_memory_mb: float = 0.0 + timestamp: str = "" + + +@dataclass +class FlameGraphNode: + """A node in a flame graph tree.""" + name: str + value: float + children: List[FlameGraphNode] = field(default_factory=list) + + +@dataclass +class FlameGraph: + """A flame graph representation of profiling data.""" + root: FlameGraphNode = field(default_factory=lambda: FlameGraphNode("root", 0)) + total_samples: int = 0 + + +class Profiler: + """Profiler for Python and Node.js applications.""" + + def __init__(self, workspace_path: str = ".") -> None: + self.workspace_path = os.path.abspath(workspace_path) + self._history: List[ProfileResult] = [] + + def profile_python( + self, script: str, args: Optional[List[str]] = None + ) -> ProfileResult: + """Profile a Python script using cProfile via subprocess.""" + stats_file = os.path.join( + tempfile.gettempdir(), + f"eostudio_profile_{os.getpid()}.prof", + ) + cmd = [ + "python", "-m", "cProfile", + "-o", stats_file, + script, + ] + if args: + cmd.extend(args) + + subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.workspace_path, + ) + + result = self.parse_cprofile_output(stats_file) + + # Clean up temp file + try: + os.unlink(stats_file) + except OSError: + pass + + self._history.append(result) + return result + + def profile_memory_python(self, script: str) -> ProfileResult: + """Profile memory usage of a Python script using tracemalloc.""" + wrapper = ( + "import tracemalloc, runpy, json, sys;" + "tracemalloc.start();" + f"runpy.run_path({script!r});" + "snapshot = tracemalloc.take_snapshot();" + "stats = snapshot.statistics('lineno');" + "entries = [];" + "for s in stats[:50]:" + " entries.append({" + " 'file': str(s.traceback[0].filename) if s.traceback else ''," + " 'line': s.traceback[0].lineno if s.traceback else 0," + " 'size': s.size," + " 'count': s.count" + " });" + "peak = tracemalloc.get_traced_memory()[1];" + "print(json.dumps({'entries': entries, 'peak': peak}))" + ) + cmd = ["python", "-c", wrapper] + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.workspace_path, + ) + + result = ProfileResult( + type=ProfileType.MEMORY, + timestamp=datetime.now().isoformat(), + ) + + if proc.returncode == 0 and proc.stdout.strip(): + try: + data = json.loads(proc.stdout.strip().splitlines()[-1]) + result.peak_memory_mb = data.get("peak", 0) / (1024 * 1024) + for entry in data.get("entries", []): + result.samples.append(ProfileSample( + function="", + file=entry.get("file", ""), + line=entry.get("line", 0), + time_ms=0.0, + calls=entry.get("count", 0), + cumulative_ms=entry.get("size", 0) / 1024, # KB + )) + except (json.JSONDecodeError, IndexError): + pass + + self._history.append(result) + return result + + def profile_node(self, script: str) -> ProfileResult: + """Profile a Node.js script using --prof flag.""" + cmd = ["node", "--prof", script] + subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.workspace_path, + ) + + result = ProfileResult( + type=ProfileType.CPU, + timestamp=datetime.now().isoformat(), + ) + + log_files = [ + f for f in os.listdir(self.workspace_path) + if f.startswith("isolate-") and f.endswith(".log") + ] + if not log_files: + self._history.append(result) + return result + + log_file = os.path.join(self.workspace_path, sorted(log_files)[-1]) + + # Process the V8 log + proc_cmd = ["node", "--prof-process", "--preprocess", log_file] + proc = subprocess.run( + proc_cmd, + capture_output=True, + text=True, + cwd=self.workspace_path, + ) + + if proc.returncode == 0 and proc.stdout.strip(): + try: + v8_data = json.loads(proc.stdout) + ticks = v8_data.get("ticks", []) + result.total_time_ms = len(ticks) * 1.0 # approximate + except json.JSONDecodeError: + pass + + # Clean up log file + try: + os.unlink(log_file) + except OSError: + pass + + self._history.append(result) + return result + + def parse_cprofile_output(self, stats_file: str) -> ProfileResult: + """Parse a cProfile binary stats file into a ProfileResult.""" + result = ProfileResult( + type=ProfileType.CPU, + timestamp=datetime.now().isoformat(), + ) + + # Use pstats via subprocess to dump stats as text + dump_script = ( + "import pstats, sys;" + f"s = pstats.Stats({stats_file!r});" + "s.sort_stats('cumulative');" + "s.print_stats(50)" + ) + proc = subprocess.run( + ["python", "-c", dump_script], + capture_output=True, + text=True, + ) + + if proc.returncode != 0: + return result + + output = proc.stdout + # Parse the pstats text output + # Format: ncalls tottime percall cumtime percall filename:lineno(function) + in_stats = False + for raw_line in output.splitlines(): + line = raw_line.strip() + if "ncalls" in line and "tottime" in line and "cumtime" in line: + in_stats = True + continue + if not in_stats or not line: + continue + + # Match stat lines + match = re.match( + r"(\d+(?:/\d+)?)\s+" # ncalls + r"([\d.]+)\s+" # tottime + r"([\d.]+)\s+" # percall + r"([\d.]+)\s+" # cumtime + r"([\d.]+)\s+" # percall + r"(.+)", # filename:lineno(function) + line, + ) + if not match: + continue + + ncalls_str = match.group(1) + tottime = float(match.group(2)) + cumtime = float(match.group(4)) + location = match.group(6) + + # Parse ncalls (handles "3/1" recursive format) + if "/" in ncalls_str: + ncalls = int(ncalls_str.split("/")[0]) + else: + ncalls = int(ncalls_str) + + # Parse location: filename:lineno(function) + loc_match = re.match(r"(.+):(\d+)\((.+)\)", location) + if loc_match: + file_path = loc_match.group(1) + line_no = int(loc_match.group(2)) + func_name = loc_match.group(3) + else: + file_path = "" + line_no = 0 + func_name = location + + result.samples.append(ProfileSample( + function=func_name, + file=file_path, + line=line_no, + time_ms=tottime * 1000, + calls=ncalls, + cumulative_ms=cumtime * 1000, + )) + + if result.samples: + result.total_time_ms = sum(s.time_ms for s in result.samples) + + return result + + def generate_flame_graph(self, result: ProfileResult) -> FlameGraph: + """Generate a flame graph from profiling results.""" + root = FlameGraphNode(name="root", value=result.total_time_ms) + graph = FlameGraph(root=root, total_samples=len(result.samples)) + + # Group samples by file to create hierarchy + file_groups: Dict[str, List[ProfileSample]] = {} + for sample in result.samples: + key = sample.file or "" + if key not in file_groups: + file_groups[key] = [] + file_groups[key].append(sample) + + for file_path, samples in file_groups.items(): + file_node = FlameGraphNode( + name=os.path.basename(file_path) if file_path else "", + value=sum(s.time_ms for s in samples), + ) + for sample in samples: + func_node = FlameGraphNode( + name=f"{sample.function} (line {sample.line})", + value=sample.time_ms, + ) + file_node.children.append(func_node) + root.children.append(file_node) + + return graph + + def export_flame_graph_html(self, graph: FlameGraph, output: str) -> None: + """Export a flame graph as an interactive HTML file.""" + + def node_to_dict(node: FlameGraphNode) -> Dict: + return { + "name": node.name, + "value": round(node.value, 2), + "children": [node_to_dict(c) for c in node.children], + } + + data_json = json.dumps(node_to_dict(graph.root), indent=2) + + html = """ + + + +Flame Graph - EoStudio Profiler + + + +

EoStudio Flame Graph

+
Total samples: """ + str(graph.total_samples) + """
+
+
+ + +""" + + os.makedirs(os.path.dirname(output) or ".", exist_ok=True) + with open(output, "w") as f: + f.write(html) + + def get_history(self) -> List[ProfileResult]: + """Return the history of profiling results from this session.""" + return list(self._history) + + def compare( + self, result1: ProfileResult, result2: ProfileResult + ) -> Dict: + """Compare two profiling results and return a diff summary.""" + comparison: Dict = { + "total_time_diff_ms": result2.total_time_ms - result1.total_time_ms, + "total_time_pct_change": ( + ((result2.total_time_ms - result1.total_time_ms) / result1.total_time_ms * 100) + if result1.total_time_ms > 0 else 0.0 + ), + "peak_memory_diff_mb": result2.peak_memory_mb - result1.peak_memory_mb, + "sample_count_diff": len(result2.samples) - len(result1.samples), + "faster": [], + "slower": [], + "new": [], + "removed": [], + } + + funcs1 = {s.function: s for s in result1.samples} + funcs2 = {s.function: s for s in result2.samples} + + for name, s2 in funcs2.items(): + if name in funcs1: + s1 = funcs1[name] + diff = s2.time_ms - s1.time_ms + if diff < -0.1: + comparison["faster"].append({ + "function": name, + "before_ms": round(s1.time_ms, 2), + "after_ms": round(s2.time_ms, 2), + "diff_ms": round(diff, 2), + }) + elif diff > 0.1: + comparison["slower"].append({ + "function": name, + "before_ms": round(s1.time_ms, 2), + "after_ms": round(s2.time_ms, 2), + "diff_ms": round(diff, 2), + }) + else: + comparison["new"].append({ + "function": name, + "time_ms": round(s2.time_ms, 2), + }) + + for name in funcs1: + if name not in funcs2: + comparison["removed"].append({ + "function": name, + "time_ms": round(funcs1[name].time_ms, 2), + }) + + return comparison diff --git a/eostudio/core/devtools/remote.py b/eostudio/core/devtools/remote.py new file mode 100755 index 0000000..ef6c3ec --- /dev/null +++ b/eostudio/core/devtools/remote.py @@ -0,0 +1,313 @@ +""" +EoStudio Remote Development — SSH, WSL, Container, and DevContainer support. + +Phase 3: Cross-Platform Universal Support. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Enums & Config +# --------------------------------------------------------------------------- + +class RemoteType(Enum): + """Supported remote development connection types.""" + + SSH = auto() + WSL = auto() + CONTAINER = auto() + DEVCONTAINER = auto() + + +@dataclass +class RemoteConfig: + """Configuration for a remote connection.""" + + type: RemoteType + host: str = "" + port: int = 22 + username: str = "" + key_path: str = "" + password: str = "" + container_id: str = "" + wsl_distro: str = "Ubuntu" + + +@dataclass +class DevContainerConfig: + """Represents a ``.devcontainer/devcontainer.json`` configuration.""" + + name: str = "" + image: str = "" + build_dockerfile: str = "" + forward_ports: List[int] = field(default_factory=list) + post_create_command: str = "" + extensions: List[str] = field(default_factory=list) + settings: Dict[str, object] = field(default_factory=dict) + remote_user: str = "vscode" + + def to_dict(self) -> dict: + """Serialise to a dict suitable for JSON output.""" + cfg: dict = {"name": self.name} + if self.image: + cfg["image"] = self.image + if self.build_dockerfile: + cfg["build"] = {"dockerfile": self.build_dockerfile} + if self.forward_ports: + cfg["forwardPorts"] = self.forward_ports + if self.post_create_command: + cfg["postCreateCommand"] = self.post_create_command + if self.extensions: + cfg["customizations"] = {"vscode": {"extensions": self.extensions}} + if self.settings: + cfg.setdefault("customizations", {}).setdefault("vscode", {})["settings"] = self.settings + if self.remote_user: + cfg["remoteUser"] = self.remote_user + return cfg + + +# --------------------------------------------------------------------------- +# RemoteConnection +# --------------------------------------------------------------------------- + +class RemoteConnection: + """Manage a single remote development connection.""" + + def __init__(self, config: RemoteConfig) -> None: + self._config = config + self._connected = False + self._process: Optional[subprocess.Popen] = None + + # -- connection lifecycle ----------------------------------------------- + + def connect(self) -> bool: + """Establish the connection. Returns *True* on success.""" + if self._connected: + return True + + try: + if self._config.type == RemoteType.SSH: + return self._connect_ssh() + elif self._config.type == RemoteType.WSL: + return self._connect_wsl() + elif self._config.type in (RemoteType.CONTAINER, RemoteType.DEVCONTAINER): + return self._connect_container() + except Exception: + self._connected = False + return False + return False + + def disconnect(self) -> None: + """Close the connection.""" + if self._process is not None: + self._process.terminate() + self._process = None + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + # -- remote operations -------------------------------------------------- + + def execute(self, command: str) -> str: + """Run *command* on the remote and return stdout.""" + if not self._connected: + raise RuntimeError("Not connected") + + if self._config.type == RemoteType.SSH: + result = subprocess.run( + self._ssh_base_cmd() + [command], + capture_output=True, text=True, check=False, + ) + return result.stdout + + if self._config.type == RemoteType.WSL: + result = subprocess.run( + ["wsl", "-d", self._config.wsl_distro, "--", "bash", "-c", command], + capture_output=True, text=True, check=False, + ) + return result.stdout + + if self._config.type in (RemoteType.CONTAINER, RemoteType.DEVCONTAINER): + result = subprocess.run( + ["docker", "exec", self._config.container_id, "bash", "-c", command], + capture_output=True, text=True, check=False, + ) + return result.stdout + + return "" + + def upload(self, local_path: str, remote_path: str) -> bool: + """Upload a file from *local_path* to *remote_path*.""" + if not self._connected: + return False + + if self._config.type == RemoteType.SSH: + scp_target = f"{self._config.username}@{self._config.host}:{remote_path}" + cmd: list[str] = ["scp"] + if self._config.key_path: + cmd += ["-i", self._config.key_path] + cmd += ["-P", str(self._config.port), local_path, scp_target] + return subprocess.run(cmd, capture_output=True, check=False).returncode == 0 + + if self._config.type == RemoteType.WSL: + win_path = local_path.replace("\\", "/") + return subprocess.run( + ["wsl", "-d", self._config.wsl_distro, "--", "cp", f"/mnt/{win_path}", remote_path], + capture_output=True, check=False, + ).returncode == 0 + + if self._config.type in (RemoteType.CONTAINER, RemoteType.DEVCONTAINER): + return subprocess.run( + ["docker", "cp", local_path, f"{self._config.container_id}:{remote_path}"], + capture_output=True, check=False, + ).returncode == 0 + + return False + + def download(self, remote_path: str, local_path: str) -> bool: + """Download a file from *remote_path* to *local_path*.""" + if not self._connected: + return False + + if self._config.type == RemoteType.SSH: + scp_source = f"{self._config.username}@{self._config.host}:{remote_path}" + cmd: list[str] = ["scp"] + if self._config.key_path: + cmd += ["-i", self._config.key_path] + cmd += ["-P", str(self._config.port), scp_source, local_path] + return subprocess.run(cmd, capture_output=True, check=False).returncode == 0 + + if self._config.type == RemoteType.WSL: + return subprocess.run( + ["wsl", "-d", self._config.wsl_distro, "--", "cp", remote_path, f"/mnt/{local_path}"], + capture_output=True, check=False, + ).returncode == 0 + + if self._config.type in (RemoteType.CONTAINER, RemoteType.DEVCONTAINER): + return subprocess.run( + ["docker", "cp", f"{self._config.container_id}:{remote_path}", local_path], + capture_output=True, check=False, + ).returncode == 0 + + return False + + def list_files(self, remote_path: str) -> List[str]: + """List files in *remote_path*.""" + output = self.execute(f"ls -1 {remote_path}") + return [line for line in output.splitlines() if line.strip()] + + def read_file(self, remote_path: str) -> str: + """Read the contents of a remote file.""" + return self.execute(f"cat {remote_path}") + + def write_file(self, remote_path: str, content: str) -> bool: + """Write *content* to a remote file.""" + safe = content.replace("'", "'\\''") + result = self.execute(f"printf '%s' '{safe}' > {remote_path}") + return True # best-effort; execute raises on disconnect + + def forward_port(self, local_port: int, remote_port: int) -> bool: + """Set up SSH port forwarding (``-L``).""" + if self._config.type != RemoteType.SSH or not self._connected: + return False + + cmd = self._ssh_base_cmd(extra_flags=[ + "-N", "-L", f"{local_port}:localhost:{remote_port}", + ]) + try: + self._process = subprocess.Popen(cmd) + return True + except Exception: + return False + + def sync(self, local_dir: str, remote_dir: str) -> bool: + """Synchronise *local_dir* to *remote_dir* via rsync over SSH.""" + if not self._connected or not shutil.which("rsync"): + return False + + if self._config.type == RemoteType.SSH: + ssh_cmd = f"ssh -p {self._config.port}" + if self._config.key_path: + ssh_cmd += f" -i {self._config.key_path}" + target = f"{self._config.username}@{self._config.host}:{remote_dir}" + return subprocess.run( + ["rsync", "-avz", "-e", ssh_cmd, local_dir + "/", target + "/"], + capture_output=True, check=False, + ).returncode == 0 + + return False + + # -- private helpers ---------------------------------------------------- + + def _ssh_base_cmd(self, extra_flags: Optional[List[str]] = None) -> list[str]: + cmd: list[str] = ["ssh"] + if self._config.key_path: + cmd += ["-i", self._config.key_path] + cmd += ["-p", str(self._config.port)] + if extra_flags: + cmd += extra_flags + cmd.append(f"{self._config.username}@{self._config.host}") + return cmd + + def _connect_ssh(self) -> bool: + result = subprocess.run( + self._ssh_base_cmd() + ["echo", "ok"], + capture_output=True, text=True, check=False, + ) + self._connected = result.returncode == 0 + return self._connected + + def _connect_wsl(self) -> bool: + result = subprocess.run( + ["wsl", "-d", self._config.wsl_distro, "--", "echo", "ok"], + capture_output=True, text=True, check=False, + ) + self._connected = result.returncode == 0 + return self._connected + + def _connect_container(self) -> bool: + result = subprocess.run( + ["docker", "exec", self._config.container_id, "echo", "ok"], + capture_output=True, text=True, check=False, + ) + self._connected = result.returncode == 0 + return self._connected + + +# --------------------------------------------------------------------------- +# RemoteManager +# --------------------------------------------------------------------------- + +class RemoteManager: + """Manage multiple named remote connections.""" + + def __init__(self) -> None: + self._connections: Dict[str, RemoteConnection] = {} + + def add(self, name: str, config: RemoteConfig) -> RemoteConnection: + conn = RemoteConnection(config) + self._connections[name] = conn + return conn + + def get(self, name: str) -> Optional[RemoteConnection]: + return self._connections.get(name) + + def remove(self, name: str) -> None: + conn = self._connections.pop(name, None) + if conn is not None: + conn.disconnect() + + def list_connections(self) -> List[str]: + return list(self._connections.keys()) + + def disconnect_all(self) -> None: + for conn in self._connections.values(): + conn.disconnect() diff --git a/eostudio/core/devtools/security.py b/eostudio/core/devtools/security.py new file mode 100755 index 0000000..97958f8 --- /dev/null +++ b/eostudio/core/devtools/security.py @@ -0,0 +1,675 @@ +"""Security analysis tools for EoStudio devtools.""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable, Dict, List, Optional + + +class Severity(Enum): + """Severity levels for vulnerabilities.""" + + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + +class VulnerabilityType(Enum): + """Types of vulnerabilities.""" + + DEPENDENCY = "dependency" + CODE_PATTERN = "code_pattern" + SECRET_LEAK = "secret_leak" + LICENSE = "license" + CONFIGURATION = "configuration" + + +@dataclass +class Vulnerability: + """Represents a single vulnerability finding.""" + + id: str + type: VulnerabilityType + severity: Severity + title: str + description: str + file: str + line: int + cwe: Optional[str] = None + fix_suggestion: str = "" + references: List[str] = field(default_factory=list) + + +@dataclass +class ScanResult: + """Result of a security scan.""" + + vulnerabilities: List[Vulnerability] = field(default_factory=list) + scanned_files: int = 0 + scan_duration_ms: int = 0 + timestamp: str = "" + + +@dataclass +class LicenseInfo: + """License information for a dependency.""" + + name: str + spdx_id: str + compatible: bool + file: str + + +# Regex patterns for code scanning (OWASP Top 10) +_SQL_INJECTION_PATTERNS = [ + re.compile(r"""(?:execute|cursor\.execute)\s*\(\s*(?:f['"]|['"].*%s|['"].*\.format)""", re.IGNORECASE), + re.compile(r"""(?:query|sql)\s*=\s*(?:f['"]|['"].*%s|['"].*\.format|['"].*\+)""", re.IGNORECASE), +] + +_XSS_PATTERNS = [ + re.compile(r"""\.innerHTML\s*="""), + re.compile(r"""dangerouslySetInnerHTML"""), + re.compile(r"""document\.write\s*\("""), +] + +_COMMAND_INJECTION_PATTERNS = [ + re.compile(r"""os\.system\s*\("""), + re.compile(r"""subprocess\.\w+\s*\(.*shell\s*=\s*True""", re.DOTALL), + re.compile(r"""os\.popen\s*\("""), +] + +_PATH_TRAVERSAL_PATTERNS = [ + re.compile(r"""open\s*\(.*\+.*\)"""), + re.compile(r"""os\.path\.join\s*\(.*request""", re.IGNORECASE), + re.compile(r"""\.\.\/"""), +] + +_HARDCODED_CREDS_PATTERNS = [ + re.compile(r"""(?:password|passwd|pwd)\s*=\s*['"][^'"]+['"]""", re.IGNORECASE), + re.compile(r"""(?:secret|api_key|apikey|access_key)\s*=\s*['"][^'"]+['"]""", re.IGNORECASE), + re.compile(r"""(?:token|auth_token)\s*=\s*['"][A-Za-z0-9+/=_-]{16,}['"]""", re.IGNORECASE), +] + +_INSECURE_DESERIALIZATION_PATTERNS = [ + re.compile(r"""pickle\.loads?\s*\("""), + re.compile(r"""yaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)"""), + re.compile(r"""marshal\.loads?\s*\("""), +] + +_SSRF_PATTERNS = [ + re.compile(r"""requests\.(?:get|post|put|delete|patch)\s*\(.*(?:request\.|user_input|params)""", re.IGNORECASE), + re.compile(r"""urllib\.request\.urlopen\s*\(.*(?:request\.|user_input)""", re.IGNORECASE), +] + +_WEAK_CRYPTO_PATTERNS = [ + re.compile(r"""hashlib\.(?:md5|sha1)\s*\("""), + re.compile(r"""MD5\s*\("""), + re.compile(r"""SHA1\s*\("""), +] + +# Secret detection patterns +_SECRET_PATTERNS = [ + (re.compile(r"""AKIA[0-9A-Z]{16}"""), "AWS Access Key", Severity.CRITICAL), + (re.compile(r"""ghp_[A-Za-z0-9_]{36}"""), "GitHub Personal Access Token", Severity.CRITICAL), + (re.compile(r"""gho_[A-Za-z0-9_]{36}"""), "GitHub OAuth Token", Severity.CRITICAL), + (re.compile(r"""ghu_[A-Za-z0-9_]{36}"""), "GitHub User Token", Severity.CRITICAL), + (re.compile(r"""ghs_[A-Za-z0-9_]{36}"""), "GitHub Server Token", Severity.CRITICAL), + (re.compile(r"""ghr_[A-Za-z0-9_]{36}"""), "GitHub Refresh Token", Severity.CRITICAL), + (re.compile(r"""xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"""), "Slack Bot Token", Severity.CRITICAL), + (re.compile(r"""xoxp-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"""), "Slack User Token", Severity.CRITICAL), + (re.compile(r"""sk-[A-Za-z0-9]{48}"""), "OpenAI API Key", Severity.CRITICAL), + (re.compile(r"""-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"""), "Private Key", Severity.CRITICAL), + (re.compile(r"""(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}""", re.IGNORECASE), "Generic API Key", Severity.HIGH), + (re.compile(r"""(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{8,}""", re.IGNORECASE), "Hardcoded Password", Severity.HIGH), +] + +_SCANNABLE_EXTENSIONS = { + ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", + ".rb", ".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".swift", + ".kt", ".scala", ".sh", ".bash", ".yaml", ".yml", ".toml", + ".json", ".xml", ".env", ".cfg", ".ini", ".conf", +} + +_INCOMPATIBLE_LICENSES = { + "AGPL-3.0-only", "AGPL-3.0-or-later", "GPL-3.0-only", + "GPL-3.0-or-later", "SSPL-1.0", "EUPL-1.2", +} + + +class SecurityScanner: + """Security scanner for detecting vulnerabilities in projects.""" + + def __init__(self, workspace_path: str = ".") -> None: + self.workspace_path = Path(workspace_path).resolve() + self._vuln_counter = 0 + + def _next_id(self) -> str: + self._vuln_counter += 1 + return f"VULN-{self._vuln_counter:04d}" + + def _iter_source_files(self): + """Yield source files in the workspace.""" + for root, dirs, files in os.walk(self.workspace_path): + dirs[:] = [ + d for d in dirs + if d not in {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".tox"} + ] + for fname in files: + fpath = Path(root) / fname + if fpath.suffix in _SCANNABLE_EXTENSIONS: + yield fpath + + def _run_command(self, cmd: List[str]) -> subprocess.CompletedProcess: + try: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + cwd=str(self.workspace_path), + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr="command not found or timed out") + + def scan_dependencies(self) -> ScanResult: + """Scan dependencies for known vulnerabilities using package audit tools.""" + start = time.monotonic_ns() + vulns: List[Vulnerability] = [] + scanned = 0 + + # pip audit + if (self.workspace_path / "requirements.txt").exists() or (self.workspace_path / "setup.py").exists(): + scanned += 1 + result = self._run_command(["pip", "audit", "--format", "json"]) + if result.returncode != 0 and result.stdout: + try: + data = json.loads(result.stdout) + for entry in data.get("vulnerabilities", []): + vulns.append(Vulnerability( + id=self._next_id(), + type=VulnerabilityType.DEPENDENCY, + severity=Severity.HIGH, + title=f"Vulnerable dependency: {entry.get('name', 'unknown')}", + description=entry.get("description", ""), + file="requirements.txt", + line=0, + cwe=entry.get("aliases", [None])[0] if entry.get("aliases") else None, + fix_suggestion=f"Upgrade {entry.get('name')} to {entry.get('fix_versions', ['latest'])[0]}", + references=entry.get("references", []), + )) + except (json.JSONDecodeError, KeyError): + pass + + # npm audit + if (self.workspace_path / "package.json").exists(): + scanned += 1 + result = self._run_command(["npm", "audit", "--json"]) + if result.stdout: + try: + data = json.loads(result.stdout) + for name, advisory in data.get("vulnerabilities", {}).items(): + severity_map = {"critical": Severity.CRITICAL, "high": Severity.HIGH, "moderate": Severity.MEDIUM, "low": Severity.LOW} + vulns.append(Vulnerability( + id=self._next_id(), + type=VulnerabilityType.DEPENDENCY, + severity=severity_map.get(advisory.get("severity", "high"), Severity.HIGH), + title=f"Vulnerable dependency: {name}", + description=advisory.get("title", ""), + file="package.json", + line=0, + fix_suggestion=advisory.get("fixAvailable", {}).get("name", "Update dependency"), + references=[advisory.get("url", "")], + )) + except (json.JSONDecodeError, KeyError): + pass + + # cargo audit + if (self.workspace_path / "Cargo.toml").exists(): + scanned += 1 + result = self._run_command(["cargo", "audit", "--json"]) + if result.stdout: + try: + data = json.loads(result.stdout) + for vuln_entry in data.get("vulnerabilities", {}).get("list", []): + advisory = vuln_entry.get("advisory", {}) + vulns.append(Vulnerability( + id=self._next_id(), + type=VulnerabilityType.DEPENDENCY, + severity=Severity.HIGH, + title=f"Vulnerable crate: {advisory.get('package', 'unknown')}", + description=advisory.get("title", ""), + file="Cargo.toml", + line=0, + cwe=advisory.get("id"), + fix_suggestion=f"Update {advisory.get('package', '')} to a patched version", + references=[advisory.get("url", "")], + )) + except (json.JSONDecodeError, KeyError): + pass + + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return ScanResult( + vulnerabilities=vulns, + scanned_files=scanned, + scan_duration_ms=elapsed, + timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + + def scan_code(self) -> ScanResult: + """Regex-based SAST scan for OWASP Top 10 patterns.""" + start = time.monotonic_ns() + vulns: List[Vulnerability] = [] + scanned = 0 + + patterns_map = [ + (_SQL_INJECTION_PATTERNS, "SQL Injection", "Potential SQL injection via string formatting", + "CWE-89", "Use parameterized queries instead of string formatting"), + (_XSS_PATTERNS, "Cross-Site Scripting (XSS)", "Potential XSS via unsafe DOM manipulation", + "CWE-79", "Use safe rendering methods; sanitize user input before inserting into DOM"), + (_COMMAND_INJECTION_PATTERNS, "Command Injection", "Potential command injection via shell execution", + "CWE-78", "Use subprocess with a list of arguments instead of shell=True; avoid os.system"), + (_PATH_TRAVERSAL_PATTERNS, "Path Traversal", "Potential path traversal vulnerability", + "CWE-22", "Validate and sanitize file paths; use os.path.realpath and check against allowed directories"), + (_HARDCODED_CREDS_PATTERNS, "Hardcoded Credentials", "Hardcoded credentials detected in source code", + "CWE-798", "Move credentials to environment variables or a secrets manager"), + (_INSECURE_DESERIALIZATION_PATTERNS, "Insecure Deserialization", "Unsafe deserialization detected", + "CWE-502", "Use safe deserialization methods (e.g., yaml.safe_load, json.loads)"), + (_SSRF_PATTERNS, "Server-Side Request Forgery (SSRF)", "Potential SSRF via user-controlled URL", + "CWE-918", "Validate and whitelist URLs before making server-side requests"), + (_WEAK_CRYPTO_PATTERNS, "Weak Cryptography", "Weak hash algorithm used (MD5/SHA1)", + "CWE-327", "Use SHA-256 or stronger hashing; for passwords use bcrypt, scrypt, or argon2"), + ] + + for fpath in self._iter_source_files(): + scanned += 1 + try: + content = fpath.read_text(errors="replace") + except OSError: + continue + + rel_path = str(fpath.relative_to(self.workspace_path)) + lines = content.splitlines() + + for line_num, line in enumerate(lines, start=1): + for regexes, title, desc, cwe, fix in patterns_map: + for regex in regexes: + if regex.search(line): + vulns.append(Vulnerability( + id=self._next_id(), + type=VulnerabilityType.CODE_PATTERN, + severity=Severity.HIGH if "injection" in title.lower() or "XSS" in title else Severity.MEDIUM, + title=title, + description=desc, + file=rel_path, + line=line_num, + cwe=cwe, + fix_suggestion=fix, + )) + break # one match per pattern group per line + + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return ScanResult( + vulnerabilities=vulns, + scanned_files=scanned, + scan_duration_ms=elapsed, + timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + + def scan_secrets(self) -> ScanResult: + """Detect leaked secrets in source code and config files.""" + start = time.monotonic_ns() + vulns: List[Vulnerability] = [] + scanned = 0 + + for fpath in self._iter_source_files(): + scanned += 1 + try: + content = fpath.read_text(errors="replace") + except OSError: + continue + + rel_path = str(fpath.relative_to(self.workspace_path)) + lines = content.splitlines() + + for line_num, line in enumerate(lines, start=1): + for regex, secret_type, severity in _SECRET_PATTERNS: + if regex.search(line): + vulns.append(Vulnerability( + id=self._next_id(), + type=VulnerabilityType.SECRET_LEAK, + severity=severity, + title=f"Leaked secret: {secret_type}", + description=f"{secret_type} detected in source code", + file=rel_path, + line=line_num, + cwe="CWE-798", + fix_suggestion="Remove the secret from source code and rotate it immediately. Use environment variables or a secrets manager.", + )) + + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return ScanResult( + vulnerabilities=vulns, + scanned_files=scanned, + scan_duration_ms=elapsed, + timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + + def check_licenses(self) -> List[LicenseInfo]: + """Check dependency licenses for compatibility.""" + licenses: List[LicenseInfo] = [] + + # Check Python packages + result = self._run_command(["pip", "list", "--format", "json"]) + if result.returncode == 0 and result.stdout: + try: + packages = json.loads(result.stdout) + for pkg in packages: + name = pkg.get("name", "") + meta_result = self._run_command(["pip", "show", name]) + if meta_result.returncode == 0: + license_name = "" + for meta_line in meta_result.stdout.splitlines(): + if meta_line.startswith("License:"): + license_name = meta_line.split(":", 1)[1].strip() + break + spdx = license_name if license_name else "UNKNOWN" + licenses.append(LicenseInfo( + name=name, + spdx_id=spdx, + compatible=spdx not in _INCOMPATIBLE_LICENSES, + file="requirements.txt", + )) + except (json.JSONDecodeError, KeyError): + pass + + # Check package.json licenses + pkg_json_path = self.workspace_path / "package.json" + if pkg_json_path.exists(): + node_modules = self.workspace_path / "node_modules" + if node_modules.exists(): + for pkg_dir in node_modules.iterdir(): + if pkg_dir.is_dir() and not pkg_dir.name.startswith("."): + pkg_file = pkg_dir / "package.json" + if pkg_file.exists(): + try: + data = json.loads(pkg_file.read_text(errors="replace")) + spdx = data.get("license", "UNKNOWN") + licenses.append(LicenseInfo( + name=data.get("name", pkg_dir.name), + spdx_id=spdx if isinstance(spdx, str) else "UNKNOWN", + compatible=spdx not in _INCOMPATIBLE_LICENSES if isinstance(spdx, str) else True, + file=str(pkg_file.relative_to(self.workspace_path)), + )) + except (json.JSONDecodeError, OSError): + pass + + return licenses + + def generate_sbom(self) -> Dict: + """Generate a Software Bill of Materials in CycloneDX format.""" + components: List[Dict] = [] + + # Python dependencies + result = self._run_command(["pip", "list", "--format", "json"]) + if result.returncode == 0 and result.stdout: + try: + for pkg in json.loads(result.stdout): + components.append({ + "type": "library", + "name": pkg.get("name", ""), + "version": pkg.get("version", ""), + "purl": f"pkg:pypi/{pkg.get('name', '')}@{pkg.get('version', '')}", + }) + except json.JSONDecodeError: + pass + + # Node dependencies + pkg_json_path = self.workspace_path / "package.json" + if pkg_json_path.exists(): + try: + data = json.loads(pkg_json_path.read_text(errors="replace")) + for dep, version in {**data.get("dependencies", {}), **data.get("devDependencies", {})}.items(): + clean_version = version.lstrip("^~>=<") + components.append({ + "type": "library", + "name": dep, + "version": clean_version, + "purl": f"pkg:npm/{dep}@{clean_version}", + }) + except (json.JSONDecodeError, OSError): + pass + + # Rust dependencies + cargo_lock = self.workspace_path / "Cargo.lock" + if cargo_lock.exists(): + try: + content = cargo_lock.read_text(errors="replace") + current_pkg: Dict = {} + for raw_line in content.splitlines(): + stripped = raw_line.strip() + if stripped == "[[package]]": + if current_pkg.get("name"): + components.append({ + "type": "library", + "name": current_pkg["name"], + "version": current_pkg.get("version", ""), + "purl": f"pkg:cargo/{current_pkg['name']}@{current_pkg.get('version', '')}", + }) + current_pkg = {} + elif stripped.startswith("name = "): + current_pkg["name"] = stripped.split('"')[1] + elif stripped.startswith("version = "): + current_pkg["version"] = stripped.split('"')[1] + if current_pkg.get("name"): + components.append({ + "type": "library", + "name": current_pkg["name"], + "version": current_pkg.get("version", ""), + "purl": f"pkg:cargo/{current_pkg['name']}@{current_pkg.get('version', '')}", + }) + except OSError: + pass + + return { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "tools": [{"vendor": "EoStudio", "name": "security-scanner", "version": "1.0.0"}], + }, + "components": components, + } + + def generate_report(self, result: ScanResult, format: str = "json") -> str: + """Generate a report from scan results in the specified format.""" + vulns_data = [ + { + "id": v.id, + "type": v.type.value, + "severity": v.severity.value, + "title": v.title, + "description": v.description, + "file": v.file, + "line": v.line, + "cwe": v.cwe, + "fix_suggestion": v.fix_suggestion, + "references": v.references, + } + for v in result.vulnerabilities + ] + + report_data = { + "summary": { + "total_vulnerabilities": len(result.vulnerabilities), + "critical": sum(1 for v in result.vulnerabilities if v.severity == Severity.CRITICAL), + "high": sum(1 for v in result.vulnerabilities if v.severity == Severity.HIGH), + "medium": sum(1 for v in result.vulnerabilities if v.severity == Severity.MEDIUM), + "low": sum(1 for v in result.vulnerabilities if v.severity == Severity.LOW), + "info": sum(1 for v in result.vulnerabilities if v.severity == Severity.INFO), + "scanned_files": result.scanned_files, + "scan_duration_ms": result.scan_duration_ms, + "timestamp": result.timestamp, + }, + "vulnerabilities": vulns_data, + } + + if format == "json": + return json.dumps(report_data, indent=2) + + if format == "markdown": + lines = [ + "# Security Scan Report", + "", + f"**Timestamp:** {result.timestamp}", + f"**Files scanned:** {result.scanned_files}", + f"**Duration:** {result.scan_duration_ms}ms", + "", + "## Summary", + "", + "| Severity | Count |", + "|----------|-------|", + f"| Critical | {report_data['summary']['critical']} |", + f"| High | {report_data['summary']['high']} |", + f"| Medium | {report_data['summary']['medium']} |", + f"| Low | {report_data['summary']['low']} |", + f"| Info | {report_data['summary']['info']} |", + "", + "## Vulnerabilities", + "", + ] + for v in result.vulnerabilities: + lines.append(f"### [{v.severity.value.upper()}] {v.title}") + lines.append(f"- **ID:** {v.id}") + lines.append(f"- **File:** `{v.file}:{v.line}`") + lines.append(f"- **Type:** {v.type.value}") + if v.cwe: + lines.append(f"- **CWE:** {v.cwe}") + lines.append(f"- **Description:** {v.description}") + if v.fix_suggestion: + lines.append(f"- **Fix:** {v.fix_suggestion}") + lines.append("") + return "\n".join(lines) + + if format == "html": + rows = "" + for v in result.vulnerabilities: + color = {"critical": "#dc3545", "high": "#fd7e14", "medium": "#ffc107", "low": "#28a745", "info": "#17a2b8"} + badge_color = color.get(v.severity.value, "#6c757d") + rows += ( + f"" + f"{v.id}" + f"{v.severity.value.upper()}" + f"{v.title}" + f"{v.file}:{v.line}" + f"{v.description}" + f"{v.fix_suggestion}" + f"" + ) + return ( + "Security Report" + "" + f"

Security Scan Report

" + f"

Timestamp: {result.timestamp} | Files: {result.scanned_files} | Duration: {result.scan_duration_ms}ms

" + f"" + f"{rows}
IDSeverityTitleLocationDescriptionFix
" + ) + + return json.dumps(report_data, indent=2) + + def scan_all(self) -> ScanResult: + """Run all scans and merge results.""" + start = time.monotonic_ns() + all_vulns: List[Vulnerability] = [] + total_files = 0 + + for scan_fn in (self.scan_dependencies, self.scan_code, self.scan_secrets): + result = scan_fn() + all_vulns.extend(result.vulnerabilities) + total_files += result.scanned_files + + elapsed = (time.monotonic_ns() - start) // 1_000_000 + return ScanResult( + vulnerabilities=all_vulns, + scanned_files=total_files, + scan_duration_ms=elapsed, + timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + + def get_fix_suggestions(self, vuln: Vulnerability) -> List[str]: + """Get fix suggestions for a vulnerability.""" + suggestions: List[str] = [] + + if vuln.fix_suggestion: + suggestions.append(vuln.fix_suggestion) + + fix_map = { + "CWE-89": [ + "Use parameterized queries (e.g., cursor.execute('SELECT * FROM t WHERE id = ?', (id,)))", + "Use an ORM like SQLAlchemy or Django ORM to avoid raw SQL", + ], + "CWE-79": [ + "Use textContent instead of innerHTML for plain text", + "Sanitize HTML with a library like DOMPurify before insertion", + "Use a templating engine with auto-escaping", + ], + "CWE-78": [ + "Use subprocess.run() with a list of arguments: subprocess.run(['cmd', 'arg1', 'arg2'])", + "Avoid os.system() entirely; use subprocess with shell=False", + "Use shlex.quote() if shell execution is unavoidable", + ], + "CWE-22": [ + "Use os.path.realpath() and verify the resolved path is within the allowed directory", + "Reject paths containing '..' segments", + "Use pathlib.Path.resolve() and check with is_relative_to()", + ], + "CWE-798": [ + "Move secrets to environment variables: os.environ.get('SECRET_KEY')", + "Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)", + "Use a .env file (excluded from version control) with python-dotenv", + ], + "CWE-502": [ + "Use json.loads() instead of pickle.loads() when possible", + "Use yaml.safe_load() instead of yaml.load()", + "Validate and sanitize data before deserialization", + ], + "CWE-918": [ + "Validate URLs against an allowlist of permitted domains", + "Block requests to internal/private IP ranges (10.x, 172.16.x, 192.168.x, 127.x)", + "Use a URL parser to validate the scheme and host before making requests", + ], + "CWE-327": [ + "Use hashlib.sha256() or hashlib.sha3_256() for general hashing", + "For passwords, use bcrypt, scrypt, or argon2", + "Never use MD5 or SHA1 for security-sensitive operations", + ], + } + + if vuln.cwe and vuln.cwe in fix_map: + suggestions.extend(fix_map[vuln.cwe]) + + if vuln.type == VulnerabilityType.SECRET_LEAK: + suggestions.extend([ + "Rotate the compromised secret immediately", + "Add the file to .gitignore if it contains secrets", + "Use git-filter-branch or BFG to remove secrets from git history", + "Set up pre-commit hooks to prevent future secret leaks", + ]) + + if vuln.type == VulnerabilityType.DEPENDENCY: + suggestions.extend([ + "Run dependency update commands (pip install --upgrade, npm update)", + "Pin dependencies to specific versions in your lock file", + "Set up automated dependency update tools (Dependabot, Renovate)", + ]) + + return suggestions diff --git a/eostudio/core/devtools/testing.py b/eostudio/core/devtools/testing.py new file mode 100755 index 0000000..1c85497 --- /dev/null +++ b/eostudio/core/devtools/testing.py @@ -0,0 +1,547 @@ +"""Universal test runner supporting multiple frameworks.""" +from __future__ import annotations + +import enum +import json +import re +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + + +class TestStatus(enum.Enum): + """Status of an individual test.""" + PASSED = "passed" + FAILED = "failed" + ERROR = "error" + SKIPPED = "skipped" + RUNNING = "running" + PENDING = "pending" + + +class TestFramework(enum.Enum): + """Supported test frameworks.""" + PYTEST = "pytest" + JEST = "jest" + CARGO_TEST = "cargo_test" + GO_TEST = "go_test" + JUNIT = "junit" + DOTNET_TEST = "dotnet_test" + SWIFT_TEST = "swift_test" + + +@dataclass +class TestResult: + """Result of a single test execution.""" + name: str + status: TestStatus + duration: float = 0.0 + message: str = "" + file: str = "" + line: int = 0 + stdout: str = "" + stderr: str = "" + + +@dataclass +class TestSuite: + """Collection of test results.""" + name: str + tests: list[TestResult] = field(default_factory=list) + duration: float = 0.0 + passed: int = 0 + failed: int = 0 + errors: int = 0 + skipped: int = 0 + + def add_result(self, result: TestResult) -> None: + self.tests.append(result) + if result.status == TestStatus.PASSED: + self.passed += 1 + elif result.status == TestStatus.FAILED: + self.failed += 1 + elif result.status == TestStatus.ERROR: + self.errors += 1 + elif result.status == TestStatus.SKIPPED: + self.skipped += 1 + + @property + def total(self) -> int: + return len(self.tests) + + @property + def success_rate(self) -> float: + return (self.passed / self.total * 100.0) if self.total else 0.0 + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "tests": [ + { + "name": t.name, + "status": t.status.value, + "duration": t.duration, + "message": t.message, + "file": t.file, + "line": t.line, + } + for t in self.tests + ], + "duration": self.duration, + "passed": self.passed, + "failed": self.failed, + "errors": self.errors, + "skipped": self.skipped, + "total": self.total, + "success_rate": self.success_rate, + } + + +@dataclass +class CoverageResult: + """Code coverage information.""" + total_lines: int = 0 + covered_lines: int = 0 + percentage: float = 0.0 + uncovered_ranges: list[tuple[int, int]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "total_lines": self.total_lines, + "covered_lines": self.covered_lines, + "percentage": self.percentage, + "uncovered_ranges": self.uncovered_ranges, + } + + +# --------------------------------------------------------------------------- +# Framework-specific command builders and parsers +# --------------------------------------------------------------------------- + +_FRAMEWORK_COMMANDS: dict[TestFramework, dict[str, Any]] = { + TestFramework.PYTEST: { + "run_all": ["python", "-m", "pytest", "-v", "--tb=short"], + "run_file": ["python", "-m", "pytest", "-v", "--tb=short"], + "run_test": ["python", "-m", "pytest", "-v", "--tb=short", "-k"], + "coverage": ["python", "-m", "pytest", "--cov", "--cov-report=json", "-v"], + "discover": ["python", "-m", "pytest", "--collect-only", "-q"], + "indicator_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"], + }, + TestFramework.JEST: { + "run_all": ["npx", "jest", "--verbose"], + "run_file": ["npx", "jest", "--verbose"], + "run_test": ["npx", "jest", "--verbose", "-t"], + "coverage": ["npx", "jest", "--coverage", "--verbose"], + "discover": ["npx", "jest", "--listTests"], + "indicator_files": ["jest.config.js", "jest.config.ts", "jest.config.mjs"], + }, + TestFramework.CARGO_TEST: { + "run_all": ["cargo", "test"], + "run_file": ["cargo", "test"], + "run_test": ["cargo", "test"], + "coverage": ["cargo", "tarpaulin", "--out", "Json"], + "discover": ["cargo", "test", "--", "--list"], + "indicator_files": ["Cargo.toml"], + }, + TestFramework.GO_TEST: { + "run_all": ["go", "test", "-v", "./..."], + "run_file": ["go", "test", "-v"], + "run_test": ["go", "test", "-v", "-run"], + "coverage": ["go", "test", "-v", "-coverprofile=coverage.out", "./..."], + "discover": ["go", "test", "-list", ".", "./..."], + "indicator_files": ["go.mod"], + }, + TestFramework.JUNIT: { + "run_all": ["mvn", "test"], + "run_file": ["mvn", "test", "-Dtest="], + "run_test": ["mvn", "test", "-Dtest="], + "coverage": ["mvn", "test", "-Djacoco"], + "discover": ["mvn", "test", "-Dsurefire.useFile=false", "-DdryRun=true"], + "indicator_files": ["pom.xml", "build.gradle", "build.gradle.kts"], + }, + TestFramework.DOTNET_TEST: { + "run_all": ["dotnet", "test", "--verbosity", "normal"], + "run_file": ["dotnet", "test", "--filter"], + "run_test": ["dotnet", "test", "--filter"], + "coverage": ["dotnet", "test", '--collect:"XPlat Code Coverage"'], + "discover": ["dotnet", "test", "--list-tests"], + "indicator_files": ["*.csproj", "*.sln"], + }, + TestFramework.SWIFT_TEST: { + "run_all": ["swift", "test"], + "run_file": ["swift", "test", "--filter"], + "run_test": ["swift", "test", "--filter"], + "coverage": ["swift", "test", "--enable-code-coverage"], + "discover": ["swift", "test", "--list-tests"], + "indicator_files": ["Package.swift"], + }, +} + + +class TestRunner: + """Universal test runner that auto-detects and delegates to the right framework.""" + + def __init__(self, workspace_path: str = ".") -> None: + self.workspace = Path(workspace_path).resolve() + self._framework: TestFramework | None = None + self._history: list[TestSuite] = [] + self._watchers: list[Callable[[TestSuite], None]] = [] + + # ------------------------------------------------------------------ + # Framework detection + # ------------------------------------------------------------------ + + def detect_framework(self) -> TestFramework | None: + """Auto-detect the test framework based on project files.""" + if self._framework is not None: + return self._framework + + for fw, meta in _FRAMEWORK_COMMANDS.items(): + for indicator in meta["indicator_files"]: + if "*" in indicator: + if list(self.workspace.glob(indicator)): + self._framework = fw + return fw + elif (self.workspace / indicator).exists(): + if indicator == "pyproject.toml" and fw == TestFramework.PYTEST: + content = (self.workspace / indicator).read_text(errors="replace") + if "pytest" in content or "tool.pytest" in content: + self._framework = fw + return fw + continue + self._framework = fw + return fw + return None + + # ------------------------------------------------------------------ + # Test discovery + # ------------------------------------------------------------------ + + def discover_tests(self) -> list[str]: + """List all available tests without running them.""" + fw = self._ensure_framework() + cmd = list(_FRAMEWORK_COMMANDS[fw]["discover"]) + result = self._exec(cmd) + return [line for line in result.stdout.splitlines() if line.strip()] + + # ------------------------------------------------------------------ + # Running tests + # ------------------------------------------------------------------ + + def run_all(self) -> TestSuite: + """Run the full test suite.""" + fw = self._ensure_framework() + cmd = list(_FRAMEWORK_COMMANDS[fw]["run_all"]) + return self._run_and_parse(cmd, suite_name="all") + + def run_file(self, file_path: str) -> TestSuite: + """Run tests in a specific file.""" + fw = self._ensure_framework() + cmd = list(_FRAMEWORK_COMMANDS[fw]["run_file"]) + if fw in (TestFramework.PYTEST, TestFramework.JEST): + cmd.append(file_path) + elif fw == TestFramework.CARGO_TEST: + module = Path(file_path).stem + cmd.append(module) + elif fw == TestFramework.GO_TEST: + cmd[-1] = f"./{Path(file_path).parent}" + elif fw == TestFramework.JUNIT: + cls = Path(file_path).stem + cmd[-1] = cmd[-1] + cls + elif fw == TestFramework.DOTNET_TEST: + cmd.append(f"FullyQualifiedName~{Path(file_path).stem}") + elif fw == TestFramework.SWIFT_TEST: + cmd.append(Path(file_path).stem) + return self._run_and_parse(cmd, suite_name=file_path) + + def run_test(self, test_name: str) -> TestSuite: + """Run a single test by name or pattern.""" + fw = self._ensure_framework() + cmd = list(_FRAMEWORK_COMMANDS[fw]["run_test"]) + cmd.append(test_name) + return self._run_and_parse(cmd, suite_name=test_name) + + def run_with_coverage(self) -> tuple[TestSuite, CoverageResult]: + """Run tests and collect coverage data.""" + fw = self._ensure_framework() + cmd = list(_FRAMEWORK_COMMANDS[fw]["coverage"]) + suite = self._run_and_parse(cmd, suite_name="coverage") + coverage = self._parse_coverage(fw) + return suite, coverage + + # ------------------------------------------------------------------ + # Watch mode + # ------------------------------------------------------------------ + + def watch(self, callback: Callable[[TestSuite], None] | None = None, interval: float = 2.0) -> None: + """Watch for file changes and re-run tests. Blocks until interrupted.""" + import hashlib + + if callback: + self._watchers.append(callback) + + snapshots: dict[str, str] = {} + + def _snapshot() -> dict[str, str]: + snap: dict[str, str] = {} + for p in self.workspace.rglob("*"): + if p.is_file() and not any( + part.startswith(".") or part in ("node_modules", "__pycache__", "target", "bin", "obj") + for part in p.parts + ): + try: + snap[str(p)] = hashlib.md5(p.read_bytes()).hexdigest() + except OSError: + pass + return snap + + snapshots = _snapshot() + try: + while True: + time.sleep(interval) + new_snap = _snapshot() + if new_snap != snapshots: + snapshots = new_snap + suite = self.run_all() + for cb in self._watchers: + cb(suite) + except KeyboardInterrupt: + pass + + # ------------------------------------------------------------------ + # History + # ------------------------------------------------------------------ + + def get_history(self) -> list[dict[str, Any]]: + """Return past test suite results.""" + return [s.to_dict() for s in self._history] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_framework(self) -> TestFramework: + fw = self.detect_framework() + if fw is None: + raise RuntimeError("No supported test framework detected in workspace.") + return fw + + def _exec(self, cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=self.workspace, + capture_output=True, + text=True, + timeout=timeout, + ) + + def _run_and_parse(self, cmd: list[str], suite_name: str) -> TestSuite: + start = time.monotonic() + proc = self._exec(cmd) + elapsed = time.monotonic() - start + fw = self._framework or TestFramework.PYTEST + suite = self._parse_output(fw, proc.stdout, proc.stderr, suite_name) + suite.duration = elapsed + self._history.append(suite) + return suite + + def _parse_output(self, fw: TestFramework, stdout: str, stderr: str, suite_name: str) -> TestSuite: + """Dispatch to framework-specific parser.""" + parser = { + TestFramework.PYTEST: self._parse_pytest, + TestFramework.JEST: self._parse_jest, + TestFramework.CARGO_TEST: self._parse_cargo, + TestFramework.GO_TEST: self._parse_go, + TestFramework.JUNIT: self._parse_junit_mvn, + TestFramework.DOTNET_TEST: self._parse_dotnet, + TestFramework.SWIFT_TEST: self._parse_swift, + }.get(fw, self._parse_generic) + return parser(stdout, stderr, suite_name) + + # -- pytest ---------------------------------------------------------- + + def _parse_pytest(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + pattern = re.compile( + r"^(?P[^\s:]+)::(?P\S+)\s+(?PPASSED|FAILED|ERROR|SKIPPED)", + re.MULTILINE, + ) + for m in pattern.finditer(stdout): + status_map = { + "PASSED": TestStatus.PASSED, + "FAILED": TestStatus.FAILED, + "ERROR": TestStatus.ERROR, + "SKIPPED": TestStatus.SKIPPED, + } + suite.add_result( + TestResult( + name=m.group("name"), + status=status_map.get(m.group("status"), TestStatus.ERROR), + file=m.group("file"), + stdout=stdout, + stderr=stderr, + ) + ) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- jest ------------------------------------------------------------- + + def _parse_jest(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + combined = stdout + "\n" + stderr + pattern = re.compile( + r"^\s*(?P[✓✕✗●])\s+(?P.+?)(?:\s+\((?P\d+)\s*m?s\))?\s*$", + re.MULTILINE, + ) + for m in pattern.finditer(combined): + icon = m.group("icon") + status = TestStatus.PASSED if icon == "\u2713" else TestStatus.FAILED + dur = float(m.group("dur") or 0) / 1000.0 + suite.add_result(TestResult(name=m.group("name").strip(), status=status, duration=dur)) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- cargo test ------------------------------------------------------- + + def _parse_cargo(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + pattern = re.compile(r"^test\s+(?P\S+)\s+\.\.\.\s+(?Pok|FAILED|ignored)", re.MULTILINE) + for m in pattern.finditer(stdout): + status_map = {"ok": TestStatus.PASSED, "FAILED": TestStatus.FAILED, "ignored": TestStatus.SKIPPED} + suite.add_result( + TestResult(name=m.group("name"), status=status_map.get(m.group("status"), TestStatus.ERROR)) + ) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- go test ---------------------------------------------------------- + + def _parse_go(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + pattern = re.compile( + r"^---\s+(?PPASS|FAIL|SKIP):\s+(?P\S+)\s+\((?P[\d.]+)s\)", + re.MULTILINE, + ) + for m in pattern.finditer(stdout): + status_map = {"PASS": TestStatus.PASSED, "FAIL": TestStatus.FAILED, "SKIP": TestStatus.SKIPPED} + suite.add_result( + TestResult( + name=m.group("name"), + status=status_map.get(m.group("status"), TestStatus.ERROR), + duration=float(m.group("dur")), + ) + ) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- junit (mvn) ------------------------------------------------------ + + def _parse_junit_mvn(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + summary = re.search( + r"Tests run:\s*(?P\d+),\s*Failures:\s*(?P\d+),\s*Errors:\s*(?P\d+),\s*Skipped:\s*(?P\d+)", + stdout, + ) + if summary: + total = int(summary.group("total")) + failed = int(summary.group("fail")) + errors = int(summary.group("err")) + skipped = int(summary.group("skip")) + passed = total - failed - errors - skipped + for i in range(passed): + suite.add_result(TestResult(name=f"test_{i + 1}", status=TestStatus.PASSED)) + for i in range(failed): + suite.add_result(TestResult(name=f"failed_{i + 1}", status=TestStatus.FAILED)) + for i in range(errors): + suite.add_result(TestResult(name=f"error_{i + 1}", status=TestStatus.ERROR)) + for i in range(skipped): + suite.add_result(TestResult(name=f"skipped_{i + 1}", status=TestStatus.SKIPPED)) + return suite + + # -- dotnet test ------------------------------------------------------- + + def _parse_dotnet(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + pattern = re.compile(r"^\s*(?PPassed|Failed|Skipped)\s+(?P\S+)", re.MULTILINE) + for m in pattern.finditer(stdout): + status_map = {"Passed": TestStatus.PASSED, "Failed": TestStatus.FAILED, "Skipped": TestStatus.SKIPPED} + suite.add_result( + TestResult(name=m.group("name"), status=status_map.get(m.group("status"), TestStatus.ERROR)) + ) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- swift test ------------------------------------------------------- + + def _parse_swift(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + pattern = re.compile( + r"^Test Case\s+'-\[(?P[^\]]+)\]'\s+(?Ppassed|failed)\s+\((?P[\d.]+)\s+seconds\)", + re.MULTILINE, + ) + for m in pattern.finditer(stdout + "\n" + stderr): + status = TestStatus.PASSED if m.group("status") == "passed" else TestStatus.FAILED + suite.add_result( + TestResult(name=m.group("name"), status=status, duration=float(m.group("dur"))) + ) + if not suite.tests: + self._fallback_summary(suite, stdout, stderr) + return suite + + # -- generic / fallback ------------------------------------------------ + + def _parse_generic(self, stdout: str, stderr: str, suite_name: str) -> TestSuite: + suite = TestSuite(name=suite_name) + self._fallback_summary(suite, stdout, stderr) + return suite + + @staticmethod + def _fallback_summary(suite: TestSuite, stdout: str, stderr: str) -> None: + """Create a single synthetic result when individual parsing fails.""" + combined = stdout + stderr + if any(kw in combined.lower() for kw in ("passed", "ok", "success")): + suite.add_result(TestResult(name="(summary)", status=TestStatus.PASSED, stdout=stdout, stderr=stderr)) + elif any(kw in combined.lower() for kw in ("failed", "failure", "error")): + suite.add_result(TestResult(name="(summary)", status=TestStatus.FAILED, stdout=stdout, stderr=stderr)) + + # -- coverage ---------------------------------------------------------- + + def _parse_coverage(self, fw: TestFramework) -> CoverageResult: + """Attempt to read coverage output generated by the framework.""" + cov = CoverageResult() + if fw == TestFramework.PYTEST: + cov_file = self.workspace / "coverage.json" + if cov_file.exists(): + try: + data = json.loads(cov_file.read_text()) + totals = data.get("totals", {}) + cov.total_lines = totals.get("num_statements", 0) + cov.covered_lines = totals.get("covered_lines", 0) + cov.percentage = totals.get("percent_covered", 0.0) + except (json.JSONDecodeError, KeyError): + pass + elif fw == TestFramework.GO_TEST: + cov_file = self.workspace / "coverage.out" + if cov_file.exists(): + lines = cov_file.read_text().splitlines() + total = covered = 0 + for line in lines[1:]: + parts = line.rsplit(" ", 2) + if len(parts) >= 3: + stmts = int(parts[-2]) + count = int(parts[-1]) + total += stmts + if count > 0: + covered += stmts + cov.total_lines = total + cov.covered_lines = covered + cov.percentage = (covered / total * 100.0) if total else 0.0 + return cov diff --git a/eostudio/core/enterprise/__init__.py b/eostudio/core/enterprise/__init__.py new file mode 100644 index 0000000..2054feb --- /dev/null +++ b/eostudio/core/enterprise/__init__.py @@ -0,0 +1,7 @@ +"""Enterprise subpackage — auth, SSO, RBAC, audit.""" + +from __future__ import annotations + +from eostudio.core.enterprise.auth import AuthManager, AuthConfig, UserSession, Permission + +__all__ = ["AuthManager", "AuthConfig", "UserSession", "Permission"] \ No newline at end of file diff --git a/eostudio/core/enterprise/__pycache__/__init__.cpython-38.pyc b/eostudio/core/enterprise/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..0cc449a Binary files /dev/null and b/eostudio/core/enterprise/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/enterprise/__pycache__/auth.cpython-38.pyc b/eostudio/core/enterprise/__pycache__/auth.cpython-38.pyc new file mode 100644 index 0000000..d102133 Binary files /dev/null and b/eostudio/core/enterprise/__pycache__/auth.cpython-38.pyc differ diff --git a/eostudio/core/enterprise/auth.py b/eostudio/core/enterprise/auth.py new file mode 100644 index 0000000..9adc0fc --- /dev/null +++ b/eostudio/core/enterprise/auth.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import hashlib +import secrets +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum, auto + + +class AuthProvider(Enum): + LOCAL = auto() + OAUTH2 = auto() + SAML = auto() + OIDC = auto() + LDAP = auto() + + +class Permission(Enum): + READ = auto() + WRITE = auto() + ADMIN = auto() + OWNER = auto() + + +@dataclass +class AuthConfig: + provider: AuthProvider = AuthProvider.LOCAL + client_id: str = "" + client_secret: str = "" + auth_url: str = "" + token_url: str = "" + redirect_uri: str = "" + ldap_server: str = "" + ldap_base_dn: str = "" + + +@dataclass +class UserSession: + user_id: str + username: str + email: str + roles: list[str] = field(default_factory=list) + token: str = "" + expires_at: str = "" + permissions: list[Permission] = field(default_factory=list) + + +@dataclass +class AuditEntry: + timestamp: str + user_id: str + action: str + resource: str + details: str = "" + + +class AuthManager: + def __init__(self, config: AuthConfig | None = None) -> None: + self._config = config or AuthConfig() + self._sessions: dict[str, UserSession] = {} + self._users: dict[str, dict] = {} + self._audit_log: list[AuditEntry] = [] + + def login(self, username: str, password: str) -> UserSession: + user = self._users.get(username) + if user is None: + raise ValueError("Invalid username or password") + pw_hash = hashlib.sha256(password.encode()).hexdigest() + if user["password_hash"] != pw_hash: + raise ValueError("Invalid username or password") + token = secrets.token_urlsafe(32) + session = UserSession( + user_id=user["user_id"], + username=username, + email=user["email"], + roles=user.get("roles", []), + token=token, + expires_at="", + permissions=user.get("permissions", [Permission.READ]), + ) + self._sessions[token] = session + self.log_audit(AuditEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + user_id=user["user_id"], + action="login", + resource="auth", + )) + return session + + def login_oauth(self, code: str) -> UserSession: + token = secrets.token_urlsafe(32) + session = UserSession( + user_id=str(uuid.uuid4()), + username=f"oauth-{code[:8]}", + email="", + roles=["user"], + token=token, + expires_at="", + permissions=[Permission.READ, Permission.WRITE], + ) + self._sessions[token] = session + return session + + def logout(self, session: UserSession) -> None: + self._sessions.pop(session.token, None) + self.log_audit(AuditEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + user_id=session.user_id, + action="logout", + resource="auth", + )) + + def validate_session(self, token: str) -> UserSession | None: + return self._sessions.get(token) + + def has_permission(self, session: UserSession, permission: Permission) -> bool: + return permission in session.permissions + + def create_user( + self, + username: str, + email: str, + password: str, + roles: list[str] | None = None, + ) -> dict: + user_id = str(uuid.uuid4()) + pw_hash = hashlib.sha256(password.encode()).hexdigest() + user = { + "user_id": user_id, + "username": username, + "email": email, + "password_hash": pw_hash, + "roles": roles or ["user"], + "permissions": [Permission.READ, Permission.WRITE], + } + self._users[username] = user + self.log_audit(AuditEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + user_id=user_id, + action="create_user", + resource=f"user:{username}", + )) + return {"user_id": user_id, "username": username, "email": email} + + def get_audit_log(self, user_id: str | None = None) -> list[AuditEntry]: + if user_id: + return [e for e in self._audit_log if e.user_id == user_id] + return list(self._audit_log) + + def log_audit(self, entry: AuditEntry) -> None: + self._audit_log.append(entry) \ No newline at end of file diff --git a/eostudio/core/geometry/__pycache__/__init__.cpython-38.pyc b/eostudio/core/geometry/__pycache__/__init__.cpython-38.pyc index 8046215..f54a2ba 100644 Binary files a/eostudio/core/geometry/__pycache__/__init__.cpython-38.pyc and b/eostudio/core/geometry/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/geometry/__pycache__/curves.cpython-38.pyc b/eostudio/core/geometry/__pycache__/curves.cpython-38.pyc index 5691240..52a0acd 100644 Binary files a/eostudio/core/geometry/__pycache__/curves.cpython-38.pyc and b/eostudio/core/geometry/__pycache__/curves.cpython-38.pyc differ diff --git a/eostudio/core/geometry/__pycache__/primitives.cpython-38.pyc b/eostudio/core/geometry/__pycache__/primitives.cpython-38.pyc index 13f9d8f..bed14a2 100644 Binary files a/eostudio/core/geometry/__pycache__/primitives.cpython-38.pyc and b/eostudio/core/geometry/__pycache__/primitives.cpython-38.pyc differ diff --git a/eostudio/core/geometry/__pycache__/transforms.cpython-38.pyc b/eostudio/core/geometry/__pycache__/transforms.cpython-38.pyc index 5108203..5b30914 100644 Binary files a/eostudio/core/geometry/__pycache__/transforms.cpython-38.pyc and b/eostudio/core/geometry/__pycache__/transforms.cpython-38.pyc differ diff --git a/eostudio/core/ide/__init__.py b/eostudio/core/ide/__init__.py index 1dfe5ed..627e8ac 100644 --- a/eostudio/core/ide/__init__.py +++ b/eostudio/core/ide/__init__.py @@ -8,9 +8,10 @@ from eostudio.core.ide.terminal import TerminalEmulator from eostudio.core.ide.debugger import Debugger from eostudio.core.ide.cloud import CloudSync +from eostudio.core.ide.config_manager import ConfigManager, SecretsManager __all__ = [ "SyntaxHighlighter", "LanguageServer", "GitIntegration", "ExtensionManager", "ProjectManager", "TerminalEmulator", - "Debugger", "CloudSync", -] + "Debugger", "CloudSync", "ConfigManager", "SecretsManager", +] \ No newline at end of file diff --git a/eostudio/core/ide/__pycache__/__init__.cpython-38.pyc b/eostudio/core/ide/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..970aeeb Binary files /dev/null and b/eostudio/core/ide/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/cloud.cpython-38.pyc b/eostudio/core/ide/__pycache__/cloud.cpython-38.pyc new file mode 100644 index 0000000..e56eae8 Binary files /dev/null and b/eostudio/core/ide/__pycache__/cloud.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/config_manager.cpython-38.pyc b/eostudio/core/ide/__pycache__/config_manager.cpython-38.pyc new file mode 100644 index 0000000..85c4b1f Binary files /dev/null and b/eostudio/core/ide/__pycache__/config_manager.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/debugger.cpython-38.pyc b/eostudio/core/ide/__pycache__/debugger.cpython-38.pyc new file mode 100644 index 0000000..ad5a993 Binary files /dev/null and b/eostudio/core/ide/__pycache__/debugger.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/extensions.cpython-38.pyc b/eostudio/core/ide/__pycache__/extensions.cpython-38.pyc new file mode 100644 index 0000000..612d854 Binary files /dev/null and b/eostudio/core/ide/__pycache__/extensions.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/git_integration.cpython-38.pyc b/eostudio/core/ide/__pycache__/git_integration.cpython-38.pyc new file mode 100644 index 0000000..cb8ecce Binary files /dev/null and b/eostudio/core/ide/__pycache__/git_integration.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/language_server.cpython-38.pyc b/eostudio/core/ide/__pycache__/language_server.cpython-38.pyc new file mode 100644 index 0000000..14e336e Binary files /dev/null and b/eostudio/core/ide/__pycache__/language_server.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/project_manager.cpython-38.pyc b/eostudio/core/ide/__pycache__/project_manager.cpython-38.pyc new file mode 100644 index 0000000..7a64597 Binary files /dev/null and b/eostudio/core/ide/__pycache__/project_manager.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/syntax.cpython-38.pyc b/eostudio/core/ide/__pycache__/syntax.cpython-38.pyc new file mode 100644 index 0000000..9329c58 Binary files /dev/null and b/eostudio/core/ide/__pycache__/syntax.cpython-38.pyc differ diff --git a/eostudio/core/ide/__pycache__/terminal.cpython-38.pyc b/eostudio/core/ide/__pycache__/terminal.cpython-38.pyc new file mode 100644 index 0000000..2e52369 Binary files /dev/null and b/eostudio/core/ide/__pycache__/terminal.cpython-38.pyc differ diff --git a/eostudio/core/ide/cloud.py b/eostudio/core/ide/cloud.py index f097b9a..f160936 100644 --- a/eostudio/core/ide/cloud.py +++ b/eostudio/core/ide/cloud.py @@ -1,24 +1,344 @@ -"""Cloud sync (stub).""" +"""Cloud sync for EoStudio — settings sync, workspace state, and secure storage.""" from __future__ import annotations -from typing import Optional +import base64 +import hashlib +import json +import os +import time +import zipfile +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_EOSTUDIO_DIR = Path.home() / ".eostudio" +_WORKSPACE_STATE_FILE = _EOSTUDIO_DIR / "workspace_state.json" +_SECURE_STORAGE_FILE = _EOSTUDIO_DIR / "credentials.enc.json" +_SETTINGS_CACHE_FILE = _EOSTUDIO_DIR / "settings_cache.json" + + +@dataclass +class SyncConfig: + """Configuration for cloud sync.""" + + endpoint: str = "" + auth_token: str = "" + auto_sync: bool = False + sync_interval_seconds: int = 300 + + +@dataclass +class WorkspaceState: + """Serialisable snapshot of the current workspace.""" + + open_files: List[str] = field(default_factory=list) + cursor_positions: Dict[str, Dict[str, int]] = field(default_factory=dict) + terminal_sessions: List[Dict[str, Any]] = field(default_factory=list) + active_editor: str = "" + window_layout: Dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Secure Storage +# --------------------------------------------------------------------------- + + +class SecureStorage: + """Credential storage with OS keychain support (keyring) or encrypted JSON fallback.""" + + _SERVICE_NAME = "eostudio" + + def __init__(self) -> None: + self._keyring: Any = None + self._use_keyring = False + try: + import keyring as _kr # lazy import + self._keyring = _kr + # Probe that the backend is functional. + _kr.get_password(self._SERVICE_NAME, "__probe__") + self._use_keyring = True + except Exception: + self._use_keyring = False + + # -- public API --------------------------------------------------------- + + def store(self, key: str, value: str) -> None: + """Store a credential.""" + if self._use_keyring: + self._keyring.set_password(self._SERVICE_NAME, key, value) + else: + self._file_store(key, value) + + def retrieve(self, key: str) -> Optional[str]: + """Retrieve a credential. Returns *None* if not found.""" + if self._use_keyring: + return self._keyring.get_password(self._SERVICE_NAME, key) + return self._file_retrieve(key) + + def delete(self, key: str) -> bool: + """Delete a credential. Returns *True* if it existed.""" + if self._use_keyring: + try: + self._keyring.delete_password(self._SERVICE_NAME, key) + return True + except Exception: + return False + return self._file_delete(key) + + # -- encrypted JSON fallback -------------------------------------------- + + @staticmethod + def _derive_key() -> bytes: + """Derive a machine-local obfuscation key (NOT cryptographic security).""" + raw = f"{os.getlogin()}-{SecureStorage._SERVICE_NAME}-local" + return hashlib.sha256(raw.encode()).digest() + + @staticmethod + def _xor_bytes(data: bytes, key: bytes) -> bytes: + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + + def _load_store(self) -> Dict[str, str]: + if not _SECURE_STORAGE_FILE.exists(): + return {} + try: + blob = json.loads(_SECURE_STORAGE_FILE.read_text(encoding="utf-8")) + return { + k: self._xor_bytes(base64.b64decode(v), self._derive_key()).decode() + for k, v in blob.items() + } + except Exception: + return {} + + def _save_store(self, store: Dict[str, str]) -> None: + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + key = self._derive_key() + blob = { + k: base64.b64encode(self._xor_bytes(v.encode(), key)).decode() + for k, v in store.items() + } + _SECURE_STORAGE_FILE.write_text(json.dumps(blob, indent=2), encoding="utf-8") + + def _file_store(self, key: str, value: str) -> None: + store = self._load_store() + store[key] = value + self._save_store(store) + + def _file_retrieve(self, key: str) -> Optional[str]: + return self._load_store().get(key) + + def _file_delete(self, key: str) -> bool: + store = self._load_store() + if key not in store: + return False + del store[key] + self._save_store(store) + return True + + +# --------------------------------------------------------------------------- +# Cloud Sync +# --------------------------------------------------------------------------- class CloudSync: - def __init__(self, endpoint: str = "") -> None: - self.endpoint = endpoint + """Settings sync and workspace state management. + + Backward-compatible with the original stub API: + ``__init__(endpoint=""), connect(), disconnect(), is_connected(), sync()`` + """ + + def __init__(self, endpoint: str = "", *, config: Optional[SyncConfig] = None) -> None: + if config is not None: + self._config = config + else: + self._config = SyncConfig(endpoint=endpoint) + # Expose ``.endpoint`` for backward compat. + self.endpoint = self._config.endpoint self._connected = False + self._client: Any = None # httpx.Client, created lazily + self._last_sync: float = 0.0 + self._secure_storage = SecureStorage() + + # -- connection lifecycle (backward compat) ---------------------------- def connect(self) -> bool: - self._connected = False + """Establish a connection to the sync endpoint.""" + if not self._config.endpoint: + self._connected = False + return False + try: + client = self._get_client() + resp = client.get( + self._url("/health"), + timeout=5.0, + ) + self._connected = resp.status_code == 200 + except Exception: + self._connected = False return self._connected def disconnect(self) -> None: + """Close the connection.""" self._connected = False + if self._client is not None: + try: + self._client.close() + except Exception: + pass + self._client = None def is_connected(self) -> bool: return self._connected def sync(self) -> bool: + """Full round-trip sync (backward compat). + + Pushes locally-cached settings if available, then pulls remote settings + and writes them to the local cache. Returns *True* on success. + """ + if not self._connected: + return False + try: + local = self._read_settings_cache() + if local: + self.sync_settings(local) + remote = self.fetch_settings() + if remote: + self._write_settings_cache(remote) + self._last_sync = time.time() + return True + except Exception: + pass return False + + # -- settings sync ------------------------------------------------------ + + def sync_settings(self, settings: dict) -> bool: + """Upload *settings* as JSON to the configured endpoint.""" + self._write_settings_cache(settings) # local-first + if not self._connected: + return False + try: + client = self._get_client() + resp = client.put( + self._url("/settings"), + json=settings, + timeout=10.0, + ) + return 200 <= resp.status_code < 300 + except Exception: + return False + + def fetch_settings(self) -> dict: + """Download settings from the remote endpoint. + + Falls back to the local cache when offline. + """ + if self._connected: + try: + client = self._get_client() + resp = client.get(self._url("/settings"), timeout=10.0) + if resp.status_code == 200: + data: dict = resp.json() + self._write_settings_cache(data) + return data + except Exception: + pass + return self._read_settings_cache() + + # -- workspace state ---------------------------------------------------- + + @staticmethod + def save_workspace_state(state: WorkspaceState) -> None: + """Persist *state* locally to ``~/.eostudio/workspace_state.json``.""" + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + _WORKSPACE_STATE_FILE.write_text( + json.dumps(asdict(state), indent=2), + encoding="utf-8", + ) + + @staticmethod + def load_workspace_state() -> WorkspaceState: + """Load the workspace state from disk. Returns defaults when absent.""" + if not _WORKSPACE_STATE_FILE.exists(): + return WorkspaceState() + try: + data = json.loads(_WORKSPACE_STATE_FILE.read_text(encoding="utf-8")) + return WorkspaceState(**data) + except Exception: + return WorkspaceState() + + # -- import / export ---------------------------------------------------- + + def export_settings(self, path: str) -> None: + """Export settings + workspace state as a ``.zip`` bundle.""" + dest = Path(path) + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf: + cached = self._read_settings_cache() + zf.writestr("settings.json", json.dumps(cached, indent=2)) + if _WORKSPACE_STATE_FILE.exists(): + zf.write(_WORKSPACE_STATE_FILE, "workspace_state.json") + + @staticmethod + def import_settings(path: str) -> None: + """Import a settings bundle from a ``.zip`` and write it locally.""" + src = Path(path) + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(src, "r") as zf: + if "settings.json" in zf.namelist(): + _SETTINGS_CACHE_FILE.write_text( + zf.read("settings.json").decode("utf-8"), + encoding="utf-8", + ) + if "workspace_state.json" in zf.namelist(): + _WORKSPACE_STATE_FILE.write_text( + zf.read("workspace_state.json").decode("utf-8"), + encoding="utf-8", + ) + + # -- secure storage pass-through --------------------------------------- + + @property + def secure_storage(self) -> SecureStorage: + return self._secure_storage + + # -- internals ---------------------------------------------------------- + + def _get_client(self) -> Any: + if self._client is None: + import httpx # lazy import + + headers: Dict[str, str] = {} + if self._config.auth_token: + headers["Authorization"] = f"Bearer {self._config.auth_token}" + self._client = httpx.Client(headers=headers) + return self._client + + def _url(self, path: str) -> str: + base = self._config.endpoint.rstrip("/") + return f"{base}{path}" + + # -- local settings cache ----------------------------------------------- + + @staticmethod + def _read_settings_cache() -> dict: + if not _SETTINGS_CACHE_FILE.exists(): + return {} + try: + return json.loads(_SETTINGS_CACHE_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + + @staticmethod + def _write_settings_cache(settings: dict) -> None: + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + _SETTINGS_CACHE_FILE.write_text( + json.dumps(settings, indent=2), + encoding="utf-8", + ) diff --git a/eostudio/core/ide/config_manager.py b/eostudio/core/ide/config_manager.py new file mode 100755 index 0000000..6fc4370 --- /dev/null +++ b/eostudio/core/ide/config_manager.py @@ -0,0 +1,693 @@ +""" +EoStudio Configuration Manager. + +Hierarchical configuration system with schema validation, secrets management, +environment variable overrides, and change notifications. + +Scope resolution order (highest priority first): + FOLDER -> WORKSPACE -> USER -> SYSTEM -> DEFAULT +""" +from __future__ import annotations + +import base64 +import copy +import enum +import hashlib +import json +import logging +import os +import platform +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Type + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Enums & Data Classes +# --------------------------------------------------------------------------- + + +class ConfigScope(enum.IntEnum): + """Configuration scopes ordered from lowest to highest priority.""" + + DEFAULT = 0 + SYSTEM = 1 + USER = 2 + WORKSPACE = 3 + FOLDER = 4 + + +@dataclass +class ConfigSchema: + """Schema definition for a configuration key.""" + + key: str + type: Type + default: Any + description: str = "" + enum_values: Optional[List[Any]] = None + deprecated: bool = False + + +# --------------------------------------------------------------------------- +# Built-in default schemas +# --------------------------------------------------------------------------- + +_BUILTIN_SCHEMAS: List[ConfigSchema] = [ + ConfigSchema("editor.fontSize", int, 14, "Font size in pixels for the editor."), + ConfigSchema("editor.tabSize", int, 4, "Number of spaces per tab."), + ConfigSchema("editor.insertSpaces", bool, True, "Insert spaces when pressing Tab."), + ConfigSchema( + "editor.theme", + str, + "dark", + "Color theme for the editor.", + enum_values=["dark", "light", "high-contrast"], + ), + ConfigSchema( + "editor.wordWrap", str, "off", "Word wrap mode.", + enum_values=["off", "on", "wordWrapColumn", "bounded"], + ), + ConfigSchema( + "editor.lineNumbers", str, "on", "Line number rendering.", + enum_values=["off", "on", "relative"], + ), + ConfigSchema("editor.minimap.enabled", bool, True, "Show minimap."), + ConfigSchema("editor.formatOnSave", bool, False, "Format the file on save."), + ConfigSchema( + "editor.autoSave", str, "off", "Auto-save mode.", + enum_values=["off", "afterDelay", "onFocusChange"], + ), + ConfigSchema("editor.autoSaveDelay", int, 1000, "Auto-save delay in milliseconds."), + ConfigSchema( + "editor.renderWhitespace", str, "selection", "Whitespace rendering.", + enum_values=["none", "boundary", "selection", "all"], + ), + ConfigSchema("terminal.shell", str, "", "Path to the default terminal shell."), + ConfigSchema("terminal.fontSize", int, 13, "Font size for the integrated terminal."), + ConfigSchema( + "terminal.cursorStyle", str, "block", "Terminal cursor style.", + enum_values=["block", "underline", "line"], + ), + ConfigSchema("files.encoding", str, "utf-8", "Default file encoding."), + ConfigSchema("files.autoGuessEncoding", bool, False, "Auto-detect file encoding."), + ConfigSchema( + "files.trimTrailingWhitespace", bool, False, + "Trim trailing whitespace on save.", + ), + ConfigSchema( + "files.insertFinalNewline", bool, False, + "Insert a final newline at end of file on save.", + ), + ConfigSchema("files.exclude", dict, {}, "Glob patterns for files to exclude."), + ConfigSchema("search.exclude", dict, {}, "Glob patterns for search exclusion."), + ConfigSchema("workbench.sideBar.visible", bool, True, "Show the side bar."), + ConfigSchema("workbench.statusBar.visible", bool, True, "Show the status bar."), + ConfigSchema("window.title", str, "EoStudio", "Window title template."), + ConfigSchema("debug.console.fontSize", int, 13, "Font size for the debug console."), + ConfigSchema("telemetry.enabled", bool, True, "Enable telemetry."), +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _deep_merge(base: Dict, override: Dict) -> Dict: + """Recursively merge *override* into a copy of *base*.""" + merged = copy.deepcopy(base) + for key, value in override.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _read_json(path: Path) -> Dict: + """Read a JSON file, returning an empty dict on any failure.""" + try: + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Failed to read config %s: %s", path, exc) + return {} + + +def _write_json(path: Path, data: Dict) -> None: + """Atomically write *data* as pretty-printed JSON to *path*.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + try: + tmp.write_text( + json.dumps(data, indent=4, sort_keys=True) + "\n", encoding="utf-8" + ) + tmp.replace(path) + except OSError as exc: + logger.error("Failed to write config %s: %s", path, exc) + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise + + +def _env_key(config_key: str) -> str: + """Convert a dotted config key to an EOSTUDIO_ environment variable name. + + Example: ``editor.fontSize`` -> ``EOSTUDIO_EDITOR_FONTSIZE`` + """ + return "EOSTUDIO_" + config_key.replace(".", "_").upper() + + +def _coerce_env(value_str: str, target_type: Type) -> Any: + """Best-effort coercion of an env-var string to *target_type*.""" + if target_type is bool: + return value_str.lower() in ("1", "true", "yes") + if target_type is int: + return int(value_str) + if target_type is float: + return float(value_str) + if target_type is dict or target_type is list: + return json.loads(value_str) + return value_str + + +# --------------------------------------------------------------------------- +# SecretsManager +# --------------------------------------------------------------------------- + + +class SecretsManager: + """Secure credential storage. + + Attempts to use the OS keychain via the optional ``keyring`` package. + Falls back to an XOR-obfuscated JSON file at ``~/.eostudio/secrets.json`` + when ``keyring`` is unavailable. + + Note: The file-based fallback provides obfuscation, not strong encryption. + Install ``keyring`` (``pip install keyring``) for production use. + """ + + _SERVICE = "eostudio" + + def __init__(self) -> None: + self._keyring = self._try_import_keyring() + self._fallback_path = Path.home() / ".eostudio" / "secrets.json" + self._lock = threading.Lock() + + # -- public API --------------------------------------------------------- + + def get_secret(self, key: str) -> Optional[str]: + """Retrieve a secret by *key*. Returns ``None`` if not found.""" + if self._keyring is not None: + try: + return self._keyring.get_password(self._SERVICE, key) + except Exception as exc: + logger.warning("Keyring get failed for %r: %s", key, exc) + return self._fallback_get(key) + + def set_secret(self, key: str, value: str) -> None: + """Store a secret.""" + if self._keyring is not None: + try: + self._keyring.set_password(self._SERVICE, key, value) + return + except Exception as exc: + logger.warning("Keyring set failed for %r: %s", key, exc) + self._fallback_set(key, value) + + def delete_secret(self, key: str) -> None: + """Delete a secret.""" + if self._keyring is not None: + try: + self._keyring.delete_password(self._SERVICE, key) + return + except Exception as exc: + logger.warning("Keyring delete failed for %r: %s", key, exc) + self._fallback_delete(key) + + def list_secrets(self) -> List[str]: + """List stored secret keys (file-based backend only).""" + store = self._load_fallback() + return list(store.keys()) + + # -- keyring import ----------------------------------------------------- + + @staticmethod + def _try_import_keyring(): + try: + import keyring # type: ignore[import-untyped] + return keyring + except ImportError: + return None + + # -- file-based fallback ------------------------------------------------ + + def _derive_key(self) -> bytes: + """Derive a machine-local obfuscation key.""" + seed = ( + f"{platform.node()}-" + f"{os.getlogin() if hasattr(os, 'getlogin') else 'user'}-" + f"eostudio" + ) + return hashlib.sha256(seed.encode()).digest() + + def _xor_bytes(self, data: bytes) -> bytes: + key = self._derive_key() + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + + def _load_fallback(self) -> Dict[str, str]: + with self._lock: + if not self._fallback_path.is_file(): + return {} + try: + raw = self._fallback_path.read_bytes() + decrypted = self._xor_bytes(base64.b64decode(raw)) + return json.loads(decrypted.decode("utf-8")) + except Exception as exc: + logger.warning("Failed to read secrets file: %s", exc) + return {} + + def _save_fallback(self, store: Dict[str, str]) -> None: + with self._lock: + self._fallback_path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(store, sort_keys=True).encode("utf-8") + encoded = base64.b64encode(self._xor_bytes(payload)) + tmp = self._fallback_path.with_suffix(".tmp") + try: + tmp.write_bytes(encoded) + tmp.replace(self._fallback_path) + except OSError as exc: + logger.error("Failed to write secrets: %s", exc) + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise + + def _fallback_get(self, key: str) -> Optional[str]: + return self._load_fallback().get(key) + + def _fallback_set(self, key: str, value: str) -> None: + store = self._load_fallback() + store[key] = value + self._save_fallback(store) + + def _fallback_delete(self, key: str) -> None: + store = self._load_fallback() + if key in store: + del store[key] + self._save_fallback(store) + + +# --------------------------------------------------------------------------- +# ConfigManager +# --------------------------------------------------------------------------- + + +class ConfigManager: + """Hierarchical configuration manager for EoStudio. + + Resolution order (highest priority wins):: + + env vars -> FOLDER -> WORKSPACE -> USER -> SYSTEM -> DEFAULT + + Usage:: + + cfg = ConfigManager() + cfg.set_workspace_path("/path/to/project") + font = cfg.get("editor.fontSize") # 14 (default) + cfg.set("editor.fontSize", 16) # persisted in USER scope + cfg.on_change("editor.fontSize", lambda k, v: print(k, v)) + """ + + def __init__(self, workspace_path: Optional[str] = None) -> None: + self._lock = threading.RLock() + self._schemas: Dict[str, ConfigSchema] = {} + self._listeners: Dict[str, List[Callable]] = {} + self._workspace_path: Optional[Path] = ( + Path(workspace_path) if workspace_path else None + ) + self._folder_paths: List[Path] = [] + + # Scope -> in-memory cache (loaded lazily on first access) + self._caches: Dict[ConfigScope, Optional[Dict]] = { + ConfigScope.DEFAULT: None, + ConfigScope.SYSTEM: None, + ConfigScope.USER: None, + ConfigScope.WORKSPACE: None, + ConfigScope.FOLDER: None, + } + + # Register built-in schemas + for schema in _BUILTIN_SCHEMAS: + self.register_schema(schema) + + # -- scope file paths --------------------------------------------------- + + @staticmethod + def _system_config_path() -> Path: + if platform.system() == "Windows": + base = os.environ.get("PROGRAMDATA", r"C:\ProgramData") + return Path(base) / "eostudio" / "settings.json" + return Path("/etc/eostudio/settings.json") + + @staticmethod + def _user_config_path() -> Path: + return Path.home() / ".eostudio" / "settings.json" + + def _workspace_config_path(self) -> Optional[Path]: + if self._workspace_path: + return self._workspace_path / ".eostudio" / "settings.json" + return None + + def _folder_config_paths(self) -> List[Path]: + return [p / ".eostudio" / "settings.json" for p in self._folder_paths] + + def _path_for_scope(self, scope: ConfigScope) -> Optional[Path]: + if scope == ConfigScope.SYSTEM: + return self._system_config_path() + if scope == ConfigScope.USER: + return self._user_config_path() + if scope == ConfigScope.WORKSPACE: + return self._workspace_config_path() + return None # DEFAULT and FOLDER handled separately + + # -- loading / caching -------------------------------------------------- + + def _load_scope(self, scope: ConfigScope) -> Dict: + if scope == ConfigScope.DEFAULT: + return {s.key: s.default for s in self._schemas.values()} + if scope == ConfigScope.FOLDER: + merged: Dict = {} + for p in self._folder_config_paths(): + merged = _deep_merge(merged, _read_json(p)) + return merged + path = self._path_for_scope(scope) + return _read_json(path) if path else {} + + def _get_scope_data(self, scope: ConfigScope) -> Dict: + with self._lock: + if self._caches[scope] is None: + self._caches[scope] = self._load_scope(scope) + return self._caches[scope] # type: ignore[return-value] + + def _invalidate(self, scope: ConfigScope) -> None: + with self._lock: + self._caches[scope] = None + + def _invalidate_all(self) -> None: + with self._lock: + for scope in ConfigScope: + self._caches[scope] = None + + def reload(self) -> None: + """Force reload of all config scopes from disk.""" + self._invalidate_all() + + # -- public API: workspace / folder paths -------------------------------- + + def set_workspace_path(self, path: str) -> None: + """Set the workspace root directory.""" + self._workspace_path = Path(path) + self._invalidate(ConfigScope.WORKSPACE) + + def add_folder_path(self, path: str) -> None: + """Add a folder root for multi-root workspace support.""" + p = Path(path) + if p not in self._folder_paths: + self._folder_paths.append(p) + self._invalidate(ConfigScope.FOLDER) + + def remove_folder_path(self, path: str) -> None: + """Remove a folder root.""" + p = Path(path) + if p in self._folder_paths: + self._folder_paths.remove(p) + self._invalidate(ConfigScope.FOLDER) + + # -- schema management -------------------------------------------------- + + def register_schema(self, schema: ConfigSchema) -> None: + """Register (or update) a configuration schema.""" + with self._lock: + self._schemas[schema.key] = schema + self._invalidate(ConfigScope.DEFAULT) + + def get_schema(self, key: str) -> Optional[ConfigSchema]: + """Return the schema for *key*, or ``None`` if unregistered.""" + return self._schemas.get(key) + + def validate(self, key: str, value: Any) -> bool: + """Validate *value* against the schema registered for *key*. + + Returns ``True`` if valid or if no schema is registered. + """ + schema = self._schemas.get(key) + if schema is None: + return True + if not isinstance(value, schema.type): + return False + if schema.enum_values is not None and value not in schema.enum_values: + return False + return True + + # -- environment variable overrides ------------------------------------- + + def _env_override(self, key: str) -> Optional[Any]: + """Check for an ``EOSTUDIO_*`` environment variable override.""" + env_name = _env_key(key) + raw = os.environ.get(env_name) + if raw is None: + return None + schema = self._schemas.get(key) + target_type = schema.type if schema else str + try: + return _coerce_env(raw, target_type) + except (ValueError, json.JSONDecodeError) as exc: + logger.warning("Bad env override %s=%r: %s", env_name, raw, exc) + return None + + # -- core get / set / delete -------------------------------------------- + + def get(self, key: str, default: Any = None) -> Any: + """Get the effective value for *key*. + + Resolution: env var -> folder -> workspace -> user -> system -> + defaults -> *default*. + """ + schema = self._schemas.get(key) + if schema and schema.deprecated: + logger.warning("Config key %r is deprecated.", key) + + # Env override wins + env = self._env_override(key) + if env is not None: + return env + + # Walk scopes from highest to lowest priority + for scope in reversed(ConfigScope): + data = self._get_scope_data(scope) + if key in data: + return data[key] + return default + + def set( + self, + key: str, + value: Any, + scope: ConfigScope = ConfigScope.USER, + ) -> None: + """Persist *value* for *key* in the given *scope*. + + Raises ``ValueError`` if the value fails schema validation or if + attempting to write to the DEFAULT scope. + """ + if scope == ConfigScope.DEFAULT: + raise ValueError( + "Cannot write to DEFAULT scope; register a schema instead." + ) + + if not self.validate(key, value): + schema = self._schemas.get(key) + raise ValueError( + f"Invalid value {value!r} for {key!r}. " + f"Expected type={schema.type.__name__}, " # type: ignore[union-attr] + f"enum_values={schema.enum_values}" # type: ignore[union-attr] + ) + + old_value = self.get(key) + + if scope == ConfigScope.FOLDER: + if not self._folder_paths: + raise ValueError( + "No folder paths configured. Call add_folder_path() first." + ) + path = self._folder_paths[0] / ".eostudio" / "settings.json" + else: + path = self._path_for_scope(scope) + if path is None: + raise ValueError( + f"No config path available for scope {scope.name}." + ) + + data = _read_json(path) + data[key] = value + _write_json(path, data) + self._invalidate(scope) + + new_value = self.get(key) + if new_value != old_value: + self._fire_listeners(key, new_value) + + def delete(self, key: str, scope: ConfigScope = ConfigScope.USER) -> None: + """Remove *key* from the given *scope*.""" + if scope == ConfigScope.DEFAULT: + raise ValueError("Cannot delete from DEFAULT scope.") + + old_value = self.get(key) + + if scope == ConfigScope.FOLDER: + for folder in self._folder_paths: + p = folder / ".eostudio" / "settings.json" + data = _read_json(p) + if key in data: + del data[key] + _write_json(p, data) + else: + path = self._path_for_scope(scope) + if path is None: + return + data = _read_json(path) + if key in data: + del data[key] + _write_json(path, data) + + self._invalidate(scope) + + new_value = self.get(key) + if new_value != old_value: + self._fire_listeners(key, new_value) + + # -- bulk queries ------------------------------------------------------- + + def get_all(self) -> Dict: + """Return the fully merged configuration (all scopes + env overrides).""" + merged: Dict = {} + for scope in ConfigScope: + merged = _deep_merge(merged, self._get_scope_data(scope)) + + # Apply env overrides on top + for key in list(merged.keys()) + [s.key for s in self._schemas.values()]: + env = self._env_override(key) + if env is not None: + merged[key] = env + return merged + + def get_scope(self, scope: ConfigScope) -> Dict: + """Return the raw configuration for a single *scope*.""" + return copy.deepcopy(self._get_scope_data(scope)) + + def list_keys(self, scope: Optional[ConfigScope] = None) -> List[str]: + """List all known keys. + + If *scope* is given, only keys in that scope are returned. + Otherwise returns the union across all scopes plus registered schemas. + """ + if scope is not None: + return sorted(self._get_scope_data(scope).keys()) + keys: set[str] = set() + for s in ConfigScope: + keys.update(self._get_scope_data(s).keys()) + keys.update(self._schemas.keys()) + return sorted(keys) + + # -- reset -------------------------------------------------------------- + + def reset( + self, + key: Optional[str] = None, + scope: ConfigScope = ConfigScope.USER, + ) -> None: + """Reset *key* (or all keys) in *scope* to defaults. + + If *key* is ``None``, the entire scope file is cleared. + """ + if key is None: + if scope == ConfigScope.DEFAULT: + raise ValueError("Cannot reset DEFAULT scope.") + if scope == ConfigScope.FOLDER: + for folder in self._folder_paths: + p = folder / ".eostudio" / "settings.json" + if p.is_file(): + _write_json(p, {}) + else: + path = self._path_for_scope(scope) + if path and path.is_file(): + _write_json(path, {}) + self._invalidate(scope) + else: + self.delete(key, scope) + + # -- import / export ---------------------------------------------------- + + def export_config(self, scope: ConfigScope, path: str) -> None: + """Export the configuration for *scope* to a JSON file at *path*.""" + data = self.get_scope(scope) + _write_json(Path(path), data) + + def import_config( + self, path: str, scope: ConfigScope = ConfigScope.USER + ) -> None: + """Import configuration from a JSON file into *scope*. + + Existing keys in *scope* are merged (imported values win). + """ + if scope == ConfigScope.DEFAULT: + raise ValueError("Cannot import into DEFAULT scope.") + incoming = _read_json(Path(path)) + if not incoming: + return + + target = self._path_for_scope(scope) + if scope == ConfigScope.FOLDER: + if not self._folder_paths: + raise ValueError("No folder paths configured.") + target = self._folder_paths[0] / ".eostudio" / "settings.json" + if target is None: + raise ValueError(f"No config path for scope {scope.name}.") + + existing = _read_json(target) + merged = _deep_merge(existing, incoming) + _write_json(target, merged) + self._invalidate(scope) + + # -- change listeners --------------------------------------------------- + + def on_change(self, key: str, callback: Callable) -> Callable: + """Register *callback* to be invoked when *key* changes. + + Callback signature: ``callback(key: str, new_value: Any)``. + Returns *callback* for use as a decorator. + """ + with self._lock: + self._listeners.setdefault(key, []).append(callback) + return callback + + def remove_listener(self, key: str, callback: Callable) -> None: + """Remove a previously registered change listener.""" + with self._lock: + cbs = self._listeners.get(key, []) + if callback in cbs: + cbs.remove(callback) + + def _fire_listeners(self, key: str, new_value: Any) -> None: + with self._lock: + callbacks = list(self._listeners.get(key, [])) + for cb in callbacks: + try: + cb(key, new_value) + except Exception: + logger.exception( + "Error in config change listener for %r", key + ) diff --git a/eostudio/core/ide/debugger.py b/eostudio/core/ide/debugger.py index e423b0a..04d2f8d 100644 --- a/eostudio/core/ide/debugger.py +++ b/eostudio/core/ide/debugger.py @@ -1,30 +1,920 @@ -"""Debugger (stub).""" - from __future__ import annotations -from typing import Any, Dict, List, Optional +import json +import os +import shutil +import signal +import socket +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class DAPMessage: + """A Debug Adapter Protocol message (request, response, or event).""" + + seq: int + type: str # "request" | "response" | "event" + command: Optional[str] = None + arguments: Optional[Dict[str, Any]] = None + request_seq: Optional[int] = None + success: Optional[bool] = None + message: Optional[str] = None + body: Optional[Dict[str, Any]] = None + event: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"seq": self.seq, "type": self.type} + if self.command is not None: + d["command"] = self.command + if self.arguments is not None: + d["arguments"] = self.arguments + if self.request_seq is not None: + d["request_seq"] = self.request_seq + if self.success is not None: + d["success"] = self.success + if self.message is not None: + d["message"] = self.message + if self.body is not None: + d["body"] = self.body + if self.event is not None: + d["event"] = self.event + return d + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> DAPMessage: + return cls( + seq=d.get("seq", 0), + type=d.get("type", ""), + command=d.get("command"), + arguments=d.get("arguments"), + request_seq=d.get("request_seq"), + success=d.get("success"), + message=d.get("message"), + body=d.get("body"), + event=d.get("event"), + ) + + def encode(self) -> bytes: + """Encode to DAP wire format (Content-Length framing).""" + payload = json.dumps(self.to_dict()).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + return header + payload + + +@dataclass +class Breakpoint: + """Represents a breakpoint set in the debugger.""" + + file: str + line: int + condition: Optional[str] = None + hit_count: Optional[int] = None + log_message: Optional[str] = None + enabled: bool = True + # Populated after verification by the debug adapter + id: Optional[int] = None + verified: bool = False + + +@dataclass +class StackFrame: + """Represents a single frame in the call stack.""" + + id: int + name: str + source_path: str + line: int + column: int + + +@dataclass +class Variable: + """Represents a variable visible during debugging.""" + + name: str + value: str + type: str = "" + children: List[Variable] = field(default_factory=list) + expandable: bool = False + variables_reference: int = 0 + +class DebugType(str, Enum): + PYTHON = "python" + NODE = "node" + CPP = "cpp" + + +@dataclass +class DebugConfig: + """Configuration for launching a debug session.""" + + program: str + args: List[str] = field(default_factory=list) + cwd: Optional[str] = None + env: Optional[Dict[str, str]] = None + stop_on_entry: bool = False + type: str = "python" # python | node | cpp + + +# --------------------------------------------------------------------------- +# DAP Transport +# --------------------------------------------------------------------------- + +class _DAPTransport: + """Handles DAP JSON messaging over stdio with Content-Length framing.""" + + def __init__(self) -> None: + self._process: Optional[subprocess.Popen] = None + self._seq = 0 + self._lock = threading.Lock() + self._pending: Dict[int, threading.Event] = {} + self._responses: Dict[int, DAPMessage] = {} + self._reader_thread: Optional[threading.Thread] = None + self._running = False + self._event_handlers: Dict[str, List[Callable[[DAPMessage], None]]] = {} + self._socket: Optional[socket.socket] = None + self._socket_rfile: Optional[Any] = None + self._socket_wfile: Optional[Any] = None + + # -- connection lifecycle ------------------------------------------------ + + def start_process(self, cmd: List[str], cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None) -> None: + merged_env: Optional[Dict[str, str]] = None + if env: + merged_env = {**os.environ, **env} + + self._process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=cwd, + env=merged_env, + ) + self._running = True + self._reader_thread = threading.Thread( + target=self._read_loop, daemon=True + ) + self._reader_thread.start() + + def connect_socket(self, host: str, port: int, timeout: float = 10.0) -> None: + """Connect to a debug adapter via TCP socket.""" + deadline = time.monotonic() + timeout + sock: Optional[socket.socket] = None + while time.monotonic() < deadline: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(max(0.5, deadline - time.monotonic())) + sock.connect((host, port)) + break + except OSError: + if sock: + sock.close() + sock = None + time.sleep(0.25) + if sock is None: + raise ConnectionError(f"Cannot connect to {host}:{port}") + self._socket = sock + self._socket_rfile = sock.makefile("rb") + self._socket_wfile = sock.makefile("wb") + self._running = True + self._reader_thread = threading.Thread( + target=self._read_loop, daemon=True + ) + self._reader_thread.start() + + def shutdown(self) -> None: + self._running = False + if self._process: + try: + if self._process.stdin: + self._process.stdin.close() + self._process.terminate() + self._process.wait(timeout=5) + except Exception: + try: + self._process.kill() + except Exception: + pass + self._process = None + if self._socket: + try: + self._socket.close() + except Exception: + pass + self._socket = None + self._socket_rfile = None + self._socket_wfile = None + # Wake any pending requests + for evt in self._pending.values(): + evt.set() + self._pending.clear() + self._responses.clear() + + # -- sending / receiving ------------------------------------------------- + + def _next_seq(self) -> int: + with self._lock: + self._seq += 1 + return self._seq + + def send_request(self, command: str, + arguments: Optional[Dict[str, Any]] = None, + timeout: float = 30.0) -> DAPMessage: + seq = self._next_seq() + msg = DAPMessage( + seq=seq, type="request", command=command, arguments=arguments + ) + event = threading.Event() + self._pending[seq] = event + self._write(msg.encode()) + if not event.wait(timeout=timeout): + self._pending.pop(seq, None) + return DAPMessage( + seq=0, type="response", request_seq=seq, + success=False, command=command, + message="Request timed out", + ) + return self._responses.pop(seq, DAPMessage( + seq=0, type="response", request_seq=seq, + success=False, command=command, message="No response", + )) + + def _write(self, data: bytes) -> None: + try: + if self._socket_wfile: + self._socket_wfile.write(data) + self._socket_wfile.flush() + elif self._process and self._process.stdin: + self._process.stdin.write(data) + self._process.stdin.flush() + except Exception: + pass + + def _read_loop(self) -> None: + while self._running: + try: + msg = self._read_message() + if msg is None: + break + self._dispatch(msg) + except Exception: + if self._running: + continue + break + + def _read_message(self) -> Optional[DAPMessage]: + stream = self._socket_rfile if self._socket_rfile else ( + self._process.stdout if self._process else None + ) + if stream is None: + return None + + headers: Dict[str, str] = {} + while True: + raw_line = stream.readline() + if not raw_line: + return None + line = raw_line.decode("ascii", errors="replace").strip() + if not line: + break + if ":" in line: + key, _, val = line.partition(":") + headers[key.strip().lower()] = val.strip() + + length_str = headers.get("content-length") + if not length_str: + return None + try: + length = int(length_str) + except ValueError: + return None + + body = b"" + while len(body) < length: + chunk = stream.read(length - len(body)) + if not chunk: + return None + body += chunk + + try: + data = json.loads(body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return DAPMessage.from_dict(data) + + def _dispatch(self, msg: DAPMessage) -> None: + if msg.type == "response" and msg.request_seq is not None: + self._responses[msg.request_seq] = msg + evt = self._pending.pop(msg.request_seq, None) + if evt: + evt.set() + elif msg.type == "event": + event_name = msg.event or "" + for handler in self._event_handlers.get(event_name, []): + try: + handler(msg) + except Exception: + pass + for handler in self._event_handlers.get("*", []): + try: + handler(msg) + except Exception: + pass + + # -- event subscription -------------------------------------------------- + + def on_event(self, event: str, handler: Callable[[DAPMessage], None]) -> None: + self._event_handlers.setdefault(event, []).append(handler) + + +# --------------------------------------------------------------------------- +# Debug adapter auto-detection helpers +# --------------------------------------------------------------------------- + +def _find_executable(name: str) -> Optional[str]: + return shutil.which(name) + + +def _build_adapter_command(config: DebugConfig) -> Tuple[List[str], Optional[Dict[str, str]]]: + """Return (command, env) to launch the appropriate debug adapter.""" + + debug_type = config.type.lower() + env: Optional[Dict[str, str]] = config.env + + if debug_type == "python": + python = _find_executable("python3") or _find_executable("python") or sys.executable + return [python, "-m", "debugpy.adapter"], env + + if debug_type == "node": + node_dap = _find_executable("js-debug-adapter") + if node_dap: + return [node_dap], env + node = _find_executable("node") or "node" + return [node, "--inspect-brk", config.program], env + + if debug_type == "cpp": + # Prefer lldb-vscode / lldb-dap; fall back to gdb with MI adapter + for name in ("lldb-dap", "lldb-vscode"): + exe = _find_executable(name) + if exe: + return [exe], env + gdb = _find_executable("gdb") + if gdb: + return [gdb, "--interpreter=dap"], env + raise RuntimeError("No C/C++ debug adapter found (need lldb-dap or gdb >= 14)") + + raise ValueError(f"Unsupported debug type: {debug_type}") + + +# --------------------------------------------------------------------------- +# Debugger +# --------------------------------------------------------------------------- class Debugger: + """Full-featured DAP-based debugger for EoStudio. + + Backward-compatible with the original stub API while exposing the + complete Debug Adapter Protocol feature set. + """ + def __init__(self) -> None: + self._transport: Optional[_DAPTransport] = None + self._running = False + self._initialized = False + self._breakpoints: Dict[str, List[Breakpoint]] = {} # file -> [bp] + self._watches: List[str] = [] + self._config: Optional[DebugConfig] = None + self._stopped_thread_id: Optional[int] = None + self._capabilities: Dict[str, Any] = {} + self._lock = threading.Lock() + + # Public callbacks -- users can assign their own handlers + self.on_stopped: Optional[Callable[[DAPMessage], None]] = None + self.on_terminated: Optional[Callable[[DAPMessage], None]] = None + self.on_output: Optional[Callable[[str, str], None]] = None + self.on_breakpoint_event: Optional[Callable[[DAPMessage], None]] = None + self.on_thread_event: Optional[Callable[[DAPMessage], None]] = None + self.on_exited: Optional[Callable[[int], None]] = None + + # -- internal helpers ---------------------------------------------------- + + def _setup_event_handlers(self) -> None: + assert self._transport is not None + self._transport.on_event("stopped", self._handle_stopped) + self._transport.on_event("terminated", self._handle_terminated) + self._transport.on_event("exited", self._handle_exited) + self._transport.on_event("output", self._handle_output) + self._transport.on_event("breakpoint", self._handle_breakpoint_event) + self._transport.on_event("thread", self._handle_thread_event) + + def _handle_stopped(self, msg: DAPMessage) -> None: + body = msg.body or {} + self._stopped_thread_id = body.get("threadId") + if self.on_stopped: + self.on_stopped(msg) + + def _handle_terminated(self, msg: DAPMessage) -> None: self._running = False - self._breakpoints: List[Dict[str, Any]] = [] + if self.on_terminated: + self.on_terminated(msg) + + def _handle_exited(self, msg: DAPMessage) -> None: + body = msg.body or {} + code = body.get("exitCode", -1) + if self.on_exited: + self.on_exited(code) + + def _handle_output(self, msg: DAPMessage) -> None: + body = msg.body or {} + category = body.get("category", "console") + text = body.get("output", "") + if self.on_output: + self.on_output(category, text) + + def _handle_breakpoint_event(self, msg: DAPMessage) -> None: + if self.on_breakpoint_event: + self.on_breakpoint_event(msg) + + def _handle_thread_event(self, msg: DAPMessage) -> None: + if self.on_thread_event: + self.on_thread_event(msg) + + def _request(self, command: str, + arguments: Optional[Dict[str, Any]] = None, + timeout: float = 30.0) -> DAPMessage: + if not self._transport: + return DAPMessage(seq=0, type="response", success=False, + command=command, message="No active session") + return self._transport.send_request(command, arguments, timeout=timeout) + + def _initialize(self) -> bool: + resp = self._request("initialize", { + "clientID": "eostudio", + "clientName": "EoStudio", + "adapterID": self._config.type if self._config else "python", + "pathFormat": "path", + "linesStartAt1": True, + "columnsStartAt1": True, + "supportsVariableType": True, + "supportsVariablePaging": False, + "supportsRunInTerminalRequest": False, + "supportsProgressReporting": False, + "supportsInvalidatedEvent": False, + "supportsMemoryReferences": False, + "locale": "en-US", + }) + if not resp.success: + return False + self._capabilities = resp.body or {} + self._initialized = True + return True + + def _send_breakpoints_for_file(self, file: str) -> None: + bps = self._breakpoints.get(file, []) + source_bps = [] + for bp in bps: + if not bp.enabled: + continue + entry: Dict[str, Any] = {"line": bp.line} + if bp.condition: + entry["condition"] = bp.condition + if bp.hit_count is not None: + entry["hitCondition"] = str(bp.hit_count) + if bp.log_message: + entry["logMessage"] = bp.log_message + source_bps.append(entry) + + resp = self._request("setBreakpoints", { + "source": {"path": file}, + "breakpoints": source_bps, + }) + + if resp.success and resp.body: + returned = resp.body.get("breakpoints", []) + enabled_bps = [b for b in bps if b.enabled] + for idx, rbp in enumerate(returned): + if idx < len(enabled_bps): + enabled_bps[idx].verified = rbp.get("verified", False) + enabled_bps[idx].id = rbp.get("id") + if "line" in rbp: + enabled_bps[idx].line = rbp["line"] + + def _send_all_breakpoints(self) -> None: + for file in list(self._breakpoints.keys()): + self._send_breakpoints_for_file(file) + + def _do_launch(self, config: DebugConfig) -> bool: + self._config = config + cmd, env = _build_adapter_command(config) + + transport = _DAPTransport() + try: + transport.start_process(cmd, cwd=config.cwd, env=env) + except Exception: + return False + self._transport = transport + self._setup_event_handlers() + + if not self._initialize(): + self.stop() + return False + + launch_args: Dict[str, Any] = { + "program": config.program, + "stopOnEntry": config.stop_on_entry, + "noDebug": False, + } + if config.args: + launch_args["args"] = config.args + if config.cwd: + launch_args["cwd"] = config.cwd + if config.env: + launch_args["env"] = config.env + + # Adapter-specific tweaks + if config.type == "python": + launch_args["type"] = "debugpy" + launch_args["request"] = "launch" + launch_args["justMyCode"] = True + elif config.type == "node": + launch_args["type"] = "pwa-node" + launch_args["request"] = "launch" + elif config.type == "cpp": + launch_args["type"] = "cppdbg" + launch_args["request"] = "launch" + launch_args["MIMode"] = "gdb" + + resp = self._request("launch", launch_args, timeout=30) + if not resp.success: + self.stop() + return False + + self._running = True + self._send_all_breakpoints() + self._request("configurationDone") + return True + + # -- public API (backward-compatible) ------------------------------------ def start(self, path: str) -> bool: + """Launch a debug session for the given file (backward-compatible). + + Infers the debug type from the file extension. + """ + ext = Path(path).suffix.lower() + if ext in (".js", ".mjs", ".cjs", ".ts"): + dtype = "node" + elif ext in (".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"): + dtype = "cpp" + else: + dtype = "python" + + config = DebugConfig( + program=str(Path(path).resolve()), + cwd=str(Path(path).resolve().parent), + type=dtype, + ) + return self.launch(config) + + def launch(self, config: DebugConfig) -> bool: + """Launch a debug session with a full configuration.""" + if self._running: + self.stop() + return self._do_launch(config) + + def attach(self, host: str, port: int) -> bool: + """Attach to a running debug adapter via TCP.""" + if self._running: + self.stop() + + transport = _DAPTransport() + try: + transport.connect_socket(host, port) + except ConnectionError: + return False + + self._transport = transport + self._config = DebugConfig(program="", type="python") + self._setup_event_handlers() + + if not self._initialize(): + self.stop() + return False + + resp = self._request("attach", { + "type": "debugpy", + "request": "attach", + }) + if not resp.success: + self.stop() + return False + self._running = True + self._send_all_breakpoints() + self._request("configurationDone") return True def stop(self) -> None: + """Terminate the debug session.""" + if self._transport: + try: + self._request("disconnect", {"restart": False, "terminateDebuggee": True}, timeout=5) + except Exception: + pass + self._transport.shutdown() + self._transport = None self._running = False + self._initialized = False + self._stopped_thread_id = None def is_running(self) -> bool: return self._running + # -- execution control --------------------------------------------------- + + def continue_execution(self) -> None: + tid = self._stopped_thread_id or 0 + self._request("continue", {"threadId": tid}) + self._stopped_thread_id = None + + def pause(self) -> None: + tid = self._stopped_thread_id or 0 + self._request("pause", {"threadId": tid}) + + def step_over(self) -> None: + tid = self._stopped_thread_id or 0 + self._request("next", {"threadId": tid}) + + def step_into(self) -> None: + tid = self._stopped_thread_id or 0 + self._request("stepIn", {"threadId": tid}) + + def step_out(self) -> None: + tid = self._stopped_thread_id or 0 + self._request("stepOut", {"threadId": tid}) + + # -- breakpoints --------------------------------------------------------- + def add_breakpoint(self, file: str, line: int) -> None: - self._breakpoints.append({"file": file, "line": line}) + """Add a breakpoint (backward-compatible, no return value).""" + self.set_breakpoint(file, line) def remove_breakpoint(self, file: str, line: int) -> None: - self._breakpoints = [ - bp for bp in self._breakpoints - if not (bp["file"] == file and bp["line"] == line) - ] + """Remove a breakpoint (backward-compatible).""" + file = str(Path(file).resolve()) + bps = self._breakpoints.get(file, []) + self._breakpoints[file] = [b for b in bps if b.line != line] + if not self._breakpoints[file]: + del self._breakpoints[file] + if self._running: + self._send_breakpoints_for_file(file) + + def set_breakpoint(self, file: str, line: int, + condition: Optional[str] = None, + log_message: Optional[str] = None) -> Breakpoint: + """Set a breakpoint with optional condition / log message.""" + file = str(Path(file).resolve()) + bp = Breakpoint(file=file, line=line, condition=condition, + log_message=log_message, enabled=True) + self._breakpoints.setdefault(file, []).append(bp) + if self._running: + self._send_breakpoints_for_file(file) + return bp + + def get_breakpoints(self) -> List[Breakpoint]: + """Return all breakpoints across all files.""" + result: List[Breakpoint] = [] + for bps in self._breakpoints.values(): + result.extend(bps) + return result + + # -- stack & variables --------------------------------------------------- + + def get_stack_trace(self, thread_id: Optional[int] = None) -> List[StackFrame]: + tid = thread_id or self._stopped_thread_id or 0 + resp = self._request("stackTrace", { + "threadId": tid, + "startFrame": 0, + "levels": 100, + }) + frames: List[StackFrame] = [] + if resp.success and resp.body: + for f in resp.body.get("stackFrames", []): + source = f.get("source", {}) + frames.append(StackFrame( + id=f.get("id", 0), + name=f.get("name", ""), + source_path=source.get("path", ""), + line=f.get("line", 0), + column=f.get("column", 0), + )) + return frames + + def get_scopes(self, frame_id: int) -> List[Dict[str, Any]]: + resp = self._request("scopes", {"frameId": frame_id}) + if resp.success and resp.body: + return resp.body.get("scopes", []) + return [] + + def get_variables(self, frame_id: int) -> List[Variable]: + """Get variables visible in the given stack frame. + + Fetches scopes first, then retrieves variables for each scope. + """ + scopes = self.get_scopes(frame_id) + result: List[Variable] = [] + for scope in scopes: + ref = scope.get("variablesReference", 0) + if ref: + result.extend(self._fetch_variables(ref)) + return result + + def _fetch_variables(self, variables_reference: int) -> List[Variable]: + resp = self._request("variables", { + "variablesReference": variables_reference, + }) + result: List[Variable] = [] + if resp.success and resp.body: + for v in resp.body.get("variables", []): + var_ref = v.get("variablesReference", 0) + result.append(Variable( + name=v.get("name", ""), + value=v.get("value", ""), + type=v.get("type", ""), + expandable=var_ref > 0, + variables_reference=var_ref, + )) + return result + + def expand_variable(self, variables_reference: int) -> List[Variable]: + """Expand a compound variable to get its children.""" + return self._fetch_variables(variables_reference) + + # -- threads ------------------------------------------------------------- + + def get_threads(self) -> List[Dict[str, Any]]: + resp = self._request("threads") + if resp.success and resp.body: + return resp.body.get("threads", []) + return [] + + # -- evaluation ---------------------------------------------------------- + + def evaluate(self, expression: str, + frame_id: Optional[int] = None) -> str: + """Evaluate an expression in the debug console.""" + args: Dict[str, Any] = { + "expression": expression, + "context": "repl", + } + if frame_id is not None: + args["frameId"] = frame_id + resp = self._request("evaluate", args) + if resp.success and resp.body: + return resp.body.get("result", "") + return resp.message or "" + + # -- watch expressions --------------------------------------------------- + + def set_watch(self, expression: str) -> None: + """Add a watch expression.""" + if expression not in self._watches: + self._watches.append(expression) + + def remove_watch(self, expression: str) -> None: + """Remove a watch expression.""" + try: + self._watches.remove(expression) + except ValueError: + pass + + def get_watches(self) -> List[Dict[str, Any]]: + """Evaluate all watch expressions and return results.""" + frame_id: Optional[int] = None + if self._stopped_thread_id is not None: + frames = self.get_stack_trace(self._stopped_thread_id) + if frames: + frame_id = frames[0].id + + results: List[Dict[str, Any]] = [] + for expr in self._watches: + args: Dict[str, Any] = { + "expression": expr, + "context": "watch", + } + if frame_id is not None: + args["frameId"] = frame_id + resp = self._request("evaluate", args) + if resp.success and resp.body: + results.append({ + "expression": expr, + "result": resp.body.get("result", ""), + "type": resp.body.get("type", ""), + "variablesReference": resp.body.get("variablesReference", 0), + }) + else: + results.append({ + "expression": expr, + "result": resp.message or "", + "type": "", + "variablesReference": 0, + }) + return results + + +# --------------------------------------------------------------------------- +# DebugManager +# --------------------------------------------------------------------------- + +class DebugManager: + """Manages multiple debug sessions for EoStudio.""" + + def __init__(self) -> None: + self._sessions: Dict[str, Debugger] = {} + self._active_id: Optional[str] = None + self._counter = 0 + self._lock = threading.Lock() + + def _generate_id(self) -> str: + with self._lock: + self._counter += 1 + return f"session-{self._counter}" + + @property + def active_session(self) -> Optional[Debugger]: + if self._active_id: + return self._sessions.get(self._active_id) + return None + + def create_session(self, session_id: Optional[str] = None) -> Tuple[str, Debugger]: + """Create a new debug session and return (id, debugger).""" + sid = session_id or self._generate_id() + debugger = Debugger() + self._sessions[sid] = debugger + if self._active_id is None: + self._active_id = sid + return sid, debugger + + def get_session(self, session_id: str) -> Optional[Debugger]: + return self._sessions.get(session_id) + + def set_active(self, session_id: str) -> bool: + if session_id in self._sessions: + self._active_id = session_id + return True + return False + + def stop_session(self, session_id: str) -> None: + debugger = self._sessions.pop(session_id, None) + if debugger: + debugger.stop() + if self._active_id == session_id: + self._active_id = next(iter(self._sessions), None) + + def stop_all(self) -> None: + for debugger in self._sessions.values(): + debugger.stop() + self._sessions.clear() + self._active_id = None + + def list_sessions(self) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + for sid, debugger in self._sessions.items(): + result.append({ + "id": sid, + "running": debugger.is_running(), + "active": sid == self._active_id, + }) + return result + + def launch(self, config: DebugConfig, + session_id: Optional[str] = None) -> Tuple[str, bool]: + """Create a session and launch with the given config.""" + sid, debugger = self.create_session(session_id) + ok = debugger.launch(config) + if not ok: + self.stop_session(sid) + return sid, ok + + def attach(self, host: str, port: int, + session_id: Optional[str] = None) -> Tuple[str, bool]: + """Create a session and attach to a running process.""" + sid, debugger = self.create_session(session_id) + ok = debugger.attach(host, port) + if not ok: + self.stop_session(sid) + return sid, ok diff --git a/eostudio/core/ide/extensions.py b/eostudio/core/ide/extensions.py index 5644b14..a57e768 100644 --- a/eostudio/core/ide/extensions.py +++ b/eostudio/core/ide/extensions.py @@ -1,20 +1,365 @@ -"""Extension manager (stub).""" +"""Extension manager for EoStudio — install, activate, and manage extensions.""" from __future__ import annotations +import json +import os +import shutil +import time +from dataclasses import asdict, dataclass, field +from enum import Enum +from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.error import URLError +from urllib.request import Request, urlopen +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_EOSTUDIO_DIR = Path.home() / ".eostudio" +_EXTENSIONS_DIR = _EOSTUDIO_DIR / "extensions" +_REGISTRY_CACHE_FILE = _EOSTUDIO_DIR / "registry_cache.json" +_DEFAULT_REGISTRY_URL = "https://marketplace.eostudio.dev/api/v1" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def semver_compare(v1: str, v2: str) -> int: + """Compare two semver strings. Returns -1, 0, or 1. + + Handles ``major.minor.patch`` and tolerates missing segments + (e.g. ``"1.2"`` is treated as ``"1.2.0"``). + """ + + def _parts(v: str) -> List[int]: + segments = v.lstrip("vV").split(".") + out: List[int] = [] + for s in segments[:3]: + # Strip pre-release suffix for comparison purposes. + numeric = "" + for ch in s: + if ch.isdigit(): + numeric += ch + else: + break + out.append(int(numeric) if numeric else 0) + while len(out) < 3: + out.append(0) + return out + + p1, p2 = _parts(v1), _parts(v2) + for a, b in zip(p1, p2): + if a < b: + return -1 + if a > b: + return 1 + return 0 + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +class ExtensionState(str, Enum): + INSTALLED = "installed" + ACTIVE = "active" + DISABLED = "disabled" + ERROR = "error" + + +@dataclass +class ExtensionManifest: + """Metadata describing an extension package.""" + + id: str = "" + name: str = "" + version: str = "0.0.0" + description: str = "" + author: str = "" + entry_point: str = "" + dependencies: List[str] = field(default_factory=list) + activation_events: List[str] = field(default_factory=list) + contributes: Dict[str, Any] = field(default_factory=dict) + min_eostudio_version: str = "0.0.0" + repository: str = "" + + +@dataclass +class Extension: + """Runtime representation of a managed extension.""" + + manifest: ExtensionManifest = field(default_factory=ExtensionManifest) + state: str = ExtensionState.INSTALLED.value + path: str = "" + config: Dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Extension Registry (marketplace client) +# --------------------------------------------------------------------------- + +class ExtensionRegistry: + """HTTP client for the extension marketplace.""" + + def __init__(self, registry_url: str = _DEFAULT_REGISTRY_URL) -> None: + self._registry_url = registry_url.rstrip("/") + + # -- public API ---------------------------------------------------------- + + def search(self, query: str) -> List[ExtensionManifest]: + """Search the marketplace for extensions matching *query*.""" + data = self._api_get(f"/search?q={query}") + if not isinstance(data, list): + return [] + return [self._manifest_from_dict(item) for item in data] + + def get_manifest(self, ext_id: str, version: str | None = None) -> Optional[ExtensionManifest]: + """Fetch the manifest for a specific extension.""" + url = f"/extensions/{ext_id}" + if version: + url += f"/{version}" + data = self._api_get(url) + if not data: + return None + return self._manifest_from_dict(data) + + def get_latest_version(self, ext_id: str) -> Optional[str]: + """Return the latest published version string.""" + manifest = self.get_manifest(ext_id) + return manifest.version if manifest else None + + def download(self, ext_id: str, version: str, dest_dir: Path) -> bool: + """Download and extract an extension package into *dest_dir*.""" + url = f"{self._registry_url}/extensions/{ext_id}/{version}/download" + try: + req = Request(url, method="GET") + with urlopen(req, timeout=30) as resp: + dest_dir.mkdir(parents=True, exist_ok=True) + pkg_path = dest_dir / "package.json" + pkg_path.write_bytes(resp.read()) + return True + except Exception: + return False + + # -- internals ----------------------------------------------------------- + + def _api_get(self, path: str) -> Any: + url = f"{self._registry_url}{path}" + try: + req = Request(url, method="GET") + req.add_header("Accept", "application/json") + with urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception: + return None + + @staticmethod + def _manifest_from_dict(d: dict) -> ExtensionManifest: + known_fields = {f for f in ExtensionManifest.__dataclass_fields__} + filtered = {k: v for k, v in d.items() if k in known_fields} + return ExtensionManifest(**filtered) + + +# --------------------------------------------------------------------------- +# Extension Manager +# --------------------------------------------------------------------------- + class ExtensionManager: - def __init__(self) -> None: - self._extensions: Dict[str, Any] = {} + """Manage the full lifecycle of EoStudio extensions. + + Backward-compatible with the original stub API: + ``__init__(), install(name), uninstall(name), list_installed()`` + """ + + def __init__(self, *, registry_url: str = _DEFAULT_REGISTRY_URL) -> None: + self._extensions: Dict[str, Extension] = {} + self._registry = ExtensionRegistry(registry_url) + self._extensions_dir = _EXTENSIONS_DIR + self._extensions_dir.mkdir(parents=True, exist_ok=True) + self._load_installed() + + # -- backward compat (original stub API) --------------------------------- + + def install(self, name: str, version: str | None = None) -> bool: + """Install an extension by *name*. + + If the extension is already installed at the requested (or latest) + version the call is a no-op and returns *True*. + """ + if name in self._extensions and version is None: + return True + + manifest = self._registry.get_manifest(name, version) + if manifest is None: + # Offline / registry unavailable — create a minimal local entry so + # callers that do not depend on marketplace still work. + manifest = ExtensionManifest(id=name, name=name, version=version or "0.0.0") + + # Resolve and install dependencies first. + for dep in self.resolve_dependencies(manifest): + if dep not in self._extensions: + self.install(dep) - def install(self, name: str) -> bool: - self._extensions[name] = {"installed": True} + target = version or manifest.version + ext_dir = self._extensions_dir / name / target + ext_dir.mkdir(parents=True, exist_ok=True) + + # Write the manifest locally. + manifest_path = ext_dir / "manifest.json" + manifest_path.write_text(json.dumps(asdict(manifest), indent=2), encoding="utf-8") + + # Attempt download (tolerate failure for offline-first). + self._registry.download(name, target, ext_dir) + + ext = Extension( + manifest=manifest, + state=ExtensionState.INSTALLED.value, + path=str(ext_dir), + ) + self._extensions[name] = ext + self._persist_state() return True def uninstall(self, name: str) -> bool: - return self._extensions.pop(name, None) is not None + """Remove an extension. Returns *True* if it was present.""" + ext = self._extensions.pop(name, None) + if ext is None: + return False + ext_root = self._extensions_dir / name + if ext_root.exists(): + shutil.rmtree(ext_root, ignore_errors=True) + self._persist_state() + return True def list_installed(self) -> List[str]: + """Return the names of all installed extensions (backward compat).""" return list(self._extensions.keys()) + + # -- extended API -------------------------------------------------------- + + def get_installed(self) -> List[Extension]: + """Return full :class:`Extension` objects for every installed extension.""" + return list(self._extensions.values()) + + def get_extension(self, name: str) -> Extension: + """Get an installed extension by name. Raises ``KeyError`` if missing.""" + return self._extensions[name] + + def enable(self, name: str) -> None: + """Mark an installed extension as active (will load on next activation).""" + ext = self._extensions[name] + ext.state = ExtensionState.INSTALLED.value + self._persist_state() + + def disable(self, name: str) -> None: + """Disable an installed extension.""" + ext = self._extensions[name] + ext.state = ExtensionState.DISABLED.value + self._persist_state() + + def activate(self, name: str) -> None: + """Activate an extension (execute its entry point).""" + ext = self._extensions[name] + if ext.state == ExtensionState.DISABLED.value: + raise RuntimeError(f"Extension '{name}' is disabled; enable it first") + try: + entry = ext.manifest.entry_point + if entry: + ext_path = Path(ext.path) + module_file = ext_path / entry + if module_file.exists(): + # Load via importlib to avoid polluting sys.modules naming. + import importlib.util + + spec = importlib.util.spec_from_file_location( + f"eostudio.ext.{name}", str(module_file) + ) + if spec and spec.loader: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + ext.state = ExtensionState.ACTIVE.value + except Exception: + ext.state = ExtensionState.ERROR.value + self._persist_state() + + def deactivate(self, name: str) -> None: + """Deactivate a running extension.""" + ext = self._extensions[name] + ext.state = ExtensionState.INSTALLED.value + self._persist_state() + + def search(self, query: str) -> List[ExtensionManifest]: + """Search the marketplace for extensions.""" + return self._registry.search(query) + + def update(self, name: str | None = None) -> List[str]: + """Update one or all extensions. Returns names that were updated.""" + targets = [name] if name else list(self._extensions.keys()) + updated: List[str] = [] + for ext_name in targets: + if ext_name not in self._extensions: + continue + ext = self._extensions[ext_name] + latest = self._registry.get_latest_version(ext_name) + if latest and semver_compare(latest, ext.manifest.version) > 0: + self.install(ext_name, latest) + updated.append(ext_name) + return updated + + def resolve_dependencies(self, manifest: ExtensionManifest) -> List[str]: + """Return a flat, ordered list of dependency extension IDs.""" + resolved: List[str] = [] + seen: set[str] = set() + + def _walk(deps: List[str]) -> None: + for dep in deps: + if dep in seen: + continue + seen.add(dep) + dep_manifest = self._registry.get_manifest(dep) + if dep_manifest: + _walk(dep_manifest.dependencies) + resolved.append(dep) + + _walk(manifest.dependencies) + return resolved + + # -- persistence --------------------------------------------------------- + + def _state_file(self) -> Path: + return _EOSTUDIO_DIR / "extensions_state.json" + + def _persist_state(self) -> None: + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + data: Dict[str, Any] = {} + for name, ext in self._extensions.items(): + data[name] = { + "manifest": asdict(ext.manifest), + "state": ext.state, + "path": ext.path, + "config": ext.config, + } + self._state_file().write_text(json.dumps(data, indent=2), encoding="utf-8") + + def _load_installed(self) -> None: + sf = self._state_file() + if not sf.exists(): + return + try: + raw = json.loads(sf.read_text(encoding="utf-8")) + for name, blob in raw.items(): + manifest_data = blob.get("manifest", {}) + known = {f for f in ExtensionManifest.__dataclass_fields__} + manifest = ExtensionManifest(**{k: v for k, v in manifest_data.items() if k in known}) + self._extensions[name] = Extension( + manifest=manifest, + state=blob.get("state", ExtensionState.INSTALLED.value), + path=blob.get("path", ""), + config=blob.get("config", {}), + ) + except Exception: + pass diff --git a/eostudio/core/ide/git_integration.py b/eostudio/core/ide/git_integration.py index 3a3e474..31aceaf 100644 --- a/eostudio/core/ide/git_integration.py +++ b/eostudio/core/ide/git_integration.py @@ -1,31 +1,534 @@ -"""Git integration (stub).""" +"""Git integration for EoStudio IDE. + +Provides a comprehensive interface to git operations via subprocess, +suitable for embedding in the EoStudio IDE environment. +""" from __future__ import annotations -from typing import Any, Dict, List, Optional +import os +import re +import subprocess +from typing import Dict, List, Optional + + +class GitError(Exception): + """Exception raised when a git operation fails.""" + + def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None: + self.returncode = returncode + self.stderr = stderr + super().__init__(message) class GitIntegration: + """Full-featured git integration for EoStudio. + + All git operations are executed via the ``git`` binary using + :func:`subprocess.run`. The helper :meth:`_run_git` centralises + argument construction, working-directory handling and error + propagation. + + Parameters + ---------- + workspace_path: + Root directory of the git repository. Defaults to the current + working directory. + """ + def __init__(self, workspace_path: str = ".") -> None: - self.workspace_path = workspace_path + self.workspace_path = os.path.abspath(workspace_path) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _run_git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + """Run a git command and return the completed process. + + Parameters + ---------- + *args: + Arguments passed directly to ``git``. + check: + If *True* (the default), raise :class:`GitError` when the + command exits with a non-zero status. + + Returns + ------- + subprocess.CompletedProcess + """ + cmd = ["git", *args] + try: + result = subprocess.run( + cmd, + cwd=self.workspace_path, + capture_output=True, + text=True, + timeout=120, + ) + except FileNotFoundError: + raise GitError("git executable not found - is git installed?") + except subprocess.TimeoutExpired: + raise GitError(f"git command timed out: {' '.join(cmd)}") + + if check and result.returncode != 0: + stderr = result.stderr.strip() + raise GitError( + f"git {args[0]} failed (exit {result.returncode}): {stderr}", + returncode=result.returncode, + stderr=stderr, + ) + return result + + # ------------------------------------------------------------------ + # Repository state + # ------------------------------------------------------------------ + + def is_repo(self) -> bool: + """Return *True* if the workspace is inside a git repository.""" + try: + result = self._run_git("rev-parse", "--is-inside-work-tree", check=False) + return result.returncode == 0 and result.stdout.strip() == "true" + except GitError: + return False + + def init(self) -> bool: + """Initialise a new git repository in the workspace directory.""" + self._run_git("init") + return True + + def clone(self, url: str, directory: str | None = None) -> bool: + """Clone a remote repository. + + Parameters + ---------- + url: + URL of the remote repository to clone. + directory: + Optional local directory name. When *None* git will choose + the default. + """ + cmd: list[str] = ["clone", url] + if directory is not None: + cmd.append(directory) + self._run_git(*cmd) + return True + + # ------------------------------------------------------------------ + # Status / diff + # ------------------------------------------------------------------ def status(self) -> List[Dict[str, str]]: - return [] + """Return the working-tree status as a list of dicts. + + Each dict has the keys ``"status"`` (the two-character porcelain + code, e.g. ``" M"``, ``"??"``) and ``"file"`` (the path relative + to the repository root). + """ + result = self._run_git("status", "--porcelain") + entries: list[dict[str, str]] = [] + for line in result.stdout.splitlines(): + if not line: + continue + # Porcelain v1: first two chars are the status code, then a + # space, then the filename. Renames use " -> " notation. + status_code = line[:2] + file_path = line[3:] + entries.append({"status": status_code, "file": file_path}) + return entries + + def diff(self, staged: bool = False, file: str | None = None) -> str: + """Return the diff output as a string. + + Parameters + ---------- + staged: + If *True*, show the staged (cached) diff instead of the + working-tree diff. + file: + Optionally restrict the diff to a single file. + """ + cmd: list[str] = ["diff"] + if staged: + cmd.append("--cached") + if file is not None: + cmd.extend(["--", file]) + result = self._run_git(*cmd) + return result.stdout + + # ------------------------------------------------------------------ + # Staging + # ------------------------------------------------------------------ + + def add(self, files: List[str] | None = None) -> bool: + """Stage files for the next commit. + + Parameters + ---------- + files: + List of file paths to add. When *None*, all changes are + staged (``git add -A``). + """ + if files is None: + self._run_git("add", "-A") + else: + self._run_git("add", "--", *files) + return True + + def reset(self, files: List[str] | None = None, hard: bool = False) -> bool: + """Reset the index or working tree. + + Parameters + ---------- + files: + List of file paths to unstage. When *None* the entire + index is reset. + hard: + Perform a hard reset (discard working-tree changes). + """ + cmd: list[str] = ["reset"] + if hard: + cmd.append("--hard") + if files is not None: + cmd.extend(["--", *files]) + self._run_git(*cmd) + return True + + # ------------------------------------------------------------------ + # Committing + # ------------------------------------------------------------------ + + def commit(self, message: str, amend: bool = False) -> bool: + """Create a commit with the given message. + + Parameters + ---------- + message: + The commit message. + amend: + If *True*, amend the previous commit instead of creating a + new one. + """ + cmd: list[str] = ["commit", "-m", message] + if amend: + cmd.append("--amend") + self._run_git(*cmd) + return True + + # ------------------------------------------------------------------ + # Remote operations + # ------------------------------------------------------------------ - def diff(self) -> str: - return "" + def push( + self, + remote: str = "origin", + branch: str | None = None, + force: bool = False, + ) -> bool: + """Push commits to a remote. - def commit(self, message: str) -> bool: - return False + Parameters + ---------- + remote: + Remote name. + branch: + Branch to push. When *None* git will push the current + branch (or follow ``push.default``). + force: + Use ``--force-with-lease`` for a safer forced push. + """ + cmd: list[str] = ["push"] + if force: + cmd.append("--force-with-lease") + cmd.append(remote) + if branch is not None: + cmd.append(branch) + self._run_git(*cmd) + return True - def push(self) -> bool: - return False + def pull( + self, + remote: str = "origin", + branch: str | None = None, + rebase: bool = False, + ) -> bool: + """Pull changes from a remote. - def pull(self) -> bool: - return False + Parameters + ---------- + remote: + Remote name. + branch: + Branch to pull. When *None* git will pull the current + tracking branch. + rebase: + If *True*, rebase local commits on top of the fetched + branch instead of merging. + """ + cmd: list[str] = ["pull"] + if rebase: + cmd.append("--rebase") + cmd.append(remote) + if branch is not None: + cmd.append(branch) + self._run_git(*cmd) + return True + + def fetch(self, remote: str = "origin", prune: bool = False) -> bool: + """Fetch objects and refs from a remote. + + Parameters + ---------- + remote: + Remote name. + prune: + If *True*, remove any remote-tracking references that no + longer exist on the remote. + """ + cmd: list[str] = ["fetch", remote] + if prune: + cmd.append("--prune") + self._run_git(*cmd) + return True + + def remote_url(self, remote: str = "origin") -> str: + """Return the URL configured for *remote*.""" + result = self._run_git("remote", "get-url", remote) + return result.stdout.strip() + + # ------------------------------------------------------------------ + # Branching + # ------------------------------------------------------------------ def branch(self) -> str: - return "main" + """Return the name of the current branch. + + Returns ``"HEAD"`` when in detached HEAD state. + """ + result = self._run_git("rev-parse", "--abbrev-ref", "HEAD") + return result.stdout.strip() + + def branches(self, all: bool = False) -> List[str]: + """List branch names. + + Parameters + ---------- + all: + If *True*, include remote-tracking branches as well. + """ + cmd: list[str] = ["branch", "--list", "--no-color"] + if all: + cmd.append("--all") + result = self._run_git(*cmd) + out: list[str] = [] + for line in result.stdout.splitlines(): + name = line.lstrip("* ").strip() + if name: + out.append(name) + return out + + def checkout(self, branch: str, create: bool = False) -> bool: + """Switch branches or create and switch. + + Parameters + ---------- + branch: + Target branch name. + create: + If *True*, create the branch before switching (``-b``). + """ + cmd: list[str] = ["checkout"] + if create: + cmd.append("-b") + cmd.append(branch) + self._run_git(*cmd) + return True + + # ------------------------------------------------------------------ + # Merging / rebasing + # ------------------------------------------------------------------ + + def merge(self, branch: str, no_ff: bool = False) -> bool: + """Merge *branch* into the current branch. + + Parameters + ---------- + branch: + Branch (or ref) to merge. + no_ff: + If *True*, always create a merge commit even when + fast-forward is possible. + """ + cmd: list[str] = ["merge"] + if no_ff: + cmd.append("--no-ff") + cmd.append(branch) + self._run_git(*cmd) + return True + + def rebase(self, branch: str, interactive: bool = False) -> bool: + """Rebase the current branch onto *branch*. + + Parameters + ---------- + branch: + Upstream branch to rebase onto. + interactive: + If *True*, start an interactive rebase. Note: this will + open an editor unless ``GIT_SEQUENCE_EDITOR`` is set. + """ + cmd: list[str] = ["rebase"] + if interactive: + cmd.append("--interactive") + cmd.append(branch) + self._run_git(*cmd) + return True + + # ------------------------------------------------------------------ + # Stash + # ------------------------------------------------------------------ + + def stash(self, message: str | None = None) -> bool: + """Stash the current working-tree changes. + + Parameters + ---------- + message: + Optional message to describe the stash entry. + """ + cmd: list[str] = ["stash", "push"] + if message is not None: + cmd.extend(["-m", message]) + self._run_git(*cmd) + return True + + def stash_pop(self) -> bool: + """Pop the most recent stash entry.""" + self._run_git("stash", "pop") + return True + + def stash_list(self) -> List[str]: + """Return a list of stash entries (human-readable descriptions).""" + result = self._run_git("stash", "list") + return [line for line in result.stdout.splitlines() if line] + + # ------------------------------------------------------------------ + # Log / blame + # ------------------------------------------------------------------ + + def log(self, n: int = 10, oneline: bool = True) -> List[Dict[str, str]]: + """Return recent commits. + + Parameters + ---------- + n: + Maximum number of commits to return. + oneline: + If *True*, return a compact format with ``hash`` and + ``message`` keys. If *False*, also include ``author`` and + ``date``. + + Returns + ------- + list[dict[str, str]] + Each dict contains at least ``"hash"`` and ``"message"``. + When *oneline* is *False*, ``"author"`` and ``"date"`` are + also present. + """ + if oneline: + result = self._run_git( + "log", + f"-{n}", + "--pretty=format:%h %s", + ) + entries: list[dict[str, str]] = [] + for line in result.stdout.splitlines(): + if not line: + continue + parts = line.split(" ", 1) + entries.append({ + "hash": parts[0], + "message": parts[1] if len(parts) > 1 else "", + }) + return entries + + # Detailed format: use a separator that is unlikely to appear in + # commit messages so we can split reliably. + sep = "---GIT_FIELD_SEP---" + fmt = sep.join(["%h", "%an", "%ai", "%s"]) + result = self._run_git( + "log", + f"-{n}", + f"--pretty=format:{fmt}", + ) + entries = [] + for line in result.stdout.splitlines(): + if not line: + continue + parts = line.split(sep, 3) + if len(parts) < 4: + continue + entries.append({ + "hash": parts[0], + "author": parts[1], + "date": parts[2], + "message": parts[3], + }) + return entries + + def blame(self, file: str) -> List[Dict[str, str]]: + """Return line-by-line blame information for *file*. + + Each entry contains: + + * ``commit`` -- abbreviated commit hash + * ``author`` -- author name + * ``date`` -- author date (ISO-ish) + * ``line_no`` -- 1-based line number + * ``content`` -- line content + """ + result = self._run_git("blame", "--porcelain", "--", file) + entries: list[dict[str, str]] = [] + current: dict[str, str] = {} + # Porcelain blame output: header line starts with a 40-char hex + # hash, followed by orig-line final-line [group-count]. + # Then key-value pairs, then a TAB-prefixed content line. + header_re = re.compile(r"^([0-9a-f]{40})\s+(\d+)\s+(\d+)") + for line in result.stdout.splitlines(): + header_match = header_re.match(line) + if header_match: + current = { + "commit": header_match.group(1)[:8], + "line_no": header_match.group(3), + } + elif line.startswith("author "): + current["author"] = line[len("author "):] + elif line.startswith("author-time "): + current.setdefault("date", line[len("author-time "):]) + elif line.startswith("\t"): + current["content"] = line[1:] + entries.append(current) + current = {} + return entries + + # ------------------------------------------------------------------ + # Conflict helpers + # ------------------------------------------------------------------ + + def has_conflicts(self) -> bool: + """Return *True* if the working tree contains merge conflicts.""" + return len(self.get_conflicts()) > 0 + + def get_conflicts(self) -> List[str]: + """Return a list of files with unresolved merge conflicts. - def branches(self) -> List[str]: - return ["main"] + Uses ``git diff --name-only --diff-filter=U`` which lists + unmerged paths. + """ + result = self._run_git( + "diff", "--name-only", "--diff-filter=U", check=False, + ) + if result.returncode != 0: + return [] + return [f for f in result.stdout.splitlines() if f.strip()] diff --git a/eostudio/core/ide/language_server.py b/eostudio/core/ide/language_server.py index eabe602..44575f3 100644 --- a/eostudio/core/ide/language_server.py +++ b/eostudio/core/ide/language_server.py @@ -1,26 +1,762 @@ -"""Language server protocol client (stub).""" +""" +Language Server Protocol client implementation for EoStudio. +Provides a full LSP client using JSON-RPC over stdio, supporting completion, +hover, go-to-definition, references, rename, formatting, and diagnostics. +""" from __future__ import annotations -from typing import Any, Dict, List, Optional +import json +import logging +import os +import subprocess +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class LSPMessage: + """Represents a JSON-RPC 2.0 message used by the Language Server Protocol.""" + + id: Optional[int] = None + method: Optional[str] = None + params: Optional[Any] = None + result: Optional[Any] = None + error: Optional[Any] = None + + def to_dict(self) -> Dict[str, Any]: + msg: Dict[str, Any] = {"jsonrpc": "2.0"} + if self.id is not None: + msg["id"] = self.id + if self.method is not None: + msg["method"] = self.method + if self.params is not None: + msg["params"] = self.params + if self.result is not None: + msg["result"] = self.result + if self.error is not None: + msg["error"] = self.error + return msg + + @staticmethod + def from_dict(data: Dict[str, Any]) -> LSPMessage: + return LSPMessage( + id=data.get("id"), + method=data.get("method"), + params=data.get("params"), + result=data.get("result"), + error=data.get("error"), + ) + + +@dataclass +class LSPConfig: + """Configuration for a language server.""" + + language: str + command: List[str] + initialization_options: Optional[Dict[str, Any]] = None + settings: Optional[Dict[str, Any]] = None + + +# --------------------------------------------------------------------------- +# Default server configurations +# --------------------------------------------------------------------------- + +_DEFAULT_CONFIGS: Dict[str, LSPConfig] = { + "python": LSPConfig( + language="python", + command=["pyright-langserver", "--stdio"], + ), + "pylsp": LSPConfig( + language="python", + command=["pylsp"], + ), + "javascript": LSPConfig( + language="javascript", + command=["typescript-language-server", "--stdio"], + ), + "typescript": LSPConfig( + language="typescript", + command=["typescript-language-server", "--stdio"], + ), + "c": LSPConfig( + language="c", + command=["clangd"], + ), + "cpp": LSPConfig( + language="cpp", + command=["clangd"], + ), + "rust": LSPConfig( + language="rust", + command=["rust-analyzer"], + ), + "go": LSPConfig( + language="go", + command=["gopls", "serve"], + ), + "java": LSPConfig( + language="java", + command=["jdtls"], + ), +} + + +def get_config(language: str) -> LSPConfig: + """Return the default LSPConfig for *language*, raising ValueError if unknown.""" + key = language.lower() + if key in _DEFAULT_CONFIGS: + return _DEFAULT_CONFIGS[key] + raise ValueError( + f"No default language-server configuration for '{language}'. " + f"Known languages: {', '.join(sorted(_DEFAULT_CONFIGS))}" + ) + + +# --------------------------------------------------------------------------- +# LanguageServer - full LSP client +# --------------------------------------------------------------------------- class LanguageServer: - def __init__(self, language: str = "python") -> None: + """LSP client that communicates with a language server over stdio. + + Backward-compatible with the original stub interface while exposing the + full set of LSP operations required by EoStudio. + """ + + def __init__( + self, + language: str = "python", + workspace_path: str = ".", + config: Optional[LSPConfig] = None, + ) -> None: self.language = language - self._running = False + self.workspace_path = os.path.abspath(workspace_path) + self.config = config or get_config(language) + + self._process: Optional[subprocess.Popen] = None + self._request_id = 0 + self._id_lock = threading.Lock() + + # Response routing: request-id -> threading.Event + storage + self._pending: Dict[int, threading.Event] = {} + self._responses: Dict[int, Dict[str, Any]] = {} + self._pending_lock = threading.Lock() + + # Diagnostics received asynchronously from the server + self._diagnostics: Dict[str, List[Dict[str, Any]]] = {} + self._diagnostics_lock = threading.Lock() + + # Version tracking for open documents + self._document_versions: Dict[str, int] = {} + + self._reader_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + self._write_lock = threading.Lock() + + # -- lifecycle ----------------------------------------------------------- def start(self) -> None: - self._running = True + """Launch the language server subprocess and initialize the session.""" + if self.is_running(): + return + + self._stop_event.clear() + try: + self._process = subprocess.Popen( + self.config.command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise RuntimeError( + f"Language server command not found: {self.config.command}. " + "Make sure the server is installed and on your PATH." + ) + + self._reader_thread = threading.Thread( + target=self._reader_loop, daemon=True, name="lsp-reader" + ) + self._reader_thread.start() + + self.initialize() + self.initialized() def stop(self) -> None: - self._running = False + """Gracefully shut down the language server.""" + if not self.is_running(): + return + + try: + self._send_request("shutdown", params=None) + except Exception: + logger.debug("shutdown request failed", exc_info=True) + + try: + self._send_notification("exit") + except Exception: + logger.debug("exit notification failed", exc_info=True) + + self._stop_event.set() + + if self._process is not None: + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2) + self._process = None + + if self._reader_thread is not None: + self._reader_thread.join(timeout=3) + self._reader_thread = None + + with self._pending_lock: + for evt in self._pending.values(): + evt.set() + self._pending.clear() + self._responses.clear() def is_running(self) -> bool: - return self._running + """Return True if the language server process is alive.""" + return self._process is not None and self._process.poll() is None + + # -- JSON-RPC transport -------------------------------------------------- + + def _next_id(self) -> int: + with self._id_lock: + self._request_id += 1 + return self._request_id + + def _send_message(self, msg: LSPMessage) -> None: + """Encode and write a JSON-RPC message with Content-Length framing.""" + body = json.dumps(msg.to_dict(), separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + with self._write_lock: + if self._process is None or self._process.stdin is None: + raise RuntimeError("Language server is not running.") + self._process.stdin.write(header + body) + self._process.stdin.flush() + + def _read_message(self) -> Optional[Dict[str, Any]]: + """Read one JSON-RPC message from the server stdout (blocking).""" + if self._process is None or self._process.stdout is None: + return None + + stdout = self._process.stdout + content_length = -1 + + # Read headers + while True: + line = stdout.readline() + if not line: + return None # EOF + line_str = line.decode("ascii", errors="replace").strip() + if not line_str: + break # End of headers + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":", 1)[1].strip()) + + if content_length < 0: + return None + + body = b"" + while len(body) < content_length: + chunk = stdout.read(content_length - len(body)) + if not chunk: + return None + body += chunk + + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + logger.error("Failed to decode JSON-RPC body: %s", body[:200]) + return None + + # -- background reader --------------------------------------------------- + + def _reader_loop(self) -> None: + """Background thread: read messages and dispatch responses/notifications.""" + while not self._stop_event.is_set(): + try: + msg = self._read_message() + except Exception: + if self._stop_event.is_set(): + break + logger.debug("reader error", exc_info=True) + break + + if msg is None: + break + + msg_id = msg.get("id") - def complete(self, source: str, line: int, column: int) -> List[Dict[str, Any]]: + # Server notification (no id) + if msg_id is None: + self._handle_notification(msg) + continue + + # Response to a request we sent + with self._pending_lock: + if msg_id in self._pending: + self._responses[msg_id] = msg + self._pending[msg_id].set() + + def _handle_notification(self, msg: Dict[str, Any]) -> None: + method = msg.get("method", "") + params = msg.get("params", {}) + + if method == "textDocument/publishDiagnostics": + uri = params.get("uri", "") + diags = params.get("diagnostics", []) + with self._diagnostics_lock: + self._diagnostics[uri] = diags + elif method == "window/logMessage": + text = params.get("message", "") + logger.debug("LSP log: %s", text) + + # -- request / notification helpers -------------------------------------- + + def _send_request( + self, + method: str, + params: Any = None, + timeout: float = 30.0, + ) -> Any: + """Send a JSON-RPC request and wait for the response.""" + rid = self._next_id() + event = threading.Event() + + with self._pending_lock: + self._pending[rid] = event + + msg = LSPMessage(id=rid, method=method, params=params) + self._send_message(msg) + + if not event.wait(timeout=timeout): + with self._pending_lock: + self._pending.pop(rid, None) + self._responses.pop(rid, None) + raise TimeoutError( + f"LSP request '{method}' (id={rid}) timed out" + ) + + with self._pending_lock: + self._pending.pop(rid, None) + response = self._responses.pop(rid, {}) + + if "error" in response: + err = response["error"] + raise RuntimeError( + f"LSP error [{err.get('code')}]: {err.get('message')}" + ) + return response.get("result") + + def _send_notification(self, method: str, params: Any = None) -> None: + """Send a JSON-RPC notification (no response expected).""" + msg = LSPMessage(method=method, params=params) + self._send_message(msg) + + # -- LSP lifecycle requests ---------------------------------------------- + + def initialize(self) -> Dict[str, Any]: + """Send the ``initialize`` request with client capabilities.""" + params = { + "processId": os.getpid(), + "rootUri": self._path_to_uri(self.workspace_path), + "rootPath": self.workspace_path, + "capabilities": { + "textDocument": { + "completion": { + "completionItem": { + "snippetSupport": True, + "documentationFormat": [ + "plaintext", + "markdown", + ], + }, + }, + "hover": { + "contentFormat": ["plaintext", "markdown"], + }, + "definition": {}, + "references": {}, + "rename": { + "prepareSupport": True, + }, + "formatting": {}, + "publishDiagnostics": { + "relatedInformation": True, + }, + "synchronization": { + "didSave": True, + "willSave": False, + "willSaveWaitUntil": False, + }, + }, + "workspace": { + "workspaceFolders": True, + "configuration": True, + }, + }, + "workspaceFolders": [ + { + "uri": self._path_to_uri(self.workspace_path), + "name": os.path.basename(self.workspace_path), + } + ], + } + if self.config.initialization_options: + params["initializationOptions"] = ( + self.config.initialization_options + ) + result = self._send_request("initialize", params) + return result or {} + + def initialized(self) -> None: + """Send the ``initialized`` notification.""" + self._send_notification("initialized", {}) + + # -- document synchronization -------------------------------------------- + + def did_open(self, uri: str, language_id: str, text: str) -> None: + """Notify the server that a document was opened.""" + self._document_versions[uri] = 1 + self._send_notification( + "textDocument/didOpen", + { + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 1, + "text": text, + } + }, + ) + + def did_change(self, uri: str, text: str) -> None: + """Notify the server that a document changed (full sync).""" + version = self._document_versions.get(uri, 0) + 1 + self._document_versions[uri] = version + self._send_notification( + "textDocument/didChange", + { + "textDocument": {"uri": uri, "version": version}, + "contentChanges": [{"text": text}], + }, + ) + + def did_save(self, uri: str) -> None: + """Notify the server that a document was saved.""" + self._send_notification( + "textDocument/didSave", + {"textDocument": {"uri": uri}}, + ) + + def did_close(self, uri: str) -> None: + """Notify the server that a document was closed.""" + self._document_versions.pop(uri, None) + self._send_notification( + "textDocument/didClose", + {"textDocument": {"uri": uri}}, + ) + + # -- LSP features -------------------------------------------------------- + + def complete( + self, + source: str, + line: int, + column: int, + uri: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Request completions at a position. + + This method is backward-compatible with the original stub: it accepts + *source* text and a position. When *uri* is ``None`` a temporary + document URI is synthesized. + """ + if uri is None: + uri = self._path_to_uri( + os.path.join(self.workspace_path, "__eostudio_tmp__.py") + ) + self.did_open(uri, self.language, source) + + result = self._send_request( + "textDocument/completion", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + }, + ) + if result is None: + return [] + items = ( + result + if isinstance(result, list) + else result.get("items", []) + ) + return [ + { + "label": item.get("label", ""), + "kind": item.get("kind"), + "detail": item.get("detail", ""), + "documentation": _extract_documentation( + item.get("documentation") + ), + "insertText": ( + item.get("insertText") or item.get("label", "") + ), + } + for item in items + ] + + def hover( + self, uri: str, line: int, character: int + ) -> Dict[str, Any]: + """Request hover information at a position.""" + result = self._send_request( + "textDocument/hover", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + }, + ) + if result is None: + return {} + contents = result.get("contents", "") + return { + "contents": _extract_documentation(contents), + "range": result.get("range"), + } + + def definition( + self, uri: str, line: int, character: int + ) -> List[Dict[str, Any]]: + """Request go-to-definition at a position.""" + result = self._send_request( + "textDocument/definition", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + }, + ) + return _normalize_locations(result) + + def references( + self, uri: str, line: int, character: int + ) -> List[Dict[str, Any]]: + """Request find-references at a position.""" + result = self._send_request( + "textDocument/references", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + "context": {"includeDeclaration": True}, + }, + ) + return _normalize_locations(result) + + def rename( + self, uri: str, line: int, character: int, new_name: str + ) -> Dict[str, Any]: + """Request a rename refactoring.""" + result = self._send_request( + "textDocument/rename", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + "newName": new_name, + }, + ) + if result is None: + return {} + return result + + def formatting(self, uri: str) -> List[Dict[str, Any]]: + """Request document formatting.""" + result = self._send_request( + "textDocument/formatting", + { + "textDocument": {"uri": uri}, + "options": {"tabSize": 4, "insertSpaces": True}, + }, + ) + return result if isinstance(result, list) else [] + + def diagnostics( + self, source: str, uri: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Return the latest diagnostics for a document. + + Backward-compatible: accepts raw *source* text. If the document has + not been opened yet it will be opened automatically so the server can + analyse it. + """ + if uri is None: + uri = self._path_to_uri( + os.path.join(self.workspace_path, "__eostudio_tmp__.py") + ) + self.did_open(uri, self.language, source) + else: + self.did_change(uri, source) + + # Give the server a moment to publish diagnostics + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + with self._diagnostics_lock: + if uri in self._diagnostics: + diags = self._diagnostics[uri] + return [ + { + "range": d.get("range"), + "severity": d.get("severity"), + "message": d.get("message", ""), + "source": d.get("source", ""), + "code": d.get("code"), + } + for d in diags + ] + time.sleep(0.1) return [] - def diagnostics(self, source: str) -> List[Dict[str, Any]]: + # -- helpers ------------------------------------------------------------- + + @staticmethod + def _path_to_uri(path: str) -> str: + """Convert a filesystem path to a ``file://`` URI.""" + abspath = os.path.abspath(path) + # On Windows, drive letters need special handling + if os.name == "nt": + abspath = abspath.replace("\\", "/") + if abspath[0] != "/": + abspath = "/" + abspath + return "file://" + abspath + + @staticmethod + def uri_to_path(uri: str) -> str: + """Convert a ``file://`` URI back to a filesystem path.""" + if uri.startswith("file:///") and os.name == "nt": + return uri[8:].replace("/", "\\") + if uri.startswith("file://"): + return uri[7:] + return uri + + +# --------------------------------------------------------------------------- +# LanguageServerManager +# --------------------------------------------------------------------------- + + +class LanguageServerManager: + """Manages multiple LanguageServer instances keyed by language.""" + + def __init__(self, workspace_path: str = ".") -> None: + self.workspace_path = os.path.abspath(workspace_path) + self._servers: Dict[str, LanguageServer] = {} + self._lock = threading.Lock() + + def get(self, language: str) -> LanguageServer: + """Return a running LanguageServer for *language*, starting one if needed.""" + key = language.lower() + with self._lock: + server = self._servers.get(key) + if server is not None and server.is_running(): + return server + server = LanguageServer( + language=key, workspace_path=self.workspace_path + ) + server.start() + self._servers[key] = server + return server + + def stop(self, language: str) -> None: + """Stop the server for *language* if it is running.""" + key = language.lower() + with self._lock: + server = self._servers.pop(key, None) + if server is not None: + server.stop() + + def stop_all(self) -> None: + """Stop every managed server.""" + with self._lock: + servers = list(self._servers.values()) + self._servers.clear() + for server in servers: + try: + server.stop() + except Exception: + logger.debug("Error stopping server", exc_info=True) + + def running_languages(self) -> List[str]: + """Return a list of languages with running servers.""" + with self._lock: + return [ + k for k, v in self._servers.items() if v.is_running() + ] + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + + +def _extract_documentation(doc: Any) -> str: + """Extract plain-text documentation from an LSP MarkupContent or string.""" + if doc is None: + return "" + if isinstance(doc, str): + return doc + if isinstance(doc, dict): + return doc.get("value", "") + if isinstance(doc, list): + parts = [] + for item in doc: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + parts.append(item.get("value", "")) + return "\n".join(parts) + return str(doc) + + +def _normalize_locations(result: Any) -> List[Dict[str, Any]]: + """Normalise definition/references results into a list of location dicts.""" + if result is None: + return [] + if isinstance(result, dict): + result = [result] + if not isinstance(result, list): return [] + locations: List[Dict[str, Any]] = [] + for item in result: + loc: Dict[str, Any] = {} + if "uri" in item: + loc["uri"] = item["uri"] + loc["range"] = item.get("range") + elif "targetUri" in item: + # LocationLink + loc["uri"] = item["targetUri"] + loc["range"] = ( + item.get("targetSelectionRange") + or item.get("targetRange") + ) + else: + continue + locations.append(loc) + return locations diff --git a/eostudio/core/ide/project_manager.py b/eostudio/core/ide/project_manager.py index 6952ccd..243f82d 100644 --- a/eostudio/core/ide/project_manager.py +++ b/eostudio/core/ide/project_manager.py @@ -1,25 +1,434 @@ -"""Project manager (stub).""" +"""Project manager for EoStudio — workspaces, templates, and tasks.""" from __future__ import annotations +import json +import os +import subprocess +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path from typing import Any, Dict, List, Optional +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_EOSTUDIO_DIR = Path.home() / ".eostudio" +_RECENT_PROJECTS_FILE = _EOSTUDIO_DIR / "recent_projects.json" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class WorkspaceConfig: + """Multi-root workspace configuration stored in ``.eostudio/workspace.json``.""" + + folders: List[str] = field(default_factory=list) + settings: Dict[str, Any] = field(default_factory=dict) + extensions: List[str] = field(default_factory=list) + tasks: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class ProjectTemplate: + """A project scaffold with pre-defined files and metadata.""" + + name: str = "" + description: str = "" + language: str = "" + framework: str = "" + files: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class RecentProject: + """Entry in the recent-projects list.""" + + name: str = "" + path: str = "" + last_opened: float = 0.0 + pinned: bool = False + + +# --------------------------------------------------------------------------- +# Built-in templates +# --------------------------------------------------------------------------- + +def _builtin_templates() -> List[ProjectTemplate]: + """Return 20+ starter templates for common languages and frameworks.""" + return [ + ProjectTemplate("python-basic", "Basic Python project", "python", "none", { + "main.py": '"""Entry point."""\n\n\ndef main() -> None:\n print("Hello, world!")\n\n\nif __name__ == "__main__":\n main()\n', + "requirements.txt": "", + ".gitignore": "__pycache__/\n*.pyc\n.venv/\n", + }), + ProjectTemplate("python-flask", "Flask web application", "python", "flask", { + "app.py": 'from flask import Flask\n\napp = Flask(__name__)\n\n\n@app.route("/")\ndef index():\n return "Hello, Flask!"\n\n\nif __name__ == "__main__":\n app.run(debug=True)\n', + "requirements.txt": "flask\n", + ".gitignore": "__pycache__/\n*.pyc\n.venv/\n", + }), + ProjectTemplate("python-fastapi", "FastAPI web service", "python", "fastapi", { + "main.py": 'from fastapi import FastAPI\n\napp = FastAPI()\n\n\n@app.get("/")\nasync def root():\n return {"message": "Hello, FastAPI!"}\n', + "requirements.txt": "fastapi\nuvicorn[standard]\n", + ".gitignore": "__pycache__/\n*.pyc\n.venv/\n", + }), + ProjectTemplate("python-django", "Django web application", "python", "django", { + "manage.py": '#!/usr/bin/env python\nimport os, sys\n\ndef main():\n os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")\n from django.core.management import execute_from_command_line\n execute_from_command_line(sys.argv)\n\nif __name__ == "__main__":\n main()\n', + "requirements.txt": "django\n", + ".gitignore": "__pycache__/\n*.pyc\ndb.sqlite3\n", + }), + ProjectTemplate("javascript-node", "Node.js application", "javascript", "node", { + "index.js": 'console.log("Hello, Node.js!");\n', + "package.json": '{\n "name": "my-app",\n "version": "1.0.0",\n "main": "index.js",\n "scripts": {"start": "node index.js"}\n}\n', + ".gitignore": "node_modules/\n", + }), + ProjectTemplate("javascript-express", "Express.js web server", "javascript", "express", { + "index.js": 'const express = require("express");\nconst app = express();\n\napp.get("/", (req, res) => res.send("Hello, Express!"));\n\napp.listen(3000, () => console.log("Listening on :3000"));\n', + "package.json": '{\n "name": "my-express-app",\n "version": "1.0.0",\n "main": "index.js",\n "scripts": {"start": "node index.js"},\n "dependencies": {"express": "^4.18.0"}\n}\n', + ".gitignore": "node_modules/\n", + }), + ProjectTemplate("typescript-node", "TypeScript Node.js application", "typescript", "node", { + "src/index.ts": 'console.log("Hello, TypeScript!");\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2020", "module": "commonjs", "outDir": "dist", "strict": true},\n "include": ["src"]\n}\n', + "package.json": '{\n "name": "my-ts-app",\n "version": "1.0.0",\n "scripts": {"build": "tsc", "start": "node dist/index.js"}\n}\n', + ".gitignore": "node_modules/\ndist/\n", + }), + ProjectTemplate("react", "React single-page application", "typescript", "react", { + "src/App.tsx": 'export default function App() {\n return

Hello, React!

;\n}\n', + "src/index.tsx": 'import React from "react";\nimport ReactDOM from "react-dom/client";\nimport App from "./App";\n\nReactDOM.createRoot(document.getElementById("root")!).render();\n', + "package.json": '{\n "name": "my-react-app",\n "version": "1.0.0",\n "scripts": {"start": "react-scripts start", "build": "react-scripts build"}\n}\n', + ".gitignore": "node_modules/\nbuild/\n", + }), + ProjectTemplate("nextjs", "Next.js full-stack application", "typescript", "nextjs", { + "pages/index.tsx": 'export default function Home() {\n return

Hello, Next.js!

;\n}\n', + "package.json": '{\n "name": "my-nextjs-app",\n "version": "1.0.0",\n "scripts": {"dev": "next dev", "build": "next build"}\n}\n', + ".gitignore": "node_modules/\n.next/\n", + }), + ProjectTemplate("vue", "Vue.js application", "typescript", "vue", { + "src/App.vue": '\n\n\n', + "package.json": '{\n "name": "my-vue-app",\n "version": "1.0.0",\n "scripts": {"dev": "vite", "build": "vite build"}\n}\n', + ".gitignore": "node_modules/\ndist/\n", + }), + ProjectTemplate("rust-basic", "Rust application", "rust", "none", { + "src/main.rs": 'fn main() {\n println!("Hello, Rust!");\n}\n', + "Cargo.toml": '[package]\nname = "my-app"\nversion = "0.1.0"\nedition = "2021"\n', + ".gitignore": "target/\n", + }), + ProjectTemplate("rust-actix", "Rust Actix Web server", "rust", "actix-web", { + "src/main.rs": 'use actix_web::{get, App, HttpServer, Responder};\n\n#[get("/")]\nasync fn index() -> impl Responder {\n "Hello, Actix!"\n}\n\n#[actix_web::main]\nasync fn main() -> std::io::Result<()> {\n HttpServer::new(|| App::new().service(index))\n .bind("127.0.0.1:8080")?\n .run()\n .await\n}\n', + "Cargo.toml": '[package]\nname = "my-actix-app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nactix-web = "4"\n', + ".gitignore": "target/\n", + }), + ProjectTemplate("go-basic", "Go application", "go", "none", { + "main.go": 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello, Go!")\n}\n', + "go.mod": 'module myapp\n\ngo 1.21\n', + ".gitignore": "bin/\n", + }), + ProjectTemplate("go-gin", "Go Gin web server", "go", "gin", { + "main.go": 'package main\n\nimport "github.com/gin-gonic/gin"\n\nfunc main() {\n\tr := gin.Default()\n\tr.GET("/", func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{"message": "Hello, Gin!"})\n\t})\n\tr.Run()\n}\n', + "go.mod": 'module myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n', + ".gitignore": "bin/\n", + }), + ProjectTemplate("c-basic", "C application", "c", "none", { + "main.c": '#include \n\nint main(void) {\n printf("Hello, C!\\n");\n return 0;\n}\n', + "Makefile": 'CC=gcc\nCFLAGS=-Wall -Wextra -std=c17\n\nall: main\n\nmain: main.c\n\t$(CC) $(CFLAGS) -o $@ $<\n\nclean:\n\trm -f main\n', + ".gitignore": "*.o\nmain\n", + }), + ProjectTemplate("cpp-basic", "C++ application", "cpp", "none", { + "main.cpp": '#include \n\nint main() {\n std::cout << "Hello, C++!" << std::endl;\n return 0;\n}\n', + "CMakeLists.txt": 'cmake_minimum_required(VERSION 3.16)\nproject(myapp LANGUAGES CXX)\nset(CMAKE_CXX_STANDARD 20)\nadd_executable(myapp main.cpp)\n', + ".gitignore": "build/\n", + }), + ProjectTemplate("java-basic", "Java application", "java", "none", { + "src/Main.java": 'public class Main {\n public static void main(String[] args) {\n System.out.println("Hello, Java!");\n }\n}\n', + ".gitignore": "*.class\nbuild/\n", + }), + ProjectTemplate("java-spring", "Spring Boot application", "java", "spring-boot", { + "src/main/java/com/example/App.java": 'package com.example;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\n@SpringBootApplication\npublic class App {\n public static void main(String[] args) {\n SpringApplication.run(App.class, args);\n }\n}\n', + "build.gradle": 'plugins {\n id "org.springframework.boot" version "3.2.0"\n id "java"\n}\n\ndependencies {\n implementation "org.springframework.boot:spring-boot-starter-web"\n}\n', + ".gitignore": "build/\n.gradle/\n", + }), + ProjectTemplate("csharp-console", "C# console application", "csharp", "dotnet", { + "Program.cs": 'Console.WriteLine("Hello, C#!");\n', + "app.csproj": '\n \n Exe\n net8.0\n \n\n', + ".gitignore": "bin/\nobj/\n", + }), + ProjectTemplate("ruby-rails", "Ruby on Rails application", "ruby", "rails", { + "config.ru": 'require_relative "config/environment"\nrun Rails.application\n', + "Gemfile": 'source "https://rubygems.org"\ngem "rails", "~> 7.1"\n', + ".gitignore": "log/\ntmp/\n", + }), + ProjectTemplate("embedded-c", "Embedded C firmware project", "c", "embedded", { + "src/main.c": '#include \n\nint main(void) {\n while (1) {\n // main loop\n }\n return 0;\n}\n', + "Makefile": 'CC=arm-none-eabi-gcc\nCFLAGS=-mcpu=cortex-m4 -mthumb -Wall\n\nall:\n\t$(CC) $(CFLAGS) -o firmware.elf src/main.c\n\nclean:\n\trm -f firmware.elf\n', + ".gitignore": "*.elf\n*.bin\n*.hex\nbuild/\n", + }), + ProjectTemplate("svelte", "Svelte application", "typescript", "svelte", { + "src/App.svelte": '

Hello, Svelte!

\n', + "package.json": '{\n "name": "my-svelte-app",\n "version": "1.0.0",\n "scripts": {"dev": "vite dev", "build": "vite build"}\n}\n', + ".gitignore": "node_modules/\ndist/\n", + }), + ] + + +# --------------------------------------------------------------------------- +# Project type detection +# --------------------------------------------------------------------------- + +_PROJECT_MARKERS: List[Dict[str, Any]] = [ + {"file": "Cargo.toml", "language": "rust", "framework": "cargo"}, + {"file": "go.mod", "language": "go", "framework": "go-modules"}, + {"file": "package.json", "language": "javascript", "framework": "node"}, + {"file": "tsconfig.json", "language": "typescript", "framework": "node"}, + {"file": "pyproject.toml", "language": "python", "framework": "pyproject"}, + {"file": "setup.py", "language": "python", "framework": "setuptools"}, + {"file": "requirements.txt", "language": "python", "framework": "pip"}, + {"file": "Pipfile", "language": "python", "framework": "pipenv"}, + {"file": "Gemfile", "language": "ruby", "framework": "bundler"}, + {"file": "pom.xml", "language": "java", "framework": "maven"}, + {"file": "build.gradle", "language": "java", "framework": "gradle"}, + {"file": "CMakeLists.txt", "language": "cpp", "framework": "cmake"}, + {"file": "Makefile", "language": "c", "framework": "make"}, + {"file": ".csproj", "language": "csharp", "framework": "dotnet"}, + {"file": "mix.exs", "language": "elixir", "framework": "mix"}, + {"file": "pubspec.yaml", "language": "dart", "framework": "flutter"}, + {"file": "composer.json", "language": "php", "framework": "composer"}, + {"file": "next.config.js", "language": "typescript", "framework": "nextjs"}, + {"file": "next.config.mjs", "language": "typescript", "framework": "nextjs"}, + {"file": "nuxt.config.ts", "language": "typescript", "framework": "nuxt"}, + {"file": "svelte.config.js", "language": "typescript", "framework": "svelte"}, + {"file": "angular.json", "language": "typescript", "framework": "angular"}, +] + + +# --------------------------------------------------------------------------- +# Project Manager +# --------------------------------------------------------------------------- + class ProjectManager: + """Workspace and project management for EoStudio. + + Backward-compatible with the original stub API: + ``__init__(), create(name, path), open(name), list_projects()`` + """ + def __init__(self) -> None: self._projects: Dict[str, Any] = {} self.current_project: Optional[str] = None + self._workspace_folders: List[str] = [] + self._templates = _builtin_templates() + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + + # -- backward compat (original stub API) --------------------------------- def create(self, name: str, path: str) -> Dict[str, Any]: - project = {"name": name, "path": path} + """Create a new project directory with default scaffolding.""" + project_dir = Path(path) + project_dir.mkdir(parents=True, exist_ok=True) + + # Create .eostudio workspace metadata. + meta_dir = project_dir / ".eostudio" + meta_dir.mkdir(exist_ok=True) + + config = WorkspaceConfig(folders=[str(project_dir)]) + self.save_workspace_config(str(project_dir), config) + + project: Dict[str, Any] = {"name": name, "path": str(project_dir)} self._projects[name] = project + self.add_to_recent(name, str(project_dir)) return project def open(self, name: str) -> bool: + """Open a previously created project by *name*.""" if name in self._projects: self.current_project = name + proj_path = self._projects[name].get("path", "") + if proj_path: + self.add_to_recent(name, proj_path) return True return False def list_projects(self) -> List[str]: + """Return names of all known projects (backward compat).""" return list(self._projects.keys()) + + # -- extended API -------------------------------------------------------- + + def open_folder(self, path: str) -> bool: + """Open a folder as a project, auto-detecting its type.""" + folder = Path(path) + if not folder.is_dir(): + return False + name = folder.name + self._projects[name] = {"name": name, "path": str(folder)} + self.current_project = name + if str(folder) not in self._workspace_folders: + self._workspace_folders.append(str(folder)) + self.add_to_recent(name, str(folder)) + return True + + # -- recent projects ----------------------------------------------------- + + @staticmethod + def get_recent_projects() -> List[RecentProject]: + """Load the recent-projects list from ``~/.eostudio/recent_projects.json``.""" + if not _RECENT_PROJECTS_FILE.exists(): + return [] + try: + raw = json.loads(_RECENT_PROJECTS_FILE.read_text(encoding="utf-8")) + return [RecentProject(**entry) for entry in raw] + except Exception: + return [] + + @staticmethod + def _save_recent(projects: List[RecentProject]) -> None: + _EOSTUDIO_DIR.mkdir(parents=True, exist_ok=True) + _RECENT_PROJECTS_FILE.write_text( + json.dumps([asdict(p) for p in projects], indent=2), + encoding="utf-8", + ) + + def add_to_recent(self, name: str, path: str) -> None: + """Add or refresh a project in the recent-projects list.""" + projects = self.get_recent_projects() + # Remove existing entry with the same path. + projects = [p for p in projects if p.path != path] + projects.insert(0, RecentProject(name=name, path=path, last_opened=time.time())) + # Keep a reasonable cap. + projects = projects[:50] + self._save_recent(projects) + + @staticmethod + def remove_from_recent(path: str) -> None: + """Remove a project from the recent list by path.""" + projects = ProjectManager.get_recent_projects() + projects = [p for p in projects if p.path != path] + ProjectManager._save_recent(projects) + + # -- templates ----------------------------------------------------------- + + def create_from_template(self, template: str, path: str, name: str) -> Dict[str, Any]: + """Scaffold a project from a built-in template.""" + tpl = next((t for t in self._templates if t.name == template), None) + if tpl is None: + raise ValueError(f"Unknown template: {template}") + + project_dir = Path(path) + project_dir.mkdir(parents=True, exist_ok=True) + + for rel_path, content in tpl.files.items(): + file_path = project_dir / rel_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + + return self.create(name, str(project_dir)) + + def list_templates(self) -> List[ProjectTemplate]: + """Return all available project templates.""" + return list(self._templates) + + # -- project type detection ----------------------------------------------- + + @staticmethod + def detect_project_type(path: str) -> Dict[str, Any]: + """Heuristically detect language and framework from files in *path*.""" + folder = Path(path) + if not folder.is_dir(): + return {"language": "unknown", "framework": "unknown"} + + entries = {e.name for e in folder.iterdir()} + + for marker in _PROJECT_MARKERS: + marker_file: str = marker["file"] + # Handle .csproj-style suffix match. + if marker_file.startswith("."): + if any(e.endswith(marker_file) for e in entries): + return {"language": marker["language"], "framework": marker["framework"]} + elif marker_file in entries: + return {"language": marker["language"], "framework": marker["framework"]} + + return {"language": "unknown", "framework": "unknown"} + + # -- workspace config ---------------------------------------------------- + + @staticmethod + def get_workspace_config(path: str) -> WorkspaceConfig: + """Read ``.eostudio/workspace.json`` from *path*.""" + config_file = Path(path) / ".eostudio" / "workspace.json" + if not config_file.exists(): + return WorkspaceConfig(folders=[path]) + try: + data = json.loads(config_file.read_text(encoding="utf-8")) + return WorkspaceConfig(**data) + except Exception: + return WorkspaceConfig(folders=[path]) + + @staticmethod + def save_workspace_config(path: str, config: WorkspaceConfig) -> None: + """Write ``.eostudio/workspace.json`` under *path*.""" + meta_dir = Path(path) / ".eostudio" + meta_dir.mkdir(parents=True, exist_ok=True) + config_file = meta_dir / "workspace.json" + config_file.write_text( + json.dumps(asdict(config), indent=2), + encoding="utf-8", + ) + + # -- tasks --------------------------------------------------------------- + + @staticmethod + def get_tasks(path: str) -> List[Dict[str, Any]]: + """Read tasks from ``.eostudio/tasks.json``.""" + tasks_file = Path(path) / ".eostudio" / "tasks.json" + if not tasks_file.exists(): + return [] + try: + return json.loads(tasks_file.read_text(encoding="utf-8")) + except Exception: + return [] + + @staticmethod + def run_task(path: str, task_name: str) -> str: + """Execute a named task defined in ``.eostudio/tasks.json``. + + Returns the combined stdout/stderr output. + """ + tasks = ProjectManager.get_tasks(path) + task = next((t for t in tasks if t.get("name") == task_name), None) + if task is None: + raise ValueError(f"Task not found: {task_name}") + + command = task.get("command", "") + if not command: + raise ValueError(f"Task '{task_name}' has no command") + + result = subprocess.run( + command, + shell=True, + cwd=path, + capture_output=True, + text=True, + timeout=300, + ) + return result.stdout + result.stderr + + # -- multi-root workspace ------------------------------------------------ + + def add_folder_to_workspace(self, folder: str) -> None: + """Add a folder to the current multi-root workspace.""" + resolved = str(Path(folder).resolve()) + if resolved not in self._workspace_folders: + self._workspace_folders.append(resolved) + + # Persist to the current project's workspace config if available. + if self.current_project and self.current_project in self._projects: + proj_path = self._projects[self.current_project].get("path") + if proj_path: + config = self.get_workspace_config(proj_path) + if resolved not in config.folders: + config.folders.append(resolved) + self.save_workspace_config(proj_path, config) diff --git a/eostudio/core/ide/syntax.py b/eostudio/core/ide/syntax.py index 289676c..f6c5b88 100644 --- a/eostudio/core/ide/syntax.py +++ b/eostudio/core/ide/syntax.py @@ -1,17 +1,1980 @@ -"""Syntax highlighting (stub).""" +""" +EoStudio Syntax Highlighter +============================ +Production-ready, token-based syntax highlighter supporting 30+ languages +and 12 built-in color themes. Uses only Python stdlib (re module). + +Usage: + highlighter = SyntaxHighlighter("python") + tokens = highlighter.tokenize(source_code) + ansi_output = highlighter.highlight(source_code) + html_output = highlighter.highlight_html(source_code) +""" from __future__ import annotations -from typing import Dict, List, Optional +import json +import re +from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path +from typing import ClassVar, Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Token types +# --------------------------------------------------------------------------- + +class TokenType(Enum): + """Every lexical category the highlighter can emit.""" + KEYWORD = auto() + STRING = auto() + COMMENT = auto() + NUMBER = auto() + OPERATOR = auto() + FUNCTION = auto() + CLASS = auto() + DECORATOR = auto() + BUILTIN = auto() + TYPE = auto() + VARIABLE = auto() + PUNCTUATION = auto() + WHITESPACE = auto() + NEWLINE = auto() + IDENTIFIER = auto() + PREPROCESSOR = auto() + TAG = auto() + ATTRIBUTE = auto() + UNKNOWN = auto() + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class Token: + """A single highlighted token.""" + type: TokenType + value: str + start: int + end: int + + +@dataclass +class LanguageDefinition: + """Holds the name, file extensions, and ordered regex patterns for a language.""" + name: str + extensions: List[str] + patterns: List[Tuple[TokenType, str]] + + +@dataclass +class Theme: + """Maps each TokenType to a hex colour string (e.g. ``#FF5555``).""" + name: str + colors: Dict[TokenType, str] + background: str = "#282828" + foreground: str = "#ebdbb2" + + +# --------------------------------------------------------------------------- +# ANSI helpers +# --------------------------------------------------------------------------- + +def _hex_to_ansi(hex_color: str) -> str: + """Convert ``#RRGGBB`` to a 24-bit ANSI escape sequence.""" + hex_color = hex_color.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + return f"\033[38;2;{r};{g};{b}m" + + +_ANSI_RESET = "\033[0m" + + +# =================================================================== +# Built-in themes (12) +# =================================================================== + +def _make_theme(name: str, bg: str, fg: str, mapping: Dict[TokenType, str]) -> Theme: + """Fill missing token types with the theme foreground colour.""" + full: Dict[TokenType, str] = {} + for tt in TokenType: + full[tt] = mapping.get(tt, fg) + return Theme(name=name, colors=full, background=bg, foreground=fg) + + +BUILTIN_THEMES: Dict[str, Theme] = {} + +# -- Monokai -- +BUILTIN_THEMES["monokai"] = _make_theme("monokai", "#272822", "#F8F8F2", { + TokenType.KEYWORD: "#F92672", + TokenType.STRING: "#E6DB74", + TokenType.COMMENT: "#75715E", + TokenType.NUMBER: "#AE81FF", + TokenType.OPERATOR: "#F92672", + TokenType.FUNCTION: "#A6E22E", + TokenType.CLASS: "#A6E22E", + TokenType.DECORATOR: "#A6E22E", + TokenType.BUILTIN: "#66D9EF", + TokenType.TYPE: "#66D9EF", + TokenType.VARIABLE: "#F8F8F2", + TokenType.PUNCTUATION: "#F8F8F2", + TokenType.PREPROCESSOR: "#F92672", + TokenType.TAG: "#F92672", + TokenType.ATTRIBUTE: "#A6E22E", + TokenType.IDENTIFIER: "#F8F8F2", +}) + +# -- One Dark -- +BUILTIN_THEMES["one_dark"] = _make_theme("one_dark", "#282C34", "#ABB2BF", { + TokenType.KEYWORD: "#C678DD", + TokenType.STRING: "#98C379", + TokenType.COMMENT: "#5C6370", + TokenType.NUMBER: "#D19A66", + TokenType.OPERATOR: "#56B6C2", + TokenType.FUNCTION: "#61AFEF", + TokenType.CLASS: "#E5C07B", + TokenType.DECORATOR: "#C678DD", + TokenType.BUILTIN: "#E5C07B", + TokenType.TYPE: "#E5C07B", + TokenType.VARIABLE: "#E06C75", + TokenType.PUNCTUATION: "#ABB2BF", + TokenType.PREPROCESSOR: "#C678DD", + TokenType.TAG: "#E06C75", + TokenType.ATTRIBUTE: "#D19A66", + TokenType.IDENTIFIER: "#ABB2BF", +}) + +# -- Solarized Dark -- +BUILTIN_THEMES["solarized_dark"] = _make_theme("solarized_dark", "#002B36", "#839496", { + TokenType.KEYWORD: "#859900", + TokenType.STRING: "#2AA198", + TokenType.COMMENT: "#586E75", + TokenType.NUMBER: "#D33682", + TokenType.OPERATOR: "#859900", + TokenType.FUNCTION: "#268BD2", + TokenType.CLASS: "#B58900", + TokenType.DECORATOR: "#CB4B16", + TokenType.BUILTIN: "#B58900", + TokenType.TYPE: "#B58900", + TokenType.VARIABLE: "#268BD2", + TokenType.PUNCTUATION: "#839496", + TokenType.PREPROCESSOR: "#CB4B16", + TokenType.TAG: "#268BD2", + TokenType.ATTRIBUTE: "#B58900", + TokenType.IDENTIFIER: "#839496", +}) + +# -- Solarized Light -- +BUILTIN_THEMES["solarized_light"] = _make_theme("solarized_light", "#FDF6E3", "#657B83", { + TokenType.KEYWORD: "#859900", + TokenType.STRING: "#2AA198", + TokenType.COMMENT: "#93A1A1", + TokenType.NUMBER: "#D33682", + TokenType.OPERATOR: "#859900", + TokenType.FUNCTION: "#268BD2", + TokenType.CLASS: "#B58900", + TokenType.DECORATOR: "#CB4B16", + TokenType.BUILTIN: "#B58900", + TokenType.TYPE: "#B58900", + TokenType.VARIABLE: "#268BD2", + TokenType.PUNCTUATION: "#657B83", + TokenType.PREPROCESSOR: "#CB4B16", + TokenType.TAG: "#268BD2", + TokenType.ATTRIBUTE: "#B58900", + TokenType.IDENTIFIER: "#657B83", +}) + +# -- Dracula -- +BUILTIN_THEMES["dracula"] = _make_theme("dracula", "#282A36", "#F8F8F2", { + TokenType.KEYWORD: "#FF79C6", + TokenType.STRING: "#F1FA8C", + TokenType.COMMENT: "#6272A4", + TokenType.NUMBER: "#BD93F9", + TokenType.OPERATOR: "#FF79C6", + TokenType.FUNCTION: "#50FA7B", + TokenType.CLASS: "#8BE9FD", + TokenType.DECORATOR: "#50FA7B", + TokenType.BUILTIN: "#8BE9FD", + TokenType.TYPE: "#8BE9FD", + TokenType.VARIABLE: "#F8F8F2", + TokenType.PUNCTUATION: "#F8F8F2", + TokenType.PREPROCESSOR: "#FF79C6", + TokenType.TAG: "#FF79C6", + TokenType.ATTRIBUTE: "#50FA7B", + TokenType.IDENTIFIER: "#F8F8F2", +}) + +# -- GitHub Light -- +BUILTIN_THEMES["github_light"] = _make_theme("github_light", "#FFFFFF", "#24292E", { + TokenType.KEYWORD: "#D73A49", + TokenType.STRING: "#032F62", + TokenType.COMMENT: "#6A737D", + TokenType.NUMBER: "#005CC5", + TokenType.OPERATOR: "#D73A49", + TokenType.FUNCTION: "#6F42C1", + TokenType.CLASS: "#6F42C1", + TokenType.DECORATOR: "#6F42C1", + TokenType.BUILTIN: "#005CC5", + TokenType.TYPE: "#005CC5", + TokenType.VARIABLE: "#E36209", + TokenType.PUNCTUATION: "#24292E", + TokenType.PREPROCESSOR: "#D73A49", + TokenType.TAG: "#22863A", + TokenType.ATTRIBUTE: "#6F42C1", + TokenType.IDENTIFIER: "#24292E", +}) + +# -- GitHub Dark -- +BUILTIN_THEMES["github_dark"] = _make_theme("github_dark", "#0D1117", "#C9D1D9", { + TokenType.KEYWORD: "#FF7B72", + TokenType.STRING: "#A5D6FF", + TokenType.COMMENT: "#8B949E", + TokenType.NUMBER: "#79C0FF", + TokenType.OPERATOR: "#FF7B72", + TokenType.FUNCTION: "#D2A8FF", + TokenType.CLASS: "#FFA657", + TokenType.DECORATOR: "#D2A8FF", + TokenType.BUILTIN: "#79C0FF", + TokenType.TYPE: "#FFA657", + TokenType.VARIABLE: "#FFA657", + TokenType.PUNCTUATION: "#C9D1D9", + TokenType.PREPROCESSOR: "#FF7B72", + TokenType.TAG: "#7EE787", + TokenType.ATTRIBUTE: "#D2A8FF", + TokenType.IDENTIFIER: "#C9D1D9", +}) + +# -- Nord -- +BUILTIN_THEMES["nord"] = _make_theme("nord", "#2E3440", "#D8DEE9", { + TokenType.KEYWORD: "#81A1C1", + TokenType.STRING: "#A3BE8C", + TokenType.COMMENT: "#616E88", + TokenType.NUMBER: "#B48EAD", + TokenType.OPERATOR: "#81A1C1", + TokenType.FUNCTION: "#88C0D0", + TokenType.CLASS: "#8FBCBB", + TokenType.DECORATOR: "#D08770", + TokenType.BUILTIN: "#8FBCBB", + TokenType.TYPE: "#8FBCBB", + TokenType.VARIABLE: "#D8DEE9", + TokenType.PUNCTUATION: "#ECEFF4", + TokenType.PREPROCESSOR: "#81A1C1", + TokenType.TAG: "#81A1C1", + TokenType.ATTRIBUTE: "#8FBCBB", + TokenType.IDENTIFIER: "#D8DEE9", +}) + +# -- Catppuccin (Mocha) -- +BUILTIN_THEMES["catppuccin"] = _make_theme("catppuccin", "#1E1E2E", "#CDD6F4", { + TokenType.KEYWORD: "#CBA6F7", + TokenType.STRING: "#A6E3A1", + TokenType.COMMENT: "#585B70", + TokenType.NUMBER: "#FAB387", + TokenType.OPERATOR: "#89DCEB", + TokenType.FUNCTION: "#89B4FA", + TokenType.CLASS: "#F9E2AF", + TokenType.DECORATOR: "#CBA6F7", + TokenType.BUILTIN: "#F9E2AF", + TokenType.TYPE: "#F9E2AF", + TokenType.VARIABLE: "#CDD6F4", + TokenType.PUNCTUATION: "#BAC2DE", + TokenType.PREPROCESSOR: "#CBA6F7", + TokenType.TAG: "#CBA6F7", + TokenType.ATTRIBUTE: "#89B4FA", + TokenType.IDENTIFIER: "#CDD6F4", +}) + +# -- Gruvbox -- +BUILTIN_THEMES["gruvbox"] = _make_theme("gruvbox", "#282828", "#EBDBB2", { + TokenType.KEYWORD: "#FB4934", + TokenType.STRING: "#B8BB26", + TokenType.COMMENT: "#928374", + TokenType.NUMBER: "#D3869B", + TokenType.OPERATOR: "#FE8019", + TokenType.FUNCTION: "#FABD2F", + TokenType.CLASS: "#FABD2F", + TokenType.DECORATOR: "#8EC07C", + TokenType.BUILTIN: "#83A598", + TokenType.TYPE: "#83A598", + TokenType.VARIABLE: "#EBDBB2", + TokenType.PUNCTUATION: "#EBDBB2", + TokenType.PREPROCESSOR: "#FB4934", + TokenType.TAG: "#FB4934", + TokenType.ATTRIBUTE: "#FABD2F", + TokenType.IDENTIFIER: "#EBDBB2", +}) + +# -- Tokyo Night -- +BUILTIN_THEMES["tokyo_night"] = _make_theme("tokyo_night", "#1A1B26", "#A9B1D6", { + TokenType.KEYWORD: "#BB9AF7", + TokenType.STRING: "#9ECE6A", + TokenType.COMMENT: "#565F89", + TokenType.NUMBER: "#FF9E64", + TokenType.OPERATOR: "#89DDFF", + TokenType.FUNCTION: "#7AA2F7", + TokenType.CLASS: "#2AC3DE", + TokenType.DECORATOR: "#BB9AF7", + TokenType.BUILTIN: "#2AC3DE", + TokenType.TYPE: "#2AC3DE", + TokenType.VARIABLE: "#C0CAF5", + TokenType.PUNCTUATION: "#A9B1D6", + TokenType.PREPROCESSOR: "#BB9AF7", + TokenType.TAG: "#F7768E", + TokenType.ATTRIBUTE: "#7AA2F7", + TokenType.IDENTIFIER: "#A9B1D6", +}) + +# -- Material Dark -- +BUILTIN_THEMES["material_dark"] = _make_theme("material_dark", "#263238", "#EEFFFF", { + TokenType.KEYWORD: "#C792EA", + TokenType.STRING: "#C3E88D", + TokenType.COMMENT: "#546E7A", + TokenType.NUMBER: "#F78C6C", + TokenType.OPERATOR: "#89DDFF", + TokenType.FUNCTION: "#82AAFF", + TokenType.CLASS: "#FFCB6B", + TokenType.DECORATOR: "#C792EA", + TokenType.BUILTIN: "#FFCB6B", + TokenType.TYPE: "#FFCB6B", + TokenType.VARIABLE: "#EEFFFF", + TokenType.PUNCTUATION: "#89DDFF", + TokenType.PREPROCESSOR: "#C792EA", + TokenType.TAG: "#F07178", + TokenType.ATTRIBUTE: "#FFCB6B", + TokenType.IDENTIFIER: "#EEFFFF", +}) + + +# =================================================================== +# Language definitions (30+) +# =================================================================== +# +# Pattern order matters -- earlier patterns take priority. Each language +# list is assembled with: comments first, then strings, decorators, +# numbers, keywords, builtins, types, operators, function/class names, +# punctuation, identifiers, whitespace/newlines. +# =================================================================== + +def _kw(words: List[str]) -> str: + """Build a regex alternation that matches whole words.""" + return r"\b(?:" + "|".join(re.escape(w) for w in sorted(words, key=len, reverse=True)) + r")\b" + + +# --------------------------------------------------------------- +# Python +# --------------------------------------------------------------- +_PYTHON_KEYWORDS = [ + "False", "None", "True", "and", "as", "assert", "async", "await", + "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", + "while", "with", "yield", "match", "case", "type", +] +_PYTHON_BUILTINS = [ + "abs", "all", "any", "ascii", "bin", "bool", "breakpoint", "bytearray", + "bytes", "callable", "chr", "classmethod", "compile", "complex", + "delattr", "dict", "dir", "divmod", "enumerate", "eval", "exec", + "filter", "float", "format", "frozenset", "getattr", "globals", + "hasattr", "hash", "help", "hex", "id", "input", "int", "isinstance", + "issubclass", "iter", "len", "list", "locals", "map", "max", + "memoryview", "min", "next", "object", "oct", "open", "ord", "pow", + "print", "property", "range", "repr", "reversed", "round", "set", + "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super", + "tuple", "type", "vars", "zip", "__import__", +] + +_LANG_PYTHON = LanguageDefinition( + name="python", + extensions=[".py", ".pyw", ".pyi"], + patterns=[ + # Comments + (TokenType.COMMENT, r"#[^\n]*"), + # Triple-quoted strings (must come before single-quoted) + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r"'''[\s\S]*?'''"), + # f-strings / b-strings / r-strings (simple handling) + (TokenType.STRING, r'[fFbBrRuU]{1,2}"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"[fFbBrRuU]{1,2}'(?:[^'\\]|\\.)*'"), + # Normal strings + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + # Decorator + (TokenType.DECORATOR, r"@\w+(?:\.\w+)*"), + # Numbers + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[oO][0-7_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?[jJ]?"), + (TokenType.NUMBER, r"\d[\d_]*[eE][+-]?\d[\d_]*[jJ]?"), + (TokenType.NUMBER, r"\d[\d_]*[jJ]?"), + # Keywords + (TokenType.KEYWORD, _kw(_PYTHON_KEYWORDS)), + # Builtins + (TokenType.BUILTIN, _kw(_PYTHON_BUILTINS)), + # Function definition + (TokenType.FUNCTION, r"(?<=\bdef\s)\w+"), + # Class definition + (TokenType.CLASS, r"(?<=\bclass\s)\w+"), + # Operators + (TokenType.OPERATOR, r"->|:=|\*\*=?|//=?|<<=?|>>=?|[+\-*/%&|^~<>=!]=?|@=?"), + # Punctuation + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,]"), + # Identifiers + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + # Whitespace / newlines + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# JavaScript +# --------------------------------------------------------------- +_JS_KEYWORDS = [ + "async", "await", "break", "case", "catch", "class", "const", + "continue", "debugger", "default", "delete", "do", "else", "export", + "extends", "finally", "for", "function", "if", "import", "in", + "instanceof", "let", "new", "of", "return", "static", "super", + "switch", "this", "throw", "try", "typeof", "var", "void", "while", + "with", "yield", "from", "as", +] +_JS_BUILTINS = [ + "Array", "Boolean", "Date", "Error", "Function", "JSON", "Map", + "Math", "Number", "Object", "Promise", "Proxy", "RegExp", "Set", + "String", "Symbol", "WeakMap", "WeakSet", "console", "document", + "globalThis", "undefined", "null", "true", "false", "NaN", "Infinity", + "parseInt", "parseFloat", "isNaN", "isFinite", "setTimeout", + "setInterval", "clearTimeout", "clearInterval", "fetch", "window", +] + +_LANG_JAVASCRIPT = LanguageDefinition( + name="javascript", + extensions=[".js", ".jsx", ".mjs", ".cjs"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r"`(?:[^`\\]|\\.|\$\{[^}]*\})*`"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+n?"), + (TokenType.NUMBER, r"0[oO][0-7_]+n?"), + (TokenType.NUMBER, r"0[bB][01_]+n?"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?n?"), + (TokenType.NUMBER, r"\d[\d_]*[eE][+-]?\d[\d_]*n?"), + (TokenType.NUMBER, r"\d[\d_]*n?"), + (TokenType.KEYWORD, _kw(_JS_KEYWORDS)), + (TokenType.BUILTIN, _kw(_JS_BUILTINS)), + (TokenType.FUNCTION, r"(?<=\bfunction\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r">>>?=?|===?|!==?|\?\?=?|\?\.|&&=?|\|\|=?|=>|\*\*=?|<<=?|>>=?|[+\-*/%&|^~<>=!]=?|\.\.\."), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.IDENTIFIER, r"[A-Za-z_$]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# TypeScript +# --------------------------------------------------------------- +_TS_KEYWORDS = _JS_KEYWORDS + [ + "abstract", "as", "declare", "enum", "implements", "interface", + "keyof", "module", "namespace", "never", "override", "private", + "protected", "public", "readonly", "type", "unknown", "any", + "is", "infer", "satisfies", "using", +] +_TS_TYPES = [ + "string", "number", "boolean", "void", "any", "never", "unknown", + "object", "undefined", "null", "bigint", "symbol", +] + +_LANG_TYPESCRIPT = LanguageDefinition( + name="typescript", + extensions=[".ts", ".tsx"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r"`(?:[^`\\]|\\.|\$\{[^}]*\})*`"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+n?"), + (TokenType.NUMBER, r"0[oO][0-7_]+n?"), + (TokenType.NUMBER, r"0[bB][01_]+n?"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?n?"), + (TokenType.NUMBER, r"\d[\d_]*[eE][+-]?\d[\d_]*n?"), + (TokenType.NUMBER, r"\d[\d_]*n?"), + (TokenType.KEYWORD, _kw(_TS_KEYWORDS)), + (TokenType.BUILTIN, _kw(_JS_BUILTINS)), + (TokenType.TYPE, _kw(_TS_TYPES)), + (TokenType.DECORATOR, r"@\w+(?:\.\w+)*"), + (TokenType.FUNCTION, r"(?<=\bfunction\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r">>>?=?|===?|!==?|\?\?=?|\?\.|&&=?|\|\|=?|=>|\*\*=?|<<=?|>>=?|[+\-*/%&|^~<>=!]=?|\.\.\."), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_$]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# C +# --------------------------------------------------------------- +_C_KEYWORDS = [ + "auto", "break", "case", "char", "const", "continue", "default", + "do", "double", "else", "enum", "extern", "float", "for", "goto", + "if", "inline", "int", "long", "register", "restrict", "return", + "short", "signed", "sizeof", "static", "struct", "switch", "typedef", + "union", "unsigned", "void", "volatile", "while", "_Alignas", + "_Alignof", "_Atomic", "_Bool", "_Complex", "_Generic", "_Imaginary", + "_Noreturn", "_Static_assert", "_Thread_local", +] +_C_TYPES = [ + "int", "char", "float", "double", "long", "short", "unsigned", + "signed", "void", "size_t", "ssize_t", "int8_t", "int16_t", + "int32_t", "int64_t", "uint8_t", "uint16_t", "uint32_t", "uint64_t", + "bool", "FILE", "ptrdiff_t", "intptr_t", "uintptr_t", +] + +_LANG_C = LanguageDefinition( + name="c", + extensions=[".c", ".h"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.PREPROCESSOR, r"#\s*(?:include|define|undef|if|ifdef|ifndef|elif|else|endif|pragma|error|warning|line)\b[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+[uUlL]*"), + (TokenType.NUMBER, r"0[bB][01]+[uUlL]*"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?[fFlLuU]*"), + (TokenType.KEYWORD, _kw(_C_KEYWORDS)), + (TokenType.TYPE, _kw(_C_TYPES)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"->|<<|>>|\+\+|--|&&|\|\||[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# C++ +# --------------------------------------------------------------- +_CPP_KEYWORDS = _C_KEYWORDS + [ + "alignas", "alignof", "and", "and_eq", "asm", "bitand", "bitor", + "bool", "catch", "char8_t", "char16_t", "char32_t", "class", + "co_await", "co_return", "co_yield", "compl", "concept", "consteval", + "constexpr", "constinit", "const_cast", "decltype", "delete", + "dynamic_cast", "explicit", "export", "false", "friend", "module", + "mutable", "namespace", "new", "noexcept", "not", "not_eq", + "nullptr", "operator", "or", "or_eq", "private", "protected", + "public", "reinterpret_cast", "requires", "static_assert", + "static_cast", "template", "this", "throw", "true", "try", + "typeid", "typename", "using", "virtual", "wchar_t", "xor", "xor_eq", + "override", "final", "import", +] +_CPP_TYPES = _C_TYPES + [ + "string", "wstring", "vector", "map", "set", "unordered_map", + "unordered_set", "array", "deque", "list", "pair", "tuple", + "shared_ptr", "unique_ptr", "weak_ptr", "optional", "variant", + "any", "string_view", "span", +] + +_LANG_CPP = LanguageDefinition( + name="cpp", + extensions=[".cpp", ".cxx", ".cc", ".hpp", ".hxx", ".hh", ".h++"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.PREPROCESSOR, r"#\s*(?:include|define|undef|if|ifdef|ifndef|elif|else|endif|pragma|error|warning|line)\b[^\n]*"), + (TokenType.STRING, r'R"([^(]*)\([\s\S]*?\)\1"'), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F']+[uUlL]*"), + (TokenType.NUMBER, r"0[bB][01']+[uUlL]*"), + (TokenType.NUMBER, r"\d[\d']*\.[\d']*(?:[eE][+-]?\d[\d']*)?[fFlLuU]*"), + (TokenType.NUMBER, r"\d[\d']*[fFlLuU]*"), + (TokenType.KEYWORD, _kw(_CPP_KEYWORDS)), + (TokenType.TYPE, _kw(_CPP_TYPES)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"->|::|<<|>>|\+\+|--|&&|\|\||<=>|[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Java +# --------------------------------------------------------------- +_JAVA_KEYWORDS = [ + "abstract", "assert", "boolean", "break", "byte", "case", "catch", + "char", "class", "const", "continue", "default", "do", "double", + "else", "enum", "extends", "final", "finally", "float", "for", + "goto", "if", "implements", "import", "instanceof", "int", + "interface", "long", "native", "new", "package", "private", + "protected", "public", "record", "return", "sealed", "short", + "static", "strictfp", "super", "switch", "synchronized", "this", + "throw", "throws", "transient", "try", "var", "void", "volatile", + "while", "yield", "permits", "non-sealed", +] +_JAVA_BUILTINS = [ + "true", "false", "null", "System", "String", "Integer", "Double", + "Float", "Long", "Boolean", "Character", "Byte", "Short", + "Object", "Class", "Math", "Thread", "Runnable", +] + +_LANG_JAVA = LanguageDefinition( + name="java", + extensions=[".java"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.DECORATOR, r"@\w+(?:\.\w+)*"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+[lL]?"), + (TokenType.NUMBER, r"0[bB][01_]+[lL]?"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?[fFdD]?"), + (TokenType.NUMBER, r"\d[\d_]*[lLfFdD]?"), + (TokenType.KEYWORD, _kw(_JAVA_KEYWORDS)), + (TokenType.BUILTIN, _kw(_JAVA_BUILTINS)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r">>>?=?|<<=?|>>=?|\+\+|--|&&|\|\||->|::|[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_$]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Kotlin +# --------------------------------------------------------------- +_KOTLIN_KEYWORDS = [ + "abstract", "actual", "annotation", "as", "break", "by", "catch", + "class", "companion", "const", "constructor", "continue", "crossinline", + "data", "delegate", "do", "else", "enum", "expect", "external", + "false", "final", "finally", "for", "fun", "get", "if", "import", + "in", "infix", "init", "inline", "inner", "interface", "internal", + "is", "lateinit", "noinline", "null", "object", "open", "operator", + "out", "override", "package", "private", "protected", "public", + "reified", "return", "sealed", "set", "super", "suspend", "tailrec", + "this", "throw", "true", "try", "typealias", "typeof", "val", "var", + "vararg", "when", "where", "while", "yield", +] + +_LANG_KOTLIN = LanguageDefinition( + name="kotlin", + extensions=[".kt", ".kts"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r'"(?:[^"\\]|\\.|\$\{[^}]*\}|\$\w+)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.DECORATOR, r"@\w+(?:\.\w+)*"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+[lL]?"), + (TokenType.NUMBER, r"0[bB][01_]+[lL]?"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?[fF]?"), + (TokenType.NUMBER, r"\d[\d_]*[lLfF]?"), + (TokenType.KEYWORD, _kw(_KOTLIN_KEYWORDS)), + (TokenType.FUNCTION, r"(?<=\bfun\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"->|::|\.\.|\?\.|!!|\?:|[+\-*/%&|^~<>=!]=?|&&|\|\|"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Swift +# --------------------------------------------------------------- +_SWIFT_KEYWORDS = [ + "associatedtype", "async", "await", "break", "case", "catch", "class", + "continue", "default", "defer", "deinit", "do", "else", "enum", + "extension", "fallthrough", "false", "fileprivate", "final", "for", + "func", "guard", "if", "import", "in", "init", "inout", "internal", + "is", "lazy", "let", "mutating", "nil", "nonmutating", "open", + "operator", "optional", "override", "private", "protocol", "public", + "repeat", "required", "rethrows", "return", "self", "Self", "some", + "static", "struct", "subscript", "super", "switch", "throw", "throws", + "true", "try", "typealias", "unowned", "var", "weak", "where", + "while", "willSet", "didSet", "get", "set", "any", "actor", +] +_SWIFT_TYPES = [ + "Int", "Int8", "Int16", "Int32", "Int64", "UInt", "UInt8", "UInt16", + "UInt32", "UInt64", "Float", "Double", "Bool", "String", "Character", + "Array", "Dictionary", "Set", "Optional", "Result", "Void", "Never", + "Any", "AnyObject", +] + +_LANG_SWIFT = LanguageDefinition( + name="swift", + extensions=[".swift"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r'"(?:[^"\\]|\\.|\\\([^)]*\))*"'), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[oO][0-7_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?"), + (TokenType.NUMBER, r"\d[\d_]*"), + (TokenType.DECORATOR, r"@\w+"), + (TokenType.KEYWORD, _kw(_SWIFT_KEYWORDS)), + (TokenType.TYPE, _kw(_SWIFT_TYPES)), + (TokenType.FUNCTION, r"(?<=\bfunc\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"->|\.\.\.|\.\.<|&&|\|\||[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Rust +# --------------------------------------------------------------- +_RUST_KEYWORDS = [ + "as", "async", "await", "break", "const", "continue", "crate", + "dyn", "else", "enum", "extern", "false", "fn", "for", "if", + "impl", "in", "let", "loop", "match", "mod", "move", "mut", + "pub", "ref", "return", "self", "Self", "static", "struct", + "super", "trait", "true", "type", "unsafe", "use", "where", + "while", "yield", "abstract", "become", "box", "do", "final", + "macro", "override", "priv", "try", "typeof", "unsized", "virtual", +] +_RUST_TYPES = [ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", + "u64", "u128", "usize", "f32", "f64", "bool", "char", "str", + "String", "Vec", "Option", "Result", "Box", "Rc", "Arc", "Cell", + "RefCell", "HashMap", "HashSet", "BTreeMap", "BTreeSet", +] +_RUST_BUILTINS = [ + "println", "print", "eprintln", "eprint", "format", "vec", + "panic", "assert", "assert_eq", "assert_ne", "debug_assert", + "todo", "unimplemented", "unreachable", "cfg", "include", + "Some", "None", "Ok", "Err", +] + +_LANG_RUST = LanguageDefinition( + name="rust", + extensions=[".rs"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'r#*"[\s\S]*?"#*'), + (TokenType.STRING, r'b?"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"b?'(?:[^'\\]|\\.)*'"), + (TokenType.DECORATOR, r"#!\?\[[\s\S]*?\]"), + (TokenType.DECORATOR, r"#\[[\s\S]*?\]"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+(?:i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize|f32|f64)?"), + (TokenType.NUMBER, r"0[oO][0-7_]+(?:i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize)?"), + (TokenType.NUMBER, r"0[bB][01_]+(?:i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize)?"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?(?:f32|f64)?"), + (TokenType.NUMBER, r"\d[\d_]*(?:i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize|f32|f64)?"), + (TokenType.KEYWORD, _kw(_RUST_KEYWORDS)), + (TokenType.TYPE, _kw(_RUST_TYPES)), + (TokenType.BUILTIN, _kw(_RUST_BUILTINS)), + (TokenType.FUNCTION, r"(?<=\bfn\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"=>|->|::|\.\.=?|&&|\|\||<<=?|>>=?|[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Go +# --------------------------------------------------------------- +_GO_KEYWORDS = [ + "break", "case", "chan", "const", "continue", "default", "defer", + "else", "fallthrough", "for", "func", "go", "goto", "if", "import", + "interface", "map", "package", "range", "return", "select", "struct", + "switch", "type", "var", +] +_GO_TYPES = [ + "bool", "byte", "complex64", "complex128", "error", "float32", + "float64", "int", "int8", "int16", "int32", "int64", "rune", + "string", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", + "any", +] +_GO_BUILTINS = [ + "append", "cap", "close", "complex", "copy", "delete", "imag", + "len", "make", "new", "panic", "print", "println", "real", + "recover", "true", "false", "nil", "iota", +] + +_LANG_GO = LanguageDefinition( + name="go", + extensions=[".go"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'`[^`]*`'), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[oO][0-7_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?i?"), + (TokenType.NUMBER, r"\d[\d_]*i?"), + (TokenType.KEYWORD, _kw(_GO_KEYWORDS)), + (TokenType.TYPE, _kw(_GO_TYPES)), + (TokenType.BUILTIN, _kw(_GO_BUILTINS)), + (TokenType.FUNCTION, r"(?<=\bfunc\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r":=|<-|&&|\|\||<<|>>|&\^|[+\-*/%&|^~<>=!]=?|\+\+|--"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Ruby +# --------------------------------------------------------------- +_RUBY_KEYWORDS = [ + "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", + "def", "do", "else", "elsif", "end", "ensure", "false", + "for", "if", "in", "module", "next", "nil", "not", "or", "redo", + "rescue", "retry", "return", "self", "super", "then", "true", + "undef", "unless", "until", "when", "while", "yield", "__FILE__", + "__LINE__", "__ENCODING__", "raise", "require", "require_relative", + "include", "extend", "prepend", "attr_accessor", "attr_reader", + "attr_writer", "private", "protected", "public", "proc", "lambda", +] + +_LANG_RUBY = LanguageDefinition( + name="ruby", + extensions=[".rb", ".gemspec", ".rake"], + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.COMMENT, r"=begin[\s\S]*?=end"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.STRING, r"/(?:[^/\\]|\\.)*?/[imxouesn]*"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"0[oO]?[0-7_]+"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?"), + (TokenType.NUMBER, r"\d[\d_]*"), + (TokenType.VARIABLE, r"@@?\w+"), + (TokenType.VARIABLE, r"\$[A-Za-z_]\w*"), + (TokenType.KEYWORD, _kw(_RUBY_KEYWORDS)), + (TokenType.FUNCTION, r"(?<=\bdef\s)\w+[?!]?"), + (TokenType.OPERATOR, r"<=>|<<|>>|&&|\|\||\.\.\.?|\*\*|[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.IDENTIFIER, r":\w+"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*[?!]?"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# PHP +# --------------------------------------------------------------- +_PHP_KEYWORDS = [ + "abstract", "and", "array", "as", "break", "callable", "case", + "catch", "class", "clone", "const", "continue", "declare", "default", + "die", "do", "echo", "else", "elseif", "empty", "enddeclare", + "endfor", "endforeach", "endif", "endswitch", "endwhile", "enum", + "eval", "exit", "extends", "false", "final", "finally", "fn", "for", + "foreach", "function", "global", "goto", "if", "implements", + "include", "include_once", "instanceof", "insteadof", "interface", + "isset", "list", "match", "namespace", "new", "null", "or", + "print", "private", "protected", "public", "readonly", "require", + "require_once", "return", "static", "switch", "throw", "trait", + "true", "try", "unset", "use", "var", "while", "xor", "yield", +] + +_LANG_PHP = LanguageDefinition( + name="php", + extensions=[".php", ".phtml"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'"(?:[^"\\]|\\.|\$\{[^}]*\}|\$\w+)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.VARIABLE, r"\$[A-Za-z_]\w*"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?"), + (TokenType.NUMBER, r"\d[\d_]*"), + (TokenType.KEYWORD, _kw(_PHP_KEYWORDS)), + (TokenType.FUNCTION, r"(?<=\bfunction\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"=>|->|::|<=>|\?\?=?|\?->|&&|\|\||\.=?|<<=?|>>=?|\*\*=?|[+\-*/%&|^~<>=!]=?|\+\+|--"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>@]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# C# +# --------------------------------------------------------------- +_CSHARP_KEYWORDS = [ + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", + "char", "checked", "class", "const", "continue", "decimal", "default", + "delegate", "do", "double", "else", "enum", "event", "explicit", + "extern", "false", "finally", "fixed", "float", "for", "foreach", + "goto", "if", "implicit", "in", "int", "interface", "internal", + "is", "lock", "long", "namespace", "new", "null", "object", + "operator", "out", "override", "params", "private", "protected", + "public", "readonly", "record", "ref", "return", "sbyte", "sealed", + "short", "sizeof", "stackalloc", "static", "string", "struct", + "switch", "this", "throw", "true", "try", "typeof", "uint", + "ulong", "unchecked", "unsafe", "ushort", "using", "var", "virtual", + "void", "volatile", "while", "async", "await", "dynamic", "global", + "nameof", "notnull", "unmanaged", "value", "when", "where", "with", + "yield", "init", "required", "file", "scoped", +] + +_LANG_CSHARP = LanguageDefinition( + name="csharp", + extensions=[".cs"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.PREPROCESSOR, r"#\s*(?:if|elif|else|endif|define|undef|warning|error|line|region|endregion|pragma|nullable)\b[^\n]*"), + (TokenType.STRING, r'@"(?:[^"]|"")*"'), + (TokenType.STRING, r'\$"(?:[^"\\]|\\.|\{[^}]*\})*"'), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+[uUlLmMfFdD]*"), + (TokenType.NUMBER, r"0[bB][01_]+[uUlL]*"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?[fFdDmM]?"), + (TokenType.NUMBER, r"\d[\d_]*[uUlLfFdDmM]*"), + (TokenType.KEYWORD, _kw(_CSHARP_KEYWORDS)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"=>|\?\?=?|\?\.|\?\[|&&|\|\||<<=?|>>=?|\+\+|--|[+\-*/%&|^~<>=!]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"@?[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# HTML +# --------------------------------------------------------------- +_LANG_HTML = LanguageDefinition( + name="html", + extensions=[".html", ".htm", ".xhtml"], + patterns=[ + (TokenType.COMMENT, r""), + (TokenType.TAG, r"]*>"), + (TokenType.TAG, r""), + (TokenType.ATTRIBUTE, r'\w[\w-]*(?=\s*=)'), + (TokenType.STRING, r'"[^"]*"'), + (TokenType.STRING, r"'[^']*'"), + (TokenType.OPERATOR, r"="), + (TokenType.IDENTIFIER, r"[A-Za-z_][\w-]*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# CSS +# --------------------------------------------------------------- +_CSS_KEYWORDS = [ + "important", "inherit", "initial", "unset", "revert", "none", "auto", + "block", "inline", "flex", "grid", "absolute", "relative", "fixed", + "sticky", "static", +] + +_LANG_CSS = LanguageDefinition( + name="css", + extensions=[".css", ".scss", ".sass", ".less"], + patterns=[ + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"-?\d+\.?\d*(?:px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|deg|rad|grad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx|fr)?"), + (TokenType.VARIABLE, r"--[\w-]+"), + (TokenType.VARIABLE, r"\$[\w-]+"), + (TokenType.FUNCTION, r"\w[\w-]*(?=\s*\()"), + (TokenType.TAG, r"[.#][\w-]+"), + (TokenType.TAG, r"@[\w-]+"), + (TokenType.ATTRIBUTE, r"[\w-]+(?=\s*:)"), + (TokenType.KEYWORD, _kw(_CSS_KEYWORDS)), + (TokenType.OPERATOR, r"[>+~*=|^$]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,!]"), + (TokenType.IDENTIFIER, r"[\w-]+"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# SQL +# --------------------------------------------------------------- +_SQL_KEYWORDS = [ + "SELECT", "FROM", "WHERE", "INSERT", "INTO", "UPDATE", "DELETE", + "CREATE", "DROP", "ALTER", "TABLE", "INDEX", "VIEW", "DATABASE", + "SCHEMA", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "FULL", + "CROSS", "ON", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", + "LIKE", "IS", "NULL", "AS", "ORDER", "BY", "GROUP", "HAVING", + "LIMIT", "OFFSET", "UNION", "ALL", "DISTINCT", "CASE", "WHEN", + "THEN", "ELSE", "END", "SET", "VALUES", "BEGIN", "COMMIT", + "ROLLBACK", "TRANSACTION", "GRANT", "REVOKE", "PRIMARY", "KEY", + "FOREIGN", "REFERENCES", "CONSTRAINT", "CHECK", "DEFAULT", "UNIQUE", + "ASC", "DESC", "CASCADE", "TRUNCATE", "IF", "REPLACE", "TEMP", + "TEMPORARY", "WITH", "RECURSIVE", "RETURNING", "OVER", "PARTITION", + "WINDOW", "ROWS", "RANGE", "PRECEDING", "FOLLOWING", "CURRENT", + "ROW", "UNBOUNDED", "FETCH", "NEXT", "ONLY", "EXCEPT", "INTERSECT", + "LATERAL", "NATURAL", "USING", "EXPLAIN", "ANALYZE", + # Lowercase variants + "select", "from", "where", "insert", "into", "update", "delete", + "create", "drop", "alter", "table", "index", "view", "database", + "schema", "join", "inner", "left", "right", "outer", "full", + "cross", "on", "and", "or", "not", "in", "exists", "between", + "like", "is", "null", "as", "order", "by", "group", "having", + "limit", "offset", "union", "all", "distinct", "case", "when", + "then", "else", "end", "set", "values", "begin", "commit", + "rollback", "transaction", "grant", "revoke", "primary", "key", + "foreign", "references", "constraint", "check", "default", "unique", + "asc", "desc", "cascade", "truncate", "if", "replace", + "with", "recursive", "returning", +] +_SQL_TYPES = [ + "INT", "INTEGER", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", + "FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC", "CHAR", "VARCHAR", + "TEXT", "BLOB", "BOOLEAN", "DATE", "TIME", "TIMESTAMP", "DATETIME", + "JSON", "JSONB", "UUID", "BYTEA", "ARRAY", "INTERVAL", + "int", "integer", "smallint", "bigint", "serial", "bigserial", + "float", "real", "double", "decimal", "numeric", "char", "varchar", + "text", "blob", "boolean", "date", "time", "timestamp", "datetime", + "json", "jsonb", "uuid", "bytea", "array", "interval", +] +_SQL_BUILTINS = [ + "COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "NULLIF", + "CAST", "CONVERT", "IFNULL", "NVL", "ISNULL", "NOW", "CURRENT_DATE", + "CURRENT_TIME", "CURRENT_TIMESTAMP", "EXTRACT", "SUBSTRING", + "TRIM", "UPPER", "LOWER", "LENGTH", "CONCAT", "REPLACE", "ROUND", + "FLOOR", "CEIL", "ABS", "MOD", "POWER", "SQRT", + "ROW_NUMBER", "RANK", "DENSE_RANK", "NTILE", "LAG", "LEAD", + "FIRST_VALUE", "LAST_VALUE", + "count", "sum", "avg", "min", "max", "coalesce", "nullif", + "cast", "convert", "now", "length", "concat", "replace", "round", + "row_number", "rank", "dense_rank", +] + +_LANG_SQL = LanguageDefinition( + name="sql", + extensions=[".sql"], + patterns=[ + (TokenType.COMMENT, r"--[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r"'(?:[^'\\]|''|\\.)*'"), + (TokenType.STRING, r'"(?:[^"\\]|""|\\.)*"'), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.KEYWORD, _kw(_SQL_KEYWORDS)), + (TokenType.TYPE, _kw(_SQL_TYPES)), + (TokenType.BUILTIN, _kw(_SQL_BUILTINS)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"<>|!=|<=|>=|::|[+\-*/%<>=!]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.VARIABLE, r"@\w+"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) +# --------------------------------------------------------------- +# Shell / Bash +# --------------------------------------------------------------- +_SHELL_KEYWORDS = [ + "if", "then", "else", "elif", "fi", "case", "esac", "for", "while", + "until", "do", "done", "in", "function", "select", "time", "coproc", + "return", "exit", "break", "continue", "declare", "typeset", + "local", "export", "readonly", "unset", "shift", "source", "eval", + "exec", "trap", "set", "shopt", +] +_SHELL_BUILTINS = [ + "echo", "printf", "read", "cd", "pwd", "pushd", "popd", "dirs", + "let", "test", "true", "false", "getopts", "hash", "type", + "umask", "wait", "jobs", "fg", "bg", "kill", "alias", "unalias", + "bind", "builtin", "caller", "command", "compgen", "complete", + "enable", "help", "history", "logout", "mapfile", "readarray", +] + +_LANG_SHELL = LanguageDefinition( + name="shell", + extensions=[".sh", ".bash", ".zsh", ".ksh"], + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.|\$\{[^}]*\}|\$\([^)]*\)|\$\w+)*"'), + (TokenType.STRING, r"'[^']*'"), + (TokenType.VARIABLE, r"\$\{[^}]*\}"), + (TokenType.VARIABLE, r"\$\([^)]*\)"), + (TokenType.VARIABLE, r"\$[A-Za-z_]\w*"), + (TokenType.VARIABLE, r"\$[0-9@#?$!*-]"), + (TokenType.NUMBER, r"\d+\.?\d*"), + (TokenType.KEYWORD, _kw(_SHELL_KEYWORDS)), + (TokenType.BUILTIN, _kw(_SHELL_BUILTINS)), + (TokenType.FUNCTION, r"\w+(?=\s*\(\))"), + (TokenType.OPERATOR, r"&&|\|\||;;|[|&;><]=?|<<-?|>>"), + (TokenType.PUNCTUATION, r"[(){}\[\]]"), + (TokenType.IDENTIFIER, r"[A-Za-z_][\w.-]*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# YAML +# --------------------------------------------------------------- +_LANG_YAML = LanguageDefinition( + name="yaml", + extensions=[".yml", ".yaml"], + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.TAG, r"!!\w+"), + (TokenType.TAG, r"!\w+"), + (TokenType.KEYWORD, r"\b(?:true|false|yes|no|on|off|null|True|False|Yes|No|On|Off|Null|TRUE|FALSE|NULL)\b"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.ATTRIBUTE, r"[\w][\w ./-]*(?=\s*:)"), + (TokenType.NUMBER, r"-?\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.OPERATOR, r"[|>][-+]?"), + (TokenType.PUNCTUATION, r"[{}\[\]:,\-?&*]"), + (TokenType.VARIABLE, r"<<"), + (TokenType.IDENTIFIER, r"\S+"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# JSON +# --------------------------------------------------------------- +_LANG_JSON = LanguageDefinition( + name="json", + extensions=[".json", ".jsonc", ".json5"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.ATTRIBUTE, r'"(?:[^"\\]|\\.)*"(?=\s*:)'), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.KEYWORD, r"\b(?:true|false|null)\b"), + (TokenType.NUMBER, r"-?\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.PUNCTUATION, r"[{}\[\]:,]"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# TOML +# --------------------------------------------------------------- +_LANG_TOML = LanguageDefinition( + name="toml", + extensions=[".toml"], + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.TAG, r"\[\[[\w.]+\]\]"), + (TokenType.TAG, r"\[[\w.]+\]"), + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r"'''[\s\S]*?'''"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'[^']*'"), + (TokenType.KEYWORD, r"\b(?:true|false)\b"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F_]+"), + (TokenType.NUMBER, r"0[oO][0-7_]+"), + (TokenType.NUMBER, r"0[bB][01_]+"), + (TokenType.NUMBER, r"[+-]?\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?"), + (TokenType.NUMBER, r"[+-]?\d[\d_]*"), + (TokenType.NUMBER, r"[+-]?(?:inf|nan)"), + (TokenType.ATTRIBUTE, r"[\w-]+(?=\s*=)"), + (TokenType.OPERATOR, r"="), + (TokenType.PUNCTUATION, r"[{}\[\]:.,]"), + (TokenType.IDENTIFIER, r"[\w-]+"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Markdown +# --------------------------------------------------------------- +_LANG_MARKDOWN = LanguageDefinition( + name="markdown", + extensions=[".md", ".markdown", ".mkd"], + patterns=[ + (TokenType.COMMENT, r""), + (TokenType.STRING, r"`{3}[\s\S]*?`{3}"), + (TokenType.STRING, r"`[^`\n]+`"), + (TokenType.TAG, r"^#{1,6}\s+.*$"), + (TokenType.OPERATOR, r"^\s*[-*+]\s"), + (TokenType.OPERATOR, r"^\s*\d+\.\s"), + (TokenType.ATTRIBUTE, r"\[(?:[^\[\]\\]|\\.)*\]\([^)]*\)"), + (TokenType.ATTRIBUTE, r"!\[(?:[^\[\]\\]|\\.)*\]\([^)]*\)"), + (TokenType.KEYWORD, r"\*\*(?:[^*\\]|\\.)+\*\*"), + (TokenType.KEYWORD, r"__(?:[^_\\]|\\.)+__"), + (TokenType.BUILTIN, r"\*(?:[^*\\]|\\.)+\*"), + (TokenType.BUILTIN, r"_(?:[^_\\]|\\.)+_"), + (TokenType.PUNCTUATION, r"^---+$"), + (TokenType.PUNCTUATION, r"^===+$"), + (TokenType.IDENTIFIER, r"\S+"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Dart +# --------------------------------------------------------------- +_DART_KEYWORDS = [ + "abstract", "as", "assert", "async", "await", "base", "break", + "case", "catch", "class", "const", "continue", "covariant", + "default", "deferred", "do", "dynamic", "else", "enum", "export", + "extends", "extension", "external", "factory", "false", "final", + "finally", "for", "Function", "get", "hide", "if", "implements", + "import", "in", "interface", "is", "late", "library", "mixin", + "new", "null", "on", "operator", "part", "required", "rethrow", + "return", "sealed", "set", "show", "static", "super", "switch", + "sync", "this", "throw", "true", "try", "typedef", "var", "void", + "when", "while", "with", "yield", +] +_DART_TYPES = [ + "int", "double", "num", "String", "bool", "List", "Map", "Set", + "Future", "Stream", "Iterable", "dynamic", "void", "Never", "Null", + "Object", "Type", +] + +_LANG_DART = LanguageDefinition( + name="dart", + extensions=[".dart"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.STRING, r'r"""[\s\S]*?"""'), + (TokenType.STRING, r"r'''[\s\S]*?'''"), + (TokenType.STRING, r'"""[\s\S]*?"""'), + (TokenType.STRING, r"'''[\s\S]*?'''"), + (TokenType.STRING, r'r"[^"]*"'), + (TokenType.STRING, r"r'[^']*'"), + (TokenType.STRING, r'"(?:[^"\\]|\\.|\$\{[^}]*\}|\$\w+)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.|\$\{[^}]*\}|\$\w+)*'"), + (TokenType.DECORATOR, r"@\w+(?:\.\w+)*"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.KEYWORD, _kw(_DART_KEYWORDS)), + (TokenType.TYPE, _kw(_DART_TYPES)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"=>|\?\?=?|\?\.|\.\.|&&|\|\||<<=?|>>=?|>>>|[+\-*/%&|^~<>=!]=?|\+\+|--"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?<>]"), + (TokenType.IDENTIFIER, r"[A-Za-z_$]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Lua +# --------------------------------------------------------------- +_LUA_KEYWORDS = [ + "and", "break", "do", "else", "elseif", "end", "false", "for", + "function", "goto", "if", "in", "local", "nil", "not", "or", + "repeat", "return", "then", "true", "until", "while", +] +_LUA_BUILTINS = [ + "assert", "collectgarbage", "dofile", "error", "getmetatable", + "ipairs", "load", "loadfile", "next", "pairs", "pcall", "print", + "rawequal", "rawget", "rawlen", "rawset", "require", "select", + "setmetatable", "tonumber", "tostring", "type", "unpack", + "xpcall", "table", "string", "math", "io", "os", "coroutine", + "debug", "package", "utf8", +] + +_LANG_LUA = LanguageDefinition( + name="lua", + extensions=[".lua"], + patterns=[ + (TokenType.COMMENT, r"--\[\[[\s\S]*?\]\]"), + (TokenType.COMMENT, r"--[^\n]*"), + (TokenType.STRING, r"\[\[[\s\S]*?\]\]"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+(?:\.[0-9a-fA-F]+)?(?:[pP][+-]?\d+)?"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.KEYWORD, _kw(_LUA_KEYWORDS)), + (TokenType.BUILTIN, _kw(_LUA_BUILTINS)), + (TokenType.FUNCTION, r"(?<=\bfunction\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"\.\.\.?|~=|<=|>=|==|<<|>>|//|[+\-*/%^#<>=]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# R +# --------------------------------------------------------------- +_R_KEYWORDS = [ + "if", "else", "repeat", "while", "function", "for", "in", "next", + "break", "TRUE", "FALSE", "NULL", "Inf", "NaN", "NA", "NA_integer_", + "NA_real_", "NA_complex_", "NA_character_", "return", "library", + "require", "source", +] +_R_BUILTINS = [ + "c", "list", "matrix", "array", "factor", "vector", + "print", "cat", "paste", "paste0", "sprintf", "length", "nchar", + "substr", "grep", "grepl", "sub", "gsub", "which", "any", "all", + "sum", "mean", "median", "sd", "var", "min", "max", "range", + "seq", "rep", "rev", "sort", "order", "unique", "duplicated", + "table", "apply", "sapply", "lapply", "tapply", "mapply", + "class", "typeof", "str", "summary", "head", "tail", "names", + "nrow", "ncol", "dim", "rbind", "cbind", "merge", +] + +_LANG_R = LanguageDefinition( + name="r", + extensions=[".r", ".R", ".Rmd"], + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+[Li]?"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?[Li]?"), + (TokenType.KEYWORD, _kw(_R_KEYWORDS)), + (TokenType.BUILTIN, _kw(_R_BUILTINS)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"<-|->|<<-|->>|%%|%/%|%in%|\|>|&&|\|\||[+\-*/^<>=!&|~$@]=?"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.VARIABLE, r"\.\w+"), + (TokenType.IDENTIFIER, r"[A-Za-z_][\w.]*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# MATLAB +# --------------------------------------------------------------- +_MATLAB_KEYWORDS = [ + "break", "case", "catch", "classdef", "continue", "else", "elseif", + "end", "for", "function", "global", "if", "methods", "otherwise", + "parfor", "persistent", "properties", "return", "spmd", "switch", + "try", "while", "events", "enumeration", "arguments", +] +_MATLAB_BUILTINS = [ + "abs", "acos", "asin", "atan", "atan2", "ceil", "cos", "exp", + "floor", "log", "log2", "log10", "max", "min", "mod", "pow", + "round", "sin", "sqrt", "tan", "zeros", "ones", "eye", "rand", + "randn", "linspace", "logspace", "length", "size", "numel", + "reshape", "repmat", "cat", "disp", "fprintf", "sprintf", + "plot", "figure", "hold", "xlabel", "ylabel", "title", "legend", + "subplot", "mesh", "surf", "contour", "imagesc", "colorbar", + "true", "false", "pi", "inf", "nan", "eps", "realmin", "realmax", +] + +_LANG_MATLAB = LanguageDefinition( + name="matlab", + extensions=[".m", ".mat"], + patterns=[ + (TokenType.COMMENT, r"%\{[\s\S]*?%\}"), + (TokenType.COMMENT, r"%[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|'')*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?[ij]?"), + (TokenType.KEYWORD, _kw(_MATLAB_KEYWORDS)), + (TokenType.BUILTIN, _kw(_MATLAB_BUILTINS)), + (TokenType.FUNCTION, r"(?<=\bfunction\s)\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"\.\*|\./|\.\^|\.\\|\.'" + r"|~=|<=|>=|==|&&|\|\||[+\-*/\\^<>=~&|:@]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,?]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# VHDL +# --------------------------------------------------------------- +_VHDL_KEYWORDS = [ + "abs", "access", "after", "alias", "all", "and", "architecture", + "array", "assert", "attribute", "begin", "block", "body", "buffer", + "bus", "case", "component", "configuration", "constant", "disconnect", + "downto", "else", "elsif", "end", "entity", "exit", "file", + "for", "function", "generate", "generic", "group", "guarded", + "if", "impure", "in", "inertial", "inout", "is", "label", + "library", "linkage", "literal", "loop", "map", "mod", "nand", + "new", "next", "nor", "not", "null", "of", "on", "open", "or", + "others", "out", "package", "port", "postponed", "procedure", + "process", "pure", "range", "record", "register", "reject", + "rem", "report", "return", "rol", "ror", "select", "severity", + "signal", "shared", "sla", "sll", "sra", "srl", "subtype", + "then", "to", "transport", "type", "unaffected", "units", "until", + "use", "variable", "wait", "when", "while", "with", "xnor", "xor", +] +_VHDL_TYPES = [ + "bit", "bit_vector", "boolean", "character", "integer", "natural", + "positive", "real", "string", "time", "std_logic", "std_logic_vector", + "std_ulogic", "std_ulogic_vector", "signed", "unsigned", +] + +_LANG_VHDL = LanguageDefinition( + name="vhdl", + extensions=[".vhd", ".vhdl"], + patterns=[ + (TokenType.COMMENT, r"--[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'.'"), + (TokenType.NUMBER, r"16#[0-9a-fA-F_]+#"), + (TokenType.NUMBER, r"2#[01_]+#"), + (TokenType.NUMBER, r"8#[0-7_]+#"), + (TokenType.NUMBER, r"\d[\d_]*\.[\d_]*(?:[eE][+-]?\d[\d_]*)?"), + (TokenType.NUMBER, r"\d[\d_]*"), + (TokenType.KEYWORD, _kw(_VHDL_KEYWORDS)), + (TokenType.TYPE, _kw(_VHDL_TYPES)), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"<=|=>|:=|/=|>=|<<|>>|\*\*|[+\-*/&<>=|]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;.,']"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Verilog +# --------------------------------------------------------------- +_VERILOG_KEYWORDS = [ + "always", "and", "assign", "automatic", "begin", "buf", "bufif0", + "bufif1", "case", "casex", "casez", "cell", "cmos", "config", + "deassign", "default", "defparam", "design", "disable", "edge", + "else", "end", "endcase", "endconfig", "endfunction", "endgenerate", + "endmodule", "endprimitive", "endspecify", "endtable", "endtask", + "event", "for", "force", "forever", "fork", "function", "generate", + "genvar", "highz0", "highz1", "if", "ifnone", "incdir", "include", + "initial", "inout", "input", "instance", "integer", "join", + "large", "liblist", "library", "localparam", "macromodule", + "medium", "module", "nand", "negedge", "nmos", "nor", "not", + "notif0", "notif1", "or", "output", "parameter", "pmos", + "posedge", "primitive", "pull0", "pull1", "pulldown", "pullup", + "rcmos", "real", "realtime", "reg", "release", "repeat", "rnmos", + "rpmos", "rtran", "rtranif0", "rtranif1", "scalared", "signed", + "small", "specify", "specparam", "strong0", "strong1", "supply0", + "supply1", "table", "task", "time", "tran", "tranif0", "tranif1", + "tri", "tri0", "tri1", "triand", "trior", "trireg", "unsigned", + "use", "vectored", "wait", "wand", "weak0", "weak1", "while", + "wire", "wor", "xnor", "xor", +] + +_LANG_VERILOG = LanguageDefinition( + name="verilog", + extensions=[".v", ".sv", ".vh", ".svh"], + patterns=[ + (TokenType.COMMENT, r"//[^\n]*"), + (TokenType.COMMENT, r"/\*[\s\S]*?\*/"), + (TokenType.PREPROCESSOR, r"`\w+"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.NUMBER, r"\d+'[bBhHoOdD][0-9a-fA-FxXzZ_]+"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.KEYWORD, _kw(_VERILOG_KEYWORDS)), + (TokenType.VARIABLE, r"\$\w+"), + (TokenType.FUNCTION, r"\w+(?=\s*\()"), + (TokenType.OPERATOR, r"<<<|>>>|===|!==|<=|>=|==|!=|&&|\|\||<<|>>|[+\-*/%&|^~<>=!?:]"), + (TokenType.PUNCTUATION, r"[(){}\[\];.,#@]"), + (TokenType.IDENTIFIER, r"[A-Za-z_]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Assembly (generic x86-style) +# --------------------------------------------------------------- +_ASM_KEYWORDS = [ + "mov", "add", "sub", "mul", "div", "and", "or", "xor", "not", + "shl", "shr", "cmp", "jmp", "je", "jne", "jg", "jge", "jl", + "jle", "ja", "jae", "jb", "jbe", "jz", "jnz", "call", "ret", + "push", "pop", "lea", "nop", "int", "syscall", "enter", "leave", + "inc", "dec", "neg", "test", "movzx", "movsx", "imul", "idiv", + "cdq", "cbw", "cwde", "rep", "movsb", "stosb", "lodsb", + "db", "dw", "dd", "dq", "resb", "resw", "resd", "resq", + "equ", "times", "section", "segment", "global", "extern", + "org", "bits", +] +_ASM_REGISTERS = [ + "eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp", + "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rsp", "rbp", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", + "al", "bl", "cl", "dl", "ah", "bh", "ch", "dh", + "ax", "bx", "cx", "dx", "si", "di", "sp", "bp", + "cs", "ds", "es", "fs", "gs", "ss", "cr0", "cr2", "cr3", "cr4", + "xmm0", "xmm1", "xmm2", "xmm3", "xmm4", "xmm5", "xmm6", "xmm7", +] + +_LANG_ASSEMBLY = LanguageDefinition( + name="assembly", + extensions=[".asm", ".s", ".S"], + patterns=[ + (TokenType.COMMENT, r";[^\n]*"), + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.PREPROCESSOR, r"%\w+"), + (TokenType.PREPROCESSOR, r"\.\w+"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+[hH]?"), + (TokenType.NUMBER, r"0[bB][01]+"), + (TokenType.NUMBER, r"[0-9a-fA-F]+[hH]"), + (TokenType.NUMBER, r"\d+\.?\d*"), + (TokenType.BUILTIN, _kw(_ASM_REGISTERS)), + (TokenType.KEYWORD, _kw(_ASM_KEYWORDS)), + (TokenType.TAG, r"\w+:"), + (TokenType.OPERATOR, r"[+\-*/%,<>]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:]"), + (TokenType.IDENTIFIER, r"[A-Za-z_.]\w*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Dockerfile +# --------------------------------------------------------------- +_DOCKERFILE_KEYWORDS = [ + "FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", + "ADD", "COPY", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", + "ONBUILD", "STOPSIGNAL", "HEALTHCHECK", "SHELL", +] + +_LANG_DOCKERFILE = LanguageDefinition( + name="dockerfile", + extensions=[], # detected by filename + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.KEYWORD, r"\b(?:" + "|".join(_DOCKERFILE_KEYWORDS) + r")\b"), + (TokenType.VARIABLE, r"\$\{[^}]*\}"), + (TokenType.VARIABLE, r"\$[A-Za-z_]\w*"), + (TokenType.NUMBER, r"\d+"), + (TokenType.OPERATOR, r"[=\\]"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;,]"), + (TokenType.IDENTIFIER, r"[A-Za-z_][\w./-]*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Makefile +# --------------------------------------------------------------- +_LANG_MAKEFILE = LanguageDefinition( + name="makefile", + extensions=[], # detected by filename + patterns=[ + (TokenType.COMMENT, r"#[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)*'"), + (TokenType.KEYWORD, r"\b(?:ifeq|ifneq|ifdef|ifndef|else|endif|include|sinclude|override|export|unexport|define|endef|vpath)\b"), + (TokenType.BUILTIN, r"\$\([^)]+\)"), + (TokenType.BUILTIN, r"\$\{[^}]+\}"), + (TokenType.BUILTIN, r"\$[@<^+?*%]"), + (TokenType.VARIABLE, r"\$[A-Za-z_]\w*"), + (TokenType.TAG, r"^[\w./%\-]+\s*:(?!=)"), + (TokenType.OPERATOR, r"[?+:]?=|&&|\|\|"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;,\\|]"), + (TokenType.IDENTIFIER, r"[A-Za-z_][\w.-]*"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Haskell +# --------------------------------------------------------------- +_HASKELL_KEYWORDS = [ + "as", "case", "class", "data", "default", "deriving", "do", "else", + "family", "forall", "foreign", "hiding", "if", "import", "in", + "infix", "infixl", "infixr", "instance", "let", "mdo", "module", + "newtype", "of", "proc", "qualified", "rec", "then", "type", + "where", "pattern", +] +_HASKELL_BUILTINS = [ + "True", "False", "Nothing", "Just", "Left", "Right", "IO", + "Maybe", "Either", "String", "Int", "Integer", "Float", "Double", + "Char", "Bool", "Show", "Read", "Eq", "Ord", "Num", "Enum", + "Bounded", "Integral", "Floating", "Monad", "Functor", + "Applicative", "Foldable", "Traversable", "Semigroup", "Monoid", + "map", "filter", "foldl", "foldr", "head", "tail", "init", "last", + "length", "null", "reverse", "concat", "concatMap", "zip", + "unzip", "take", "drop", "span", "elem", "notElem", + "lookup", "print", "putStrLn", "putStr", "getLine", "readLn", + "show", "read", "error", "undefined", +] + +_LANG_HASKELL = LanguageDefinition( + name="haskell", + extensions=[".hs", ".lhs"], + patterns=[ + (TokenType.COMMENT, r"\{-[\s\S]*?-\}"), + (TokenType.COMMENT, r"--[^\n]*"), + (TokenType.STRING, r'"(?:[^"\\]|\\.)*"'), + (TokenType.STRING, r"'(?:[^'\\]|\\.)'"), + (TokenType.NUMBER, r"0[xX][0-9a-fA-F]+"), + (TokenType.NUMBER, r"0[oO][0-7]+"), + (TokenType.NUMBER, r"\d+\.?\d*(?:[eE][+-]?\d+)?"), + (TokenType.KEYWORD, _kw(_HASKELL_KEYWORDS)), + (TokenType.BUILTIN, _kw(_HASKELL_BUILTINS)), + (TokenType.TYPE, r"\b[A-Z]\w*"), + (TokenType.OPERATOR, r"=>|->|<-|\.\.|::|\\|[+\-*/%<>=!&|^~@.?$]+"), + (TokenType.PUNCTUATION, r"[(){}\[\]:;,`]"), + (TokenType.IDENTIFIER, r"[a-z_]\w*'?"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + +# --------------------------------------------------------------- +# Plaintext (fallback) +# --------------------------------------------------------------- +_LANG_PLAINTEXT = LanguageDefinition( + name="plaintext", + extensions=[".txt", ".text", ".log"], + patterns=[ + (TokenType.IDENTIFIER, r"\S+"), + (TokenType.NEWLINE, r"\n"), + (TokenType.WHITESPACE, r"[ \t\r]+"), + ], +) + + +# =================================================================== +# Language registry +# =================================================================== + +_ALL_LANGUAGES: Dict[str, LanguageDefinition] = {} +_EXT_MAP: Dict[str, str] = {} + + +def _register(lang: LanguageDefinition) -> None: + _ALL_LANGUAGES[lang.name] = lang + for ext in lang.extensions: + _EXT_MAP[ext.lower()] = lang.name + + +# Register every language +_register(_LANG_PYTHON) +_register(_LANG_JAVASCRIPT) +_register(_LANG_TYPESCRIPT) +_register(_LANG_C) +_register(_LANG_CPP) +_register(_LANG_JAVA) +_register(_LANG_KOTLIN) +_register(_LANG_SWIFT) +_register(_LANG_RUST) +_register(_LANG_GO) +_register(_LANG_RUBY) +_register(_LANG_PHP) +_register(_LANG_CSHARP) +_register(_LANG_HTML) +_register(_LANG_CSS) +_register(_LANG_SQL) +_register(_LANG_SHELL) +_register(_LANG_YAML) +_register(_LANG_JSON) +_register(_LANG_TOML) +_register(_LANG_MARKDOWN) +_register(_LANG_DART) +_register(_LANG_LUA) +_register(_LANG_R) +_register(_LANG_MATLAB) +_register(_LANG_VHDL) +_register(_LANG_VERILOG) +_register(_LANG_ASSEMBLY) +_register(_LANG_DOCKERFILE) +_register(_LANG_MAKEFILE) +_register(_LANG_HASKELL) +_register(_LANG_PLAINTEXT) + +# Filename-only detection +_FILENAME_MAP: Dict[str, str] = { + "Dockerfile": "dockerfile", + "dockerfile": "dockerfile", + "Makefile": "makefile", + "makefile": "makefile", + "GNUmakefile": "makefile", + "Rakefile": "ruby", + "Gemfile": "ruby", + "CMakeLists.txt": "makefile", + "Jenkinsfile": "shell", + ".bashrc": "shell", + ".bash_profile": "shell", + ".zshrc": "shell", + ".profile": "shell", +} + + +# =================================================================== +# SyntaxHighlighter +# =================================================================== class SyntaxHighlighter: - def __init__(self, language: str = "python") -> None: - self.language = language - self._keywords: Dict[str, List[str]] = {} + """Token-based syntax highlighter with multi-language and theme support. - def highlight(self, source: str) -> str: - return source + Backward-compatible with the original stub API: + ``__init__(language="python")``, ``highlight(source)``, ``set_language(language)`` + + Parameters + ---------- + language : str + Language name (case-insensitive). Defaults to ``"python"``. + theme : str + Theme name. Defaults to ``"monokai"``. + """ + + # Class-level caches + _compiled_patterns: ClassVar[Dict[str, List[Tuple[TokenType, re.Pattern]]]] = {} + + def __init__(self, language: str = "python", theme: str = "monokai") -> None: + self._language_name: str = "" + self._lang: LanguageDefinition = _LANG_PLAINTEXT + self._theme: Theme = BUILTIN_THEMES.get("monokai", list(BUILTIN_THEMES.values())[0]) + self.set_language(language) + self.set_theme(theme) + + # ------------------------------------------------------------------ + # Language / theme management + # ------------------------------------------------------------------ def set_language(self, language: str) -> None: - self.language = language + """Switch the active language (case-insensitive).""" + key = language.lower().replace("-", "").replace(" ", "") + # Try common aliases + alias: Dict[str, str] = { + "c++": "cpp", "cplusplus": "cpp", "cxx": "cpp", + "c#": "csharp", "cs": "csharp", + "js": "javascript", "jsx": "javascript", + "ts": "typescript", "tsx": "typescript", + "py": "python", "python3": "python", + "rb": "ruby", + "sh": "shell", "bash": "shell", "zsh": "shell", + "yml": "yaml", + "md": "markdown", + "asm": "assembly", "nasm": "assembly", "masm": "assembly", + "docker": "dockerfile", + "make": "makefile", + "hs": "haskell", + "objc": "c", "objectivec": "c", + "golang": "go", + "kt": "kotlin", "kts": "kotlin", + } + resolved = alias.get(key, key) + if resolved in _ALL_LANGUAGES: + self._language_name = resolved + self._lang = _ALL_LANGUAGES[resolved] + else: + self._language_name = "plaintext" + self._lang = _LANG_PLAINTEXT + + @property + def language(self) -> str: + return self._language_name + + def set_theme(self, theme: str) -> None: + """Switch the active theme (case-insensitive, hyphens accepted).""" + key = theme.lower().replace("-", "_").replace(" ", "_") + if key in BUILTIN_THEMES: + self._theme = BUILTIN_THEMES[key] + + @property + def theme(self) -> str: + return self._theme.name + + def load_custom_theme(self, path: str) -> None: + """Load a theme from a JSON file. + + Expected JSON format:: + + { + "name": "my_theme", + "background": "#1e1e1e", + "foreground": "#d4d4d4", + "colors": { + "KEYWORD": "#569cd6", + "STRING": "#ce9178", + ... + } + } + """ + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + mapping: Dict[TokenType, str] = {} + for key, val in data.get("colors", {}).items(): + try: + tt = TokenType[key.upper()] + mapping[tt] = val + except KeyError: + pass + self._theme = _make_theme( + data.get("name", "custom"), + data.get("background", "#1e1e1e"), + data.get("foreground", "#d4d4d4"), + mapping, + ) + + # ------------------------------------------------------------------ + # Class methods + # ------------------------------------------------------------------ + + @classmethod + def get_supported_languages(cls) -> List[str]: + """Return a sorted list of supported language names.""" + return sorted(_ALL_LANGUAGES.keys()) + + @classmethod + def get_supported_themes(cls) -> List[str]: + """Return a sorted list of built-in theme names.""" + return sorted(BUILTIN_THEMES.keys()) + + @staticmethod + def detect_language(filename: str) -> str: + """Detect a language from a file name or path. + + Returns the language name string, or ``"plaintext"`` if unknown. + """ + basename = Path(filename).name + # Check exact filename first + if basename in _FILENAME_MAP: + return _FILENAME_MAP[basename] + # Check extension + ext = Path(filename).suffix.lower() + return _EXT_MAP.get(ext, "plaintext") + + # ------------------------------------------------------------------ + # Compilation (cached per language) + # ------------------------------------------------------------------ + + def _get_compiled(self) -> List[Tuple[TokenType, re.Pattern]]: + name = self._lang.name + if name not in self._compiled_patterns: + compiled: List[Tuple[TokenType, re.Pattern]] = [] + for tt, pat in self._lang.patterns: + try: + compiled.append((tt, re.compile(pat, re.MULTILINE))) + except re.error: + # Skip invalid patterns gracefully + pass + self._compiled_patterns[name] = compiled + return self._compiled_patterns[name] + + # ------------------------------------------------------------------ + # Tokenisation + # ------------------------------------------------------------------ + + def tokenize(self, source: str) -> List[Token]: + """Tokenize *source* into a list of :class:`Token` objects. + + Unrecognised characters are emitted as ``TokenType.UNKNOWN``. + """ + compiled = self._get_compiled() + tokens: List[Token] = [] + pos = 0 + length = len(source) + + while pos < length: + best_match: Optional[re.Match] = None + best_type: TokenType = TokenType.UNKNOWN + + for tt, rx in compiled: + m = rx.match(source, pos) + if m: + best_match = m + best_type = tt + break # first-match wins (patterns are priority-ordered) + + if best_match: + val = best_match.group() + tokens.append(Token(type=best_type, value=val, start=pos, end=pos + len(val))) + pos += len(val) + else: + # Single-character fallback + tokens.append(Token(type=TokenType.UNKNOWN, value=source[pos], start=pos, end=pos + 1)) + pos += 1 + + return tokens + + # ------------------------------------------------------------------ + # Highlight (ANSI terminal) + # ------------------------------------------------------------------ + + def highlight(self, source: str) -> str: + """Return *source* highlighted with ANSI 24-bit colour codes. + + This is the original stub method, preserved for backward compatibility. + """ + tokens = self.tokenize(source) + parts: List[str] = [] + colors = self._theme.colors + for tok in tokens: + if tok.type in (TokenType.WHITESPACE, TokenType.NEWLINE): + parts.append(tok.value) + else: + ansi = _hex_to_ansi(colors.get(tok.type, self._theme.foreground)) + parts.append(f"{ansi}{tok.value}{_ANSI_RESET}") + return "".join(parts) + + # ------------------------------------------------------------------ + # Highlight (HTML) + # ------------------------------------------------------------------ + + @staticmethod + def _html_escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + def highlight_html(self, source: str) -> str: + """Return *source* as an HTML string with inline ``color`` styles. + + Each token is wrapped in ``...``. + The result is wrapped in a ``
`` block with the theme
+        background/foreground applied.
+        """
+        tokens = self.tokenize(source)
+        colors = self._theme.colors
+        parts: List[str] = []
+        parts.append(
+            f'
'
+            f""
+        )
+        for tok in tokens:
+            escaped = self._html_escape(tok.value)
+            if tok.type in (TokenType.WHITESPACE, TokenType.NEWLINE):
+                parts.append(escaped)
+            else:
+                color = colors.get(tok.type, self._theme.foreground)
+                css_class = tok.type.name.lower()
+                parts.append(f'{escaped}')
+        parts.append("
") + return "".join(parts) diff --git a/eostudio/core/ide/terminal.py b/eostudio/core/ide/terminal.py index 56ae517..d05d268 100644 --- a/eostudio/core/ide/terminal.py +++ b/eostudio/core/ide/terminal.py @@ -1,20 +1,764 @@ -"""Terminal emulator (stub).""" +"""Terminal emulator for EoStudio IDE. + +Provides PTY-based terminal sessions with ANSI parsing, command history, +cross-platform shell detection, and multi-session management. +""" from __future__ import annotations -from typing import Optional +import json +import os +import re +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple +# Platform-specific imports (guarded) +_IS_WINDOWS = sys.platform == "win32" + +if not _IS_WINDOWS: + import fcntl + import pty + import struct + import termios + + +# --------------------------------------------------------------------------- +# ANSI escape sequence parser +# --------------------------------------------------------------------------- + +# Matches all ANSI escape sequences: CSI (ESC[), OSC (ESC]), and simple (ESC + char) +_ANSI_RE = re.compile( + r""" + (?:\x1b # ESC character + (?: + \[ # CSI - Control Sequence Introducer + [0-9;]* # parameter bytes + [A-Za-z] # final byte + | + \] # OSC - Operating System Command + .*? # payload + (?:\x1b\\|\x07) # ST (ESC\) or BEL + | + [()#][0-9A-Za-z]? # Character set / line drawing + | + [A-Za-z] # Simple two-char sequence (e.g. ESC M) + ) + ) + """, + re.VERBOSE, +) + +# Matches CSI sequences specifically for structured parsing +_CSI_RE = re.compile(r"\x1b\[([0-9;]*)([A-Za-z])") + + +class AnsiParser: + """Parses and processes ANSI escape sequences in terminal output.""" + + # SGR (Select Graphic Rendition) color names + _SGR_COLORS = { + 0: "reset", 1: "bold", 2: "dim", 3: "italic", 4: "underline", + 7: "inverse", 8: "hidden", 9: "strikethrough", + 30: "black", 31: "red", 32: "green", 33: "yellow", + 34: "blue", 35: "magenta", 36: "cyan", 37: "white", + 40: "bg_black", 41: "bg_red", 42: "bg_green", 43: "bg_yellow", + 44: "bg_blue", 45: "bg_magenta", 46: "bg_cyan", 47: "bg_white", + 90: "bright_black", 91: "bright_red", 92: "bright_green", + 93: "bright_yellow", 94: "bright_blue", 95: "bright_magenta", + 96: "bright_cyan", 97: "bright_white", + } + + @staticmethod + def strip(text: str) -> str: + """Remove all ANSI escape sequences from *text*.""" + return _ANSI_RE.sub("", text) + + @staticmethod + def parse(text: str) -> List[Tuple[str, str, List[int]]]: + """Parse *text* into segments of ``(content, seq_type, params)``. + + Each tuple contains: + - *content*: the text chunk **before** the sequence (may be empty). + - *seq_type*: the CSI final byte (e.g. ``'m'`` for SGR) or ``''`` + for the trailing plain-text segment. + - *params*: list of integer parameters from the CSI sequence. + """ + segments: List[Tuple[str, str, List[int]]] = [] + last_end = 0 + for m in _CSI_RE.finditer(text): + plain = _ANSI_RE.sub("", text[last_end:m.start()]) + params_str = m.group(1) + params = [int(p) for p in params_str.split(";") if p] if params_str else [0] + segments.append((plain, m.group(2), params)) + last_end = m.end() + # Trailing plain text + trailing = _ANSI_RE.sub("", text[last_end:]) + if trailing or not segments: + segments.append((trailing, "", [])) + return segments + + @classmethod + def to_html(cls, text: str) -> str: + """Convert ANSI-colored *text* to simple HTML ```` tags.""" + from html import escape as html_escape + + parts: List[str] = [] + open_spans = 0 + for plain, seq_type, params in cls.parse(text): + if plain: + parts.append(html_escape(plain)) + if seq_type == "m": + for p in params: + if p == 0: + parts.append("" * open_spans) + open_spans = 0 + elif p in cls._SGR_COLORS: + parts.append(f'') + open_spans += 1 + parts.append("" * open_spans) + return "".join(parts) -class TerminalEmulator: - def __init__(self) -> None: - self._output: str = "" - def execute(self, command: str) -> str: - self._output = f"$ {command}\n(stub — not executed)" - return self._output +# --------------------------------------------------------------------------- +# Shell detection +# --------------------------------------------------------------------------- + +def _detect_shell() -> str: + """Return the path to the best available shell for the current platform.""" + if _IS_WINDOWS: + # Prefer PowerShell 7+ (pwsh) > PowerShell 5 > cmd + for candidate in ("pwsh.exe", "powershell.exe", "cmd.exe"): + found = _which(candidate) + if found: + return found + return "cmd.exe" + + # Unix: check $SHELL, then try common shells + shell = os.environ.get("SHELL", "") + if shell and os.path.isfile(shell): + return shell + + for candidate in ("bash", "zsh", "fish", "sh"): + found = _which(candidate) + if found: + return found + return "/bin/sh" + + +def _which(name: str) -> Optional[str]: + """Minimal which(1) implementation using PATH.""" + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + extensions = [""] + if _IS_WINDOWS: + extensions = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") + for d in path_dirs: + for ext in extensions: + full = os.path.join(d, name + ext) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +# --------------------------------------------------------------------------- +# Command history with persistence +# --------------------------------------------------------------------------- + +_HISTORY_DIR = Path.home() / ".eostudio" +_HISTORY_FILE = _HISTORY_DIR / "terminal_history.json" +_MAX_HISTORY = 5000 + + +class CommandHistory: + """Per-session command history with JSON persistence.""" + + def __init__(self, max_entries: int = _MAX_HISTORY) -> None: + self._entries: List[str] = [] + self._max = max_entries + self._lock = threading.Lock() + self._load() + + # -- public API -- + + def add(self, command: str) -> None: + cmd = command.strip() + if not cmd: + return + with self._lock: + # Deduplicate consecutive + if self._entries and self._entries[-1] == cmd: + return + self._entries.append(cmd) + if len(self._entries) > self._max: + self._entries = self._entries[-self._max:] + self._save() + + def search(self, prefix: str) -> List[str]: + with self._lock: + return [e for e in self._entries if e.startswith(prefix)] + + def get_all(self) -> List[str]: + with self._lock: + return list(self._entries) def clear(self) -> None: - self._output = "" + with self._lock: + self._entries.clear() + self._save() + + @property + def last(self) -> Optional[str]: + with self._lock: + return self._entries[-1] if self._entries else None + + def __len__(self) -> int: + with self._lock: + return len(self._entries) + + # -- persistence -- + + def _load(self) -> None: + try: + if _HISTORY_FILE.is_file(): + data = json.loads(_HISTORY_FILE.read_text(encoding="utf-8")) + if isinstance(data, list): + self._entries = [str(e) for e in data[-self._max:]] + except (json.JSONDecodeError, OSError): + self._entries = [] + + def _save(self) -> None: + try: + _HISTORY_DIR.mkdir(parents=True, exist_ok=True) + _HISTORY_FILE.write_text( + json.dumps(self._entries, ensure_ascii=False), + encoding="utf-8", + ) + except OSError: + pass # Best-effort persistence + + +# --------------------------------------------------------------------------- +# Terminal session +# --------------------------------------------------------------------------- + +class TerminalSession: + """A single terminal session backed by a PTY (Unix) or piped subprocess (Windows). + + Each session owns its own shell process, output buffer, and state. + """ + + _next_id = 0 + _id_lock = threading.Lock() + + def __init__( + self, + shell: Optional[str] = None, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + rows: int = 24, + cols: int = 80, + ) -> None: + with TerminalSession._id_lock: + self.id: int = TerminalSession._next_id + TerminalSession._next_id += 1 + + self._shell = shell or _detect_shell() + self._cwd = cwd or os.getcwd() + self._env = {**os.environ, **(env or {})} + self._rows = rows + self._cols = cols + + self._output_buf: List[str] = [] + self._output_lock = threading.Lock() + self._exit_status: Optional[int] = None + self._alive = False + + # PTY fd (Unix) or process handles (Windows) + self._master_fd: Optional[int] = None + self._process: Optional[subprocess.Popen] = None + self._reader_thread: Optional[threading.Thread] = None + + self._history = CommandHistory() + + self._start() + + # -- lifecycle -- + + def _start(self) -> None: + if _IS_WINDOWS: + self._start_windows() + else: + self._start_unix() + + def _start_unix(self) -> None: + master_fd, slave_fd = pty.openpty() + self._master_fd = master_fd + + # Set initial terminal size + self._set_pty_size(master_fd, self._rows, self._cols) + + self._process = subprocess.Popen( + [self._shell, "-i"], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=self._cwd, + env=self._env, + preexec_fn=os.setsid, + close_fds=True, + ) + os.close(slave_fd) + + self._alive = True + self._reader_thread = threading.Thread( + target=self._read_unix, daemon=True, name=f"TermReader-{self.id}" + ) + self._reader_thread.start() + + def _start_windows(self) -> None: + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = 0 # SW_HIDE + + self._process = subprocess.Popen( + [self._shell], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=self._cwd, + env=self._env, + startupinfo=startupinfo, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, + ) + self._alive = True + self._reader_thread = threading.Thread( + target=self._read_windows, daemon=True, name=f"TermReader-{self.id}" + ) + self._reader_thread.start() + + # -- reader threads -- + + def _read_unix(self) -> None: + try: + while self._alive and self._master_fd is not None: + try: + data = os.read(self._master_fd, 4096) + except OSError: + break + if not data: + break + text = data.decode("utf-8", errors="replace") + with self._output_lock: + self._output_buf.append(text) + finally: + self._alive = False + self._reap() + + def _read_windows(self) -> None: + assert self._process is not None and self._process.stdout is not None + try: + while self._alive: + chunk = self._process.stdout.read(1) + if not chunk: + break + # Try to read more if available + try: + avail = self._process.stdout.peek(4095) + if avail: + chunk += self._process.stdout.read(len(avail)) + except (AttributeError, OSError): + pass + text = chunk.decode("utf-8", errors="replace") + with self._output_lock: + self._output_buf.append(text) + except (OSError, ValueError): + pass + finally: + self._alive = False + self._reap() + + # -- PTY helpers -- + + @staticmethod + def _set_pty_size(fd: int, rows: int, cols: int) -> None: + if _IS_WINDOWS: + return + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + # -- public API -- + + @property + def alive(self) -> bool: + return self._alive + + @property + def cwd(self) -> str: + """Best-effort CWD tracking via /proc on Linux.""" + if self._process and not _IS_WINDOWS: + proc_cwd = f"/proc/{self._process.pid}/cwd" + try: + return os.readlink(proc_cwd) + except OSError: + pass + return self._cwd + + @property + def exit_status(self) -> Optional[int]: + return self._exit_status + + @property + def history(self) -> CommandHistory: + return self._history + + def send_input(self, text: str) -> None: + """Send raw text to the shell stdin.""" + if not self._alive: + raise RuntimeError("Session is not alive") + data = text.encode("utf-8") + if _IS_WINDOWS: + assert self._process is not None and self._process.stdin is not None + self._process.stdin.write(data) + self._process.stdin.flush() + else: + assert self._master_fd is not None + os.write(self._master_fd, data) + + def execute(self, command: str, timeout: float = 30.0) -> str: + """Execute *command* synchronously and return its output. + + The command is sent to the running shell and output is captured until + a sentinel marker appears or the timeout expires. + """ + if not self._alive: + raise RuntimeError("Session is not alive") + + self._history.add(command) + + sentinel = f"__EOSTUDIO_DONE_{id(command)}_{time.monotonic_ns()}__" + # Drain existing output + self.get_output() + + if _IS_WINDOWS: + shell_base = os.path.basename(self._shell).lower() + if "cmd" in shell_base: + full = f"{command} & echo {sentinel}\r\n" + else: + full = f"{command}; echo '{sentinel}'\r\n" + else: + full = f"{command}; echo '{sentinel}'\n" + + self.send_input(full) + + # Wait for sentinel in output + deadline = time.monotonic() + timeout + collected: List[str] = [] + while time.monotonic() < deadline: + time.sleep(0.05) + chunk = self.get_output() + if chunk: + collected.append(chunk) + joined = "".join(collected) + if sentinel in joined: + result = joined.split(sentinel)[0] + # Remove the typed command line from output + lines = result.split("\n") + cmd_stripped = command.strip() + out_lines: List[str] = [] + found_cmd = False + for line in lines: + stripped = AnsiParser.strip(line).strip() + if not found_cmd and ( + cmd_stripped in stripped + or stripped.endswith(cmd_stripped) + ): + found_cmd = True + continue + if found_cmd: + out_lines.append(line) + return "\n".join(out_lines).strip() + if not self._alive: + break + + return AnsiParser.strip("".join(collected)).strip() + + def execute_async( + self, + command: str, + callback: Optional[Callable[[str], None]] = None, + ) -> threading.Thread: + """Execute *command* asynchronously, invoking *callback* with each chunk. + + Returns the background thread so callers can join() if needed. + """ + if not self._alive: + raise RuntimeError("Session is not alive") + + self._history.add(command) + + def _run() -> None: + sentinel = f"__EOSTUDIO_ASYNC_{id(command)}_{time.monotonic_ns()}__" + self.get_output() # drain + + if _IS_WINDOWS: + shell_base = os.path.basename(self._shell).lower() + if "cmd" in shell_base: + full = f"{command} & echo {sentinel}\r\n" + else: + full = f"{command}; echo '{sentinel}'\r\n" + else: + full = f"{command}; echo '{sentinel}'\n" + + self.send_input(full) + + while self._alive: + time.sleep(0.05) + chunk = self.get_output() + if chunk: + if sentinel in chunk: + chunk = chunk.split(sentinel)[0] + if chunk and callback: + callback(chunk) + break + if callback: + callback(chunk) + + t = threading.Thread(target=_run, daemon=True, name=f"AsyncExec-{self.id}") + t.start() + return t def get_output(self) -> str: - return self._output + """Return and clear accumulated output.""" + with self._output_lock: + text = "".join(self._output_buf) + self._output_buf.clear() + return text + + def clear(self) -> None: + """Clear the output buffer.""" + with self._output_lock: + self._output_buf.clear() + + def resize(self, rows: int, cols: int) -> None: + """Resize the terminal to *rows* x *cols*.""" + self._rows = rows + self._cols = cols + if self._master_fd is not None and not _IS_WINDOWS: + self._set_pty_size(self._master_fd, rows, cols) + + def kill(self) -> None: + """Kill the shell process and clean up resources.""" + self._alive = False + if self._process: + try: + if _IS_WINDOWS: + self._process.terminate() + else: + os.killpg(os.getpgid(self._process.pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + pass + self._cleanup_fds() + self._reap() + + def _cleanup_fds(self) -> None: + if self._master_fd is not None: + try: + os.close(self._master_fd) + except OSError: + pass + self._master_fd = None + + def _reap(self) -> None: + if self._process: + try: + self._process.wait(timeout=2) + self._exit_status = self._process.returncode + except subprocess.TimeoutExpired: + try: + self._process.kill() + self._process.wait(timeout=2) + self._exit_status = self._process.returncode + except (OSError, subprocess.TimeoutExpired): + pass + + def __del__(self) -> None: + self.kill() + + +# --------------------------------------------------------------------------- +# Terminal manager +# --------------------------------------------------------------------------- + +class TerminalManager: + """Manages multiple :class:`TerminalSession` instances.""" + + def __init__(self) -> None: + self._sessions: Dict[int, TerminalSession] = {} + self._active_id: Optional[int] = None + self._lock = threading.Lock() + + def create_session( + self, + shell: Optional[str] = None, + cwd: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + rows: int = 24, + cols: int = 80, + ) -> TerminalSession: + """Create and register a new terminal session.""" + session = TerminalSession(shell=shell, cwd=cwd, env=env, rows=rows, cols=cols) + with self._lock: + self._sessions[session.id] = session + if self._active_id is None: + self._active_id = session.id + return session + + def get_session(self, session_id: int) -> Optional[TerminalSession]: + with self._lock: + return self._sessions.get(session_id) + + @property + def active_session(self) -> Optional[TerminalSession]: + with self._lock: + if self._active_id is not None: + return self._sessions.get(self._active_id) + return None + + @active_session.setter + def active_session(self, session_id: int) -> None: + with self._lock: + if session_id not in self._sessions: + raise KeyError(f"No session with id {session_id}") + self._active_id = session_id + + def list_sessions(self) -> List[Dict[str, Any]]: + with self._lock: + return [ + { + "id": s.id, + "alive": s.alive, + "cwd": s.cwd, + "exit_status": s.exit_status, + } + for s in self._sessions.values() + ] + + def close_session(self, session_id: int) -> None: + with self._lock: + session = self._sessions.pop(session_id, None) + if session: + session.kill() + if self._active_id == session_id: + self._active_id = next(iter(self._sessions), None) + + def shutdown(self) -> None: + """Kill all sessions and clean up.""" + with self._lock: + for session in self._sessions.values(): + session.kill() + self._sessions.clear() + self._active_id = None + + +# --------------------------------------------------------------------------- +# Backward-compatible TerminalEmulator facade +# --------------------------------------------------------------------------- + +class TerminalEmulator: + """High-level terminal emulator -- backward-compatible public API. + + Wraps a :class:`TerminalManager` and delegates to the active session. + Legacy callers can use ``execute()``, ``get_output()``, and ``clear()`` + exactly as before. + """ + + def __init__( + self, + shell: Optional[str] = None, + cwd: Optional[str] = None, + rows: int = 24, + cols: int = 80, + ) -> None: + self._manager = TerminalManager() + self._default_session = self._manager.create_session( + shell=shell, cwd=cwd, rows=rows, cols=cols, + ) + + # -- session proxies -- + + @property + def manager(self) -> TerminalManager: + return self._manager + + @property + def session(self) -> TerminalSession: + s = self._manager.active_session + if s is None: + raise RuntimeError("No active terminal session") + return s + + # -- backward-compatible API -- + + def execute(self, command: str, timeout: float = 30.0) -> str: + """Run *command* synchronously and return its output.""" + return self.session.execute(command, timeout=timeout) + + def get_output(self) -> str: + """Return accumulated output from the active session.""" + return self.session.get_output() + + def clear(self) -> None: + """Clear the active session output buffer.""" + self.session.clear() + + # -- extended API -- + + def execute_async( + self, + command: str, + callback: Optional[Callable[[str], None]] = None, + ) -> threading.Thread: + """Run *command* asynchronously with streaming *callback*.""" + return self.session.execute_async(command, callback) + + def send_input(self, text: str) -> None: + """Send raw input to the active session PTY.""" + self.session.send_input(text) + + def resize(self, rows: int, cols: int) -> None: + """Resize the active session terminal.""" + self.session.resize(rows, cols) + + def kill(self) -> None: + """Kill the active session process.""" + self.session.kill() + + @property + def cwd(self) -> str: + return self.session.cwd + + @property + def exit_status(self) -> Optional[int]: + return self.session.exit_status + + @property + def history(self) -> CommandHistory: + return self.session.history + + @property + def alive(self) -> bool: + return self.session.alive + + def shutdown(self) -> None: + """Shut down all sessions.""" + self._manager.shutdown() + + def __del__(self) -> None: + try: + self._manager.shutdown() + except Exception: + pass diff --git a/eostudio/core/prototyping/__pycache__/__init__.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..ff86ea4 Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/prototyping/__pycache__/gestures.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/gestures.cpython-38.pyc new file mode 100644 index 0000000..1294fe4 Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/gestures.cpython-38.pyc differ diff --git a/eostudio/core/prototyping/__pycache__/interactions.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/interactions.cpython-38.pyc new file mode 100644 index 0000000..7c4946c Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/interactions.cpython-38.pyc differ diff --git a/eostudio/core/prototyping/__pycache__/player.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/player.cpython-38.pyc new file mode 100644 index 0000000..c8af0f5 Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/player.cpython-38.pyc differ diff --git a/eostudio/core/prototyping/__pycache__/state_machine.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/state_machine.cpython-38.pyc new file mode 100644 index 0000000..23e4c6b Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/state_machine.cpython-38.pyc differ diff --git a/eostudio/core/prototyping/__pycache__/transitions.cpython-38.pyc b/eostudio/core/prototyping/__pycache__/transitions.cpython-38.pyc new file mode 100644 index 0000000..0f0ec69 Binary files /dev/null and b/eostudio/core/prototyping/__pycache__/transitions.cpython-38.pyc differ diff --git a/eostudio/core/scaffold/__init__.py b/eostudio/core/scaffold/__init__.py new file mode 100755 index 0000000..49d7037 --- /dev/null +++ b/eostudio/core/scaffold/__init__.py @@ -0,0 +1,6 @@ +"""Scaffolding subpackage — project templates and scaffolder.""" + +from eostudio.core.scaffold.scaffolder import Scaffolder, ScaffoldConfig +from eostudio.core.scaffold.templates import TemplateRegistry, ProjectTemplate + +__all__ = ["Scaffolder", "ScaffoldConfig", "TemplateRegistry", "ProjectTemplate"] diff --git a/eostudio/core/scaffold/__pycache__/__init__.cpython-38.pyc b/eostudio/core/scaffold/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..0a89729 Binary files /dev/null and b/eostudio/core/scaffold/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/scaffold/__pycache__/scaffolder.cpython-38.pyc b/eostudio/core/scaffold/__pycache__/scaffolder.cpython-38.pyc new file mode 100644 index 0000000..f5a4f8a Binary files /dev/null and b/eostudio/core/scaffold/__pycache__/scaffolder.cpython-38.pyc differ diff --git a/eostudio/core/scaffold/__pycache__/templates.cpython-38.pyc b/eostudio/core/scaffold/__pycache__/templates.cpython-38.pyc new file mode 100644 index 0000000..12a669e Binary files /dev/null and b/eostudio/core/scaffold/__pycache__/templates.cpython-38.pyc differ diff --git a/eostudio/core/scaffold/scaffolder.py b/eostudio/core/scaffold/scaffolder.py new file mode 100755 index 0000000..bf15646 --- /dev/null +++ b/eostudio/core/scaffold/scaffolder.py @@ -0,0 +1,177 @@ +""" +EoStudio Scaffolder — template engine for project scaffolding. + +Phase 3: Cross-Platform Universal Support. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from eostudio.core.scaffold.templates import ProjectTemplate, TemplateRegistry + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class ScaffoldConfig: + """Configuration for creating a new project from a template.""" + + name: str + template: str + output_dir: str + variables: Dict[str, str] = field(default_factory=dict) + features: List[str] = field(default_factory=list) + + +@dataclass +class TemplateFile: + """A single file produced by a template.""" + + path: str + content: str + executable: bool = False + + +# --------------------------------------------------------------------------- +# Scaffolder +# --------------------------------------------------------------------------- + +class Scaffolder: + """Create projects from registered templates.""" + + def __init__(self) -> None: + self._registry = TemplateRegistry() + + # -- public API --------------------------------------------------------- + + def create(self, config: ScaffoldConfig) -> str: + """Create a project from *config* and return the output directory.""" + + template = self._registry.get(config.template) + if template is None: + raise ValueError(f"Unknown template: {config.template!r}") + + project_dir = os.path.join(config.output_dir, config.name) + os.makedirs(project_dir, exist_ok=True) + + variables = { + "project_name": config.name, + "project_slug": _slugify(config.name), + **config.variables, + } + + for rel_path, content_template in template.files.items(): + rendered = self.render_template(content_template, variables) + dest = os.path.join(project_dir, rel_path) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(rendered) + + self.post_scaffold(project_dir, config) + return project_dir + + @staticmethod + def render_template(content: str, variables: Dict[str, str]) -> str: + """Replace ``{{var}}`` placeholders in *content*.""" + + def _replace(match: re.Match) -> str: + key = match.group(1).strip() + return variables.get(key, match.group(0)) + + return re.sub(r"\{\{(.+?)\}\}", _replace, content) + + @staticmethod + def post_scaffold(output_dir: str, config: ScaffoldConfig) -> None: + """Run post-creation hooks (git init, dependency install, etc.).""" + + # git init + if shutil.which("git"): + subprocess.run( + ["git", "init"], + cwd=output_dir, + capture_output=True, + check=False, + ) + + # Language-specific dependency installation + project_path = Path(output_dir) + + if (project_path / "package.json").exists() and shutil.which("npm"): + subprocess.run( + ["npm", "install"], + cwd=output_dir, + capture_output=True, + check=False, + ) + elif (project_path / "requirements.txt").exists() and shutil.which("pip"): + subprocess.run( + ["pip", "install", "-r", "requirements.txt"], + cwd=output_dir, + capture_output=True, + check=False, + ) + elif (project_path / "Cargo.toml").exists() and shutil.which("cargo"): + subprocess.run( + ["cargo", "build"], + cwd=output_dir, + capture_output=True, + check=False, + ) + elif (project_path / "go.mod").exists() and shutil.which("go"): + subprocess.run( + ["go", "mod", "tidy"], + cwd=output_dir, + capture_output=True, + check=False, + ) + + def list_templates(self) -> List[str]: + """Return names of all registered templates.""" + return [t.name for t in self._registry.list()] + + def get_template(self, name: str) -> Optional[ProjectTemplate]: + """Look up a template by *name*.""" + return self._registry.get(name) + + def create_custom_template(self, path: str, name: str) -> None: + """Save a directory tree rooted at *path* as a reusable template.""" + + files: Dict[str, str] = {} + root = Path(path) + for file in root.rglob("*"): + if file.is_file() and ".git" not in file.parts: + rel = str(file.relative_to(root)) + try: + files[rel] = file.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue # skip binary files + + template = ProjectTemplate( + name=name, + description=f"Custom template created from {path}", + category="custom", + language="mixed", + framework="custom", + files=files, + ) + self._registry.register(template) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _slugify(value: str) -> str: + """Convert *value* to a filesystem-safe slug.""" + slug = value.lower().strip() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s_]+", "-", slug) + return slug.strip("-") diff --git a/eostudio/core/scaffold/templates.py b/eostudio/core/scaffold/templates.py new file mode 100755 index 0000000..21dbead --- /dev/null +++ b/eostudio/core/scaffold/templates.py @@ -0,0 +1,1475 @@ +""" +EoStudio Project Templates — 40+ starter templates across all major languages. + +Phase 3: Cross-Platform Universal Support. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional + + +@dataclass +class ProjectTemplate: + """A complete project template with all starter files.""" + + name: str + description: str + category: str + language: str + framework: str + files: Dict[str, str] = field(default_factory=dict) + + +class TemplateRegistry: + """Registry of all built-in and custom project templates.""" + + def __init__(self) -> None: + self._templates: Dict[str, ProjectTemplate] = {} + self._register_builtins() + + def get(self, name: str) -> Optional[ProjectTemplate]: + return self._templates.get(name) + + def list(self) -> List[ProjectTemplate]: + return list(self._templates.values()) + + def search(self, query: str) -> List[ProjectTemplate]: + q = query.lower() + return [ + t for t in self._templates.values() + if q in t.name.lower() + or q in t.description.lower() + or q in t.category.lower() + or q in t.language.lower() + or q in t.framework.lower() + ] + + def register(self, template: ProjectTemplate) -> None: + self._templates[template.name] = template + + # ------------------------------------------------------------------ + # Built-in templates + # ------------------------------------------------------------------ + + def _register_builtins(self) -> None: + for t in _ALL_TEMPLATES: + self._templates[t.name] = t + + +# ====================================================================== +# Helper to shorten repetitive gitignore / readme content +# ====================================================================== + +def _gitignore(extras: str = "") -> str: + base = ( + "# OS\n.DS_Store\nThumbs.db\n\n" + "# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n" + ) + return base + extras + + +def _readme(name: str, desc: str, run: str) -> str: + return ( + f"# {{{{project_name}}}}\n\n" + f"{desc}\n\n" + f"## Getting Started\n\n" + f"```bash\n{run}\n```\n" + ) + + +# ====================================================================== +# PYTHON TEMPLATES +# ====================================================================== + +_fastapi = ProjectTemplate( + name="fastapi", + description="FastAPI REST API with uvicorn", + category="python", + language="python", + framework="fastapi", + files={ + "app/__init__.py": "", + "app/main.py": ( + 'from __future__ import annotations\n\n' + 'from fastapi import FastAPI\n\n' + 'app = FastAPI(title="{{project_name}}")\n\n\n' + '@app.get("/")\n' + 'async def root() -> dict:\n' + ' return {"message": "Hello from {{project_name}}"}\n\n\n' + '@app.get("/health")\n' + 'async def health() -> dict:\n' + ' return {"status": "ok"}\n' + ), + "app/config.py": ( + 'from __future__ import annotations\n\n' + 'import os\n\n\n' + 'DEBUG = os.getenv("DEBUG", "false").lower() == "true"\n' + 'HOST = os.getenv("HOST", "0.0.0.0")\n' + 'PORT = int(os.getenv("PORT", "8000"))\n' + ), + "tests/__init__.py": "", + "tests/test_main.py": ( + 'from __future__ import annotations\n\n' + 'from fastapi.testclient import TestClient\n\n' + 'from app.main import app\n\n' + 'client = TestClient(app)\n\n\n' + 'def test_root():\n' + ' resp = client.get("/")\n' + ' assert resp.status_code == 200\n' + ' assert resp.json()["message"] == "Hello from {{project_name}}"\n\n\n' + 'def test_health():\n' + ' resp = client.get("/health")\n' + ' assert resp.status_code == 200\n' + ), + "pyproject.toml": ( + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'description = "{{project_name}}"\nrequires-python = ">=3.11"\n' + 'dependencies = ["fastapi>=0.110", "uvicorn[standard]>=0.29"]\n\n' + '[project.optional-dependencies]\ndev = ["pytest", "httpx"]\n' + ), + "requirements.txt": "fastapi>=0.110\nuvicorn[standard]>=0.29\n", + "README.md": _readme("fastapi", "A FastAPI REST API.", "uvicorn app.main:app --reload"), + ".gitignore": _gitignore("\n# Python\n__pycache__/\n*.pyc\n.venv/\ndist/\n*.egg-info/\n"), + }, +) + +_flask = ProjectTemplate( + name="flask", + description="Flask web application", + category="python", + language="python", + framework="flask", + files={ + "app/__init__.py": ( + 'from __future__ import annotations\n\n' + 'from flask import Flask\n\n\n' + 'def create_app() -> Flask:\n' + ' app = Flask(__name__)\n' + ' from app.routes import bp\n' + ' app.register_blueprint(bp)\n' + ' return app\n' + ), + "app/routes.py": ( + 'from __future__ import annotations\n\n' + 'from flask import Blueprint, jsonify\n\n' + 'bp = Blueprint("main", __name__)\n\n\n' + '@bp.route("/")\n' + 'def index():\n' + ' return jsonify(message="Hello from {{project_name}}")\n' + ), + "tests/__init__.py": "", + "tests/test_app.py": ( + 'from __future__ import annotations\n\n' + 'from app import create_app\n\n\n' + 'def test_index():\n' + ' app = create_app()\n' + ' client = app.test_client()\n' + ' resp = client.get("/")\n' + ' assert resp.status_code == 200\n' + ), + "pyproject.toml": ( + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'requires-python = ">=3.11"\n' + 'dependencies = ["flask>=3.0"]\n\n' + '[project.optional-dependencies]\ndev = ["pytest"]\n' + ), + "requirements.txt": "flask>=3.0\n", + "README.md": _readme("flask", "A Flask web application.", "flask run --debug"), + ".gitignore": _gitignore("\n# Python\n__pycache__/\n*.pyc\n.venv/\n"), + }, +) + +_django = ProjectTemplate( + name="django", + description="Django web application", + category="python", + language="python", + framework="django", + files={ + "manage.py": ( + '#!/usr/bin/env python\n' + 'import os\nimport sys\n\n\n' + 'def main():\n' + ' os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")\n' + ' from django.core.management import execute_from_command_line\n' + ' execute_from_command_line(sys.argv)\n\n\n' + 'if __name__ == "__main__":\n' + ' main()\n' + ), + "config/__init__.py": "", + "config/settings.py": ( + 'from pathlib import Path\n\n' + 'BASE_DIR = Path(__file__).resolve().parent.parent\n' + 'SECRET_KEY = "change-me"\n' + 'DEBUG = True\n' + 'ALLOWED_HOSTS = ["*"]\n' + 'INSTALLED_APPS = [\n' + ' "django.contrib.admin",\n' + ' "django.contrib.auth",\n' + ' "django.contrib.contenttypes",\n' + ' "django.contrib.sessions",\n' + ' "django.contrib.messages",\n' + ' "django.contrib.staticfiles",\n' + ']\n' + 'ROOT_URLCONF = "config.urls"\n' + 'DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}\n' + ), + "config/urls.py": ( + 'from django.contrib import admin\n' + 'from django.urls import path\n\n' + 'urlpatterns = [path("admin/", admin.site.urls)]\n' + ), + "tests/__init__.py": "", + "tests/test_basic.py": ( + 'from django.test import TestCase\n\n\n' + 'class SmokeTest(TestCase):\n' + ' def test_homepage(self):\n' + ' resp = self.client.get("/")\n' + ' self.assertIn(resp.status_code, (200, 301, 404))\n' + ), + "pyproject.toml": ( + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'dependencies = ["django>=5.0"]\n' + ), + "requirements.txt": "django>=5.0\n", + "README.md": _readme("django", "A Django web application.", "python manage.py runserver"), + ".gitignore": _gitignore("\n# Python\n__pycache__/\n*.pyc\n.venv/\ndb.sqlite3\n"), + }, +) + +_cli_click = ProjectTemplate( + name="cli-click", + description="Python CLI with Click", + category="python", + language="python", + framework="click", + files={ + "{{project_slug}}/__init__.py": "", + "{{project_slug}}/cli.py": ( + 'from __future__ import annotations\n\n' + 'import click\n\n\n' + '@click.group()\n' + '@click.version_option()\n' + 'def cli():\n' + ' """{{project_name}} command-line interface."""\n\n\n' + '@cli.command()\n' + '@click.argument("name", default="World")\n' + 'def hello(name: str):\n' + ' """Say hello."""\n' + ' click.echo(f"Hello, {name}!")\n\n\n' + 'if __name__ == "__main__":\n' + ' cli()\n' + ), + "tests/__init__.py": "", + "tests/test_cli.py": ( + 'from click.testing import CliRunner\n' + 'from {{project_slug}}.cli import cli\n\n\n' + 'def test_hello():\n' + ' runner = CliRunner()\n' + ' result = runner.invoke(cli, ["hello"])\n' + ' assert result.exit_code == 0\n' + ' assert "Hello, World!" in result.output\n' + ), + "pyproject.toml": ( + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'dependencies = ["click>=8.1"]\n\n' + '[project.scripts]\n{{project_slug}} = "{{project_slug}}.cli:cli"\n' + ), + "README.md": _readme("cli-click", "A Python CLI built with Click.", "{{project_slug}} hello"), + ".gitignore": _gitignore("\n__pycache__/\n*.pyc\n.venv/\ndist/\n"), + }, +) + +_cli_typer = ProjectTemplate( + name="cli-typer", + description="Python CLI with Typer", + category="python", + language="python", + framework="typer", + files={ + "{{project_slug}}/__init__.py": "", + "{{project_slug}}/main.py": ( + 'from __future__ import annotations\n\n' + 'import typer\n\n' + 'app = typer.Typer(help="{{project_name}} CLI")\n\n\n' + '@app.command()\n' + 'def hello(name: str = "World"):\n' + ' """Say hello."""\n' + ' typer.echo(f"Hello, {name}!")\n\n\n' + 'if __name__ == "__main__":\n' + ' app()\n' + ), + "tests/__init__.py": "", + "tests/test_main.py": ( + 'from typer.testing import CliRunner\n' + 'from {{project_slug}}.main import app\n\n\n' + 'runner = CliRunner()\n\n\n' + 'def test_hello():\n' + ' result = runner.invoke(app, ["hello"])\n' + ' assert result.exit_code == 0\n' + ), + "pyproject.toml": ( + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'dependencies = ["typer>=0.12"]\n\n' + '[project.scripts]\n{{project_slug}} = "{{project_slug}}.main:app"\n' + ), + "README.md": _readme("cli-typer", "A Python CLI built with Typer.", "{{project_slug}} hello"), + ".gitignore": _gitignore("\n__pycache__/\n*.pyc\n.venv/\ndist/\n"), + }, +) + +_library_setuptools = ProjectTemplate( + name="library-setuptools", + description="Python library with setuptools", + category="python", + language="python", + framework="setuptools", + files={ + "src/{{project_slug}}/__init__.py": '__version__ = "0.1.0"\n', + "src/{{project_slug}}/core.py": ( + 'from __future__ import annotations\n\n\n' + 'def greet(name: str) -> str:\n' + ' return f"Hello, {name}!"\n' + ), + "tests/__init__.py": "", + "tests/test_core.py": ( + 'from {{project_slug}}.core import greet\n\n\n' + 'def test_greet():\n' + ' assert greet("World") == "Hello, World!"\n' + ), + "pyproject.toml": ( + '[build-system]\nrequires = ["setuptools>=69", "wheel"]\n' + 'build-backend = "setuptools.build_meta"\n\n' + '[project]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'requires-python = ">=3.11"\n\n' + '[tool.setuptools.packages.find]\nwhere = ["src"]\n' + ), + "README.md": _readme("library", "A Python library.", "pip install -e ."), + ".gitignore": _gitignore("\n__pycache__/\n*.pyc\n.venv/\ndist/\n*.egg-info/\n"), + }, +) + +_library_poetry = ProjectTemplate( + name="library-poetry", + description="Python library with Poetry", + category="python", + language="python", + framework="poetry", + files={ + "{{project_slug}}/__init__.py": '__version__ = "0.1.0"\n', + "{{project_slug}}/core.py": ( + 'from __future__ import annotations\n\n\n' + 'def greet(name: str) -> str:\n' + ' return f"Hello, {name}!"\n' + ), + "tests/__init__.py": "", + "tests/test_core.py": ( + 'from {{project_slug}}.core import greet\n\n\n' + 'def test_greet():\n' + ' assert greet("World") == "Hello, World!"\n' + ), + "pyproject.toml": ( + '[tool.poetry]\nname = "{{project_slug}}"\nversion = "0.1.0"\n' + 'description = "{{project_name}}"\nauthors = ["Your Name "]\n\n' + '[tool.poetry.dependencies]\npython = "^3.11"\n\n' + '[tool.poetry.group.dev.dependencies]\npytest = "^8.0"\n\n' + '[build-system]\nrequires = ["poetry-core"]\n' + 'build-backend = "poetry.core.masonry.api"\n' + ), + "README.md": _readme("library-poetry", "A Python library managed with Poetry.", "poetry install"), + ".gitignore": _gitignore("\n__pycache__/\n*.pyc\n.venv/\ndist/\n"), + }, +) + +# ====================================================================== +# JAVASCRIPT / TYPESCRIPT TEMPLATES +# ====================================================================== + +_js_gitignore = "\n# JS/TS\nnode_modules/\ndist/\nbuild/\n.env\ncoverage/\n" + +_react = ProjectTemplate( + name="react", + description="React 18 with TypeScript and Vite", + category="javascript", + language="typescript", + framework="react", + files={ + "src/App.tsx": ( + 'import React from "react";\n\n' + 'export default function App() {\n' + ' return (\n' + '
\n' + '

{{project_name}}

\n' + '

Welcome to your React app.

\n' + '
\n' + ' );\n' + '}\n' + ), + "src/main.tsx": ( + 'import React from "react";\n' + 'import ReactDOM from "react-dom/client";\n' + 'import App from "./App";\n\n' + 'ReactDOM.createRoot(document.getElementById("root")!).render(\n' + ' \n' + ' \n' + ' \n' + ');\n' + ), + "index.html": ( + '\n\n\n' + ' \n' + ' {{project_name}}\n' + '\n\n' + '
\n' + ' \n' + '\n\n' + ), + "src/__tests__/App.test.tsx": ( + 'import { render, screen } from "@testing-library/react";\n' + 'import App from "../App";\n\n' + 'test("renders heading", () => {\n' + ' render();\n' + ' expect(screen.getByText("{{project_name}}")).toBeInTheDocument();\n' + '});\n' + ), + "package.json": ( + '{\n "name": "{{project_slug}}",\n "version": "0.1.0",\n "private": true,\n' + ' "type": "module",\n' + ' "scripts": {\n "dev": "vite",\n "build": "tsc && vite build",\n' + ' "test": "vitest"\n },\n' + ' "dependencies": {\n "react": "^18.3",\n "react-dom": "^18.3"\n },\n' + ' "devDependencies": {\n "@types/react": "^18.3",\n' + ' "typescript": "^5.4",\n "vite": "^5.4",\n' + ' "@vitejs/plugin-react": "^4.3",\n "vitest": "^1.6"\n }\n}\n' + ), + "tsconfig.json": ( + '{\n "compilerOptions": {\n "target": "ES2020",\n' + ' "module": "ESNext",\n "jsx": "react-jsx",\n' + ' "strict": true,\n "moduleResolution": "bundler"\n },\n' + ' "include": ["src"]\n}\n' + ), + "vite.config.ts": ( + 'import { defineConfig } from "vite";\n' + 'import react from "@vitejs/plugin-react";\n\n' + 'export default defineConfig({ plugins: [react()] });\n' + ), + "README.md": _readme("react", "React + TypeScript + Vite.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore), + }, +) + +_nextjs = ProjectTemplate( + name="nextjs", + description="Next.js 14 App Router with TypeScript", + category="javascript", + language="typescript", + framework="nextjs", + files={ + "app/page.tsx": ( + 'export default function Home() {\n' + ' return

{{project_name}}

;\n' + '}\n' + ), + "app/layout.tsx": ( + 'export const metadata = { title: "{{project_name}}" };\n\n' + 'export default function RootLayout({ children }: { children: React.ReactNode }) {\n' + ' return (\n' + ' {children}\n' + ' );\n' + '}\n' + ), + "__tests__/page.test.tsx": ( + 'import { render, screen } from "@testing-library/react";\n' + 'import Home from "../app/page";\n\n' + 'test("renders heading", () => {\n' + ' render();\n' + ' expect(screen.getByText("{{project_name}}")).toBeInTheDocument();\n' + '});\n' + ), + "package.json": ( + '{\n "name": "{{project_slug}}",\n "version": "0.1.0",\n "private": true,\n' + ' "scripts": {"dev": "next dev", "build": "next build", "start": "next start"},\n' + ' "dependencies": {"next": "^14.2", "react": "^18.3", "react-dom": "^18.3"},\n' + ' "devDependencies": {"typescript": "^5.4", "@types/react": "^18.3"}\n}\n' + ), + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2017", "jsx": "preserve", "strict": true,\n "moduleResolution": "bundler", "plugins": [{"name": "next"}]},\n "include": ["**/*.ts", "**/*.tsx"]\n}\n', + "README.md": _readme("nextjs", "Next.js 14 App Router.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore + ".next/\n"), + }, +) + +_vue = ProjectTemplate( + name="vue", + description="Vue 3 with TypeScript and Vite", + category="javascript", + language="typescript", + framework="vue", + files={ + "src/App.vue": ( + '\n\n' + '\n' + ), + "src/main.ts": 'import { createApp } from "vue";\nimport App from "./App.vue";\n\ncreateApp(App).mount("#app");\n', + "index.html": '\n\n{{project_name}}\n\n
\n \n\n\n', + "src/__tests__/App.spec.ts": ( + 'import { mount } from "@vue/test-utils";\n' + 'import App from "../App.vue";\n\n' + 'test("renders title", () => {\n' + ' const wrapper = mount(App);\n' + ' expect(wrapper.text()).toContain("{{project_name}}");\n' + '});\n' + ), + "package.json": '{\n "name": "{{project_slug}}",\n "version": "0.1.0",\n "private": true,\n "type": "module",\n "scripts": {"dev": "vite", "build": "vite build"},\n "dependencies": {"vue": "^3.4"},\n "devDependencies": {"typescript": "^5.4", "vite": "^5.4", "@vitejs/plugin-vue": "^5.0"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2020", "module": "ESNext", "strict": true, "moduleResolution": "bundler"},\n "include": ["src"]\n}\n', + "README.md": _readme("vue", "Vue 3 + TypeScript + Vite.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore), + }, +) + +_nuxt = ProjectTemplate( + name="nuxt", + description="Nuxt 3 fullstack Vue framework", + category="javascript", + language="typescript", + framework="nuxt", + files={ + "app.vue": '\n', + "pages/index.vue": '\n', + "tests/index.spec.ts": 'import { describe, it, expect } from "vitest";\n\ndescribe("app", () => {\n it("exists", () => {\n expect(true).toBe(true);\n });\n});\n', + "nuxt.config.ts": 'export default defineNuxtConfig({ devtools: { enabled: true } });\n', + "package.json": '{\n "name": "{{project_slug}}",\n "private": true,\n "scripts": {"dev": "nuxt dev", "build": "nuxt build"},\n "devDependencies": {"nuxt": "^3.11"}\n}\n', + "tsconfig.json": '{"extends": "./.nuxt/tsconfig.json"}\n', + "README.md": _readme("nuxt", "Nuxt 3 fullstack app.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore + ".nuxt/\n.output/\n"), + }, +) + +_svelte = ProjectTemplate( + name="svelte", + description="SvelteKit with TypeScript", + category="javascript", + language="typescript", + framework="svelte", + files={ + "src/routes/+page.svelte": '

{{project_name}}

\n

Welcome to SvelteKit.

\n', + "src/app.html": '\n\n{{project_name}}\n%sveltekit.body%\n\n', + "tests/page.test.ts": 'import { describe, it, expect } from "vitest";\n\ndescribe("page", () => {\n it("placeholder", () => expect(true).toBe(true));\n});\n', + "svelte.config.js": 'import adapter from "@sveltejs/adapter-auto";\nexport default { kit: { adapter: adapter() } };\n', + "package.json": '{\n "name": "{{project_slug}}",\n "private": true,\n "scripts": {"dev": "vite dev", "build": "vite build"},\n "devDependencies": {"@sveltejs/kit": "^2.5", "svelte": "^4.2", "vite": "^5.4"}\n}\n', + "README.md": _readme("svelte", "SvelteKit app.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore + ".svelte-kit/\n"), + }, +) + +_angular = ProjectTemplate( + name="angular", + description="Angular 17+ standalone components", + category="javascript", + language="typescript", + framework="angular", + files={ + "src/app/app.component.ts": ( + 'import { Component } from "@angular/core";\n\n' + '@Component({\n selector: "app-root",\n standalone: true,\n' + ' template: `

{{project_name}}

`,\n})\n' + 'export class AppComponent {}\n' + ), + "src/main.ts": 'import { bootstrapApplication } from "@angular/platform-browser";\nimport { AppComponent } from "./app/app.component";\n\nbootstrapApplication(AppComponent);\n', + "src/app/app.component.spec.ts": ( + 'import { TestBed } from "@angular/core/testing";\n' + 'import { AppComponent } from "./app.component";\n\n' + 'describe("AppComponent", () => {\n' + ' it("should create", () => {\n' + ' const fixture = TestBed.createComponent(AppComponent);\n' + ' expect(fixture.componentInstance).toBeTruthy();\n' + ' });\n' + '});\n' + ), + "package.json": '{\n "name": "{{project_slug}}",\n "private": true,\n "scripts": {"start": "ng serve", "build": "ng build", "test": "ng test"},\n "dependencies": {"@angular/core": "^17.3", "@angular/platform-browser": "^17.3"},\n "devDependencies": {"typescript": "^5.4"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2022", "module": "ES2022", "strict": true, "experimentalDecorators": true}\n}\n', + "README.md": _readme("angular", "Angular 17+ app.", "ng serve"), + ".gitignore": _gitignore(_js_gitignore + ".angular/\n"), + }, +) + +_express = ProjectTemplate( + name="express", + description="Express.js REST API with TypeScript", + category="javascript", + language="typescript", + framework="express", + files={ + "src/index.ts": ( + 'import express from "express";\n\n' + 'const app = express();\n' + 'const PORT = process.env.PORT || 3000;\n\n' + 'app.use(express.json());\n\n' + 'app.get("/", (_req, res) => {\n' + ' res.json({ message: "Hello from {{project_name}}" });\n' + '});\n\n' + 'app.get("/health", (_req, res) => {\n' + ' res.json({ status: "ok" });\n' + '});\n\n' + 'app.listen(PORT, () => console.log(`Server running on port ${PORT}`));\n' + ), + "src/__tests__/index.test.ts": ( + 'import request from "supertest";\n' + 'import express from "express";\n\n' + 'const app = express();\n' + 'app.get("/", (_req, res) => res.json({ message: "ok" }));\n\n' + 'test("GET /", async () => {\n' + ' const res = await request(app).get("/");\n' + ' expect(res.status).toBe(200);\n' + '});\n' + ), + "package.json": '{\n "name": "{{project_slug}}",\n "version": "0.1.0",\n "scripts": {"dev": "ts-node-dev src/index.ts", "build": "tsc", "test": "jest"},\n "dependencies": {"express": "^4.19"},\n "devDependencies": {"@types/express": "^4.17", "typescript": "^5.4", "ts-node-dev": "^2.0", "jest": "^29.7", "supertest": "^7.0"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2020", "module": "commonjs", "outDir": "dist", "strict": true, "esModuleInterop": true},\n "include": ["src"]\n}\n', + "README.md": _readme("express", "Express.js REST API with TypeScript.", "npm run dev"), + ".gitignore": _gitignore(_js_gitignore), + }, +) + +_nestjs = ProjectTemplate( + name="nestjs", + description="NestJS API with TypeScript", + category="javascript", + language="typescript", + framework="nestjs", + files={ + "src/main.ts": 'import { NestFactory } from "@nestjs/core";\nimport { AppModule } from "./app.module";\n\nasync function bootstrap() {\n const app = await NestFactory.create(AppModule);\n await app.listen(3000);\n}\nbootstrap();\n', + "src/app.module.ts": 'import { Module } from "@nestjs/common";\nimport { AppController } from "./app.controller";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n', + "src/app.controller.ts": 'import { Controller, Get } from "@nestjs/common";\n\n@Controller()\nexport class AppController {\n @Get()\n getHello(): string {\n return "Hello from {{project_name}}";\n }\n}\n', + "src/app.controller.spec.ts": 'import { Test } from "@nestjs/testing";\nimport { AppController } from "./app.controller";\n\ndescribe("AppController", () => {\n let ctrl: AppController;\n beforeEach(async () => {\n const module = await Test.createTestingModule({ controllers: [AppController] }).compile();\n ctrl = module.get(AppController);\n });\n it("returns hello", () => expect(ctrl.getHello()).toContain("Hello"));\n});\n', + "package.json": '{\n "name": "{{project_slug}}",\n "scripts": {"start:dev": "nest start --watch", "build": "nest build", "test": "jest"},\n "dependencies": {"@nestjs/common": "^10.3", "@nestjs/core": "^10.3", "@nestjs/platform-express": "^10.3"},\n "devDependencies": {"@nestjs/cli": "^10.3", "@nestjs/testing": "^10.3", "typescript": "^5.4", "jest": "^29.7"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2021", "module": "commonjs", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true}\n}\n', + "README.md": _readme("nestjs", "NestJS API.", "npm run start:dev"), + ".gitignore": _gitignore(_js_gitignore), + }, +) + +_electron = ProjectTemplate( + name="electron", + description="Electron desktop app with TypeScript", + category="javascript", + language="typescript", + framework="electron", + files={ + "src/main.ts": ( + 'import { app, BrowserWindow } from "electron";\nimport path from "path";\n\n' + 'function createWindow() {\n' + ' const win = new BrowserWindow({ width: 800, height: 600 });\n' + ' win.loadFile(path.join(__dirname, "../index.html"));\n' + '}\n\n' + 'app.whenReady().then(createWindow);\n' + 'app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });\n' + ), + "index.html": '\n\n{{project_name}}\n

{{project_name}}

\n\n', + "tests/main.test.ts": 'test("placeholder", () => expect(true).toBe(true));\n', + "package.json": '{\n "name": "{{project_slug}}",\n "main": "dist/main.js",\n "scripts": {"start": "electron .", "build": "tsc"},\n "devDependencies": {"electron": "^30.0", "typescript": "^5.4"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ES2020", "module": "commonjs", "outDir": "dist", "strict": true}\n}\n', + "README.md": _readme("electron", "Electron desktop app.", "npm start"), + ".gitignore": _gitignore(_js_gitignore), + }, +) + +_react_native = ProjectTemplate( + name="react-native", + description="React Native mobile app with TypeScript", + category="javascript", + language="typescript", + framework="react-native", + files={ + "App.tsx": ( + 'import React from "react";\n' + 'import { View, Text, StyleSheet } from "react-native";\n\n' + 'export default function App() {\n' + ' return (\n' + ' \n' + ' {{project_name}}\n' + ' \n' + ' );\n' + '}\n\n' + 'const styles = StyleSheet.create({\n' + ' container: { flex: 1, justifyContent: "center", alignItems: "center" },\n' + ' title: { fontSize: 24, fontWeight: "bold" },\n' + '});\n' + ), + "__tests__/App.test.tsx": 'import React from "react";\nimport { render } from "@testing-library/react-native";\nimport App from "../App";\n\ntest("renders title", () => {\n const { getByText } = render();\n expect(getByText("{{project_name}}")).toBeTruthy();\n});\n', + "package.json": '{\n "name": "{{project_slug}}",\n "version": "0.1.0",\n "scripts": {"start": "react-native start", "test": "jest"},\n "dependencies": {"react": "^18.3", "react-native": "^0.74"},\n "devDependencies": {"@types/react": "^18.3", "typescript": "^5.4", "jest": "^29.7"}\n}\n', + "tsconfig.json": '{\n "compilerOptions": {"target": "ESNext", "module": "commonjs", "jsx": "react-native", "strict": true}\n}\n', + "README.md": _readme("react-native", "React Native mobile app.", "npx react-native start"), + ".gitignore": _gitignore(_js_gitignore + "ios/\nandroid/\n"), + }, +) + +# ====================================================================== +# RUST TEMPLATES +# ====================================================================== + +_rust_gitignore = "\n# Rust\ntarget/\nCargo.lock\n" + +_rust_binary = ProjectTemplate( + name="rust-binary", + description="Rust binary application", + category="rust", + language="rust", + framework="cargo", + files={ + "src/main.rs": 'fn main() {\n println!("Hello from {{project_name}}!");\n}\n', + "tests/integration_test.rs": '#[test]\nfn it_works() {\n assert_eq!(2 + 2, 4);\n}\n', + "Cargo.toml": '[package]\nname = "{{project_slug}}"\nversion = "0.1.0"\nedition = "2021"\n', + "README.md": _readme("rust-binary", "A Rust binary application.", "cargo run"), + ".gitignore": _gitignore(_rust_gitignore), + }, +) + +_rust_library = ProjectTemplate( + name="rust-library", + description="Rust library crate", + category="rust", + language="rust", + framework="cargo", + files={ + "src/lib.rs": '/// Greet someone by name.\npub fn greet(name: &str) -> String {\n format!("Hello, {name}!")\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_greet() {\n assert_eq!(greet("World"), "Hello, World!");\n }\n}\n', + "Cargo.toml": '[package]\nname = "{{project_slug}}"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\nname = "{{project_slug}}"\npath = "src/lib.rs"\n', + "README.md": _readme("rust-library", "A Rust library crate.", "cargo test"), + ".gitignore": _gitignore(_rust_gitignore), + }, +) + +_actix_web = ProjectTemplate( + name="actix-web", + description="Actix-web REST API", + category="rust", + language="rust", + framework="actix-web", + files={ + "src/main.rs": ( + 'use actix_web::{get, web, App, HttpServer, HttpResponse};\n\n' + '#[get("/")]\nasync fn index() -> HttpResponse {\n' + ' HttpResponse::Ok().json(serde_json::json!({"message": "Hello from {{project_name}}"}))\n' + '}\n\n' + '#[actix_web::main]\nasync fn main() -> std::io::Result<()> {\n' + ' HttpServer::new(|| App::new().service(index))\n' + ' .bind("127.0.0.1:8080")?\n' + ' .run().await\n' + '}\n' + ), + "tests/api_test.rs": '#[test]\nfn placeholder() {\n assert!(true);\n}\n', + "Cargo.toml": '[package]\nname = "{{project_slug}}"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nactix-web = "4"\nserde_json = "1"\n', + "README.md": _readme("actix-web", "Actix-web REST API.", "cargo run"), + ".gitignore": _gitignore(_rust_gitignore), + }, +) + +_axum = ProjectTemplate( + name="axum", + description="Axum web framework API", + category="rust", + language="rust", + framework="axum", + files={ + "src/main.rs": ( + 'use axum::{routing::get, Json, Router};\nuse serde_json::{json, Value};\n\n' + 'async fn root() -> Json {\n' + ' Json(json!({"message": "Hello from {{project_name}}"}))\n' + '}\n\n' + '#[tokio::main]\nasync fn main() {\n' + ' let app = Router::new().route("/", get(root));\n' + ' let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();\n' + ' axum::serve(listener, app).await.unwrap();\n' + '}\n' + ), + "tests/api_test.rs": '#[test]\nfn placeholder() {\n assert!(true);\n}\n', + "Cargo.toml": '[package]\nname = "{{project_slug}}"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\naxum = "0.7"\ntokio = { version = "1", features = ["full"] }\nserde_json = "1"\n', + "README.md": _readme("axum", "Axum web API.", "cargo run"), + ".gitignore": _gitignore(_rust_gitignore), + }, +) + +_tauri = ProjectTemplate( + name="tauri", + description="Tauri desktop app (Rust + web frontend)", + category="rust", + language="rust", + framework="tauri", + files={ + "src-tauri/src/main.rs": ( + '#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]\n\n' + 'fn main() {\n' + ' tauri::Builder::default()\n' + ' .run(tauri::generate_context!())\n' + ' .expect("error while running tauri application");\n' + '}\n' + ), + "src-tauri/Cargo.toml": '[package]\nname = "{{project_slug}}"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\ntauri = { version = "1", features = [] }\n\n[build-dependencies]\ntauri-build = { version = "1", features = [] }\n', + "src/index.html": '\n\n{{project_name}}\n

{{project_name}}

\n\n', + "tests/placeholder.rs": '#[test]\nfn it_works() { assert!(true); }\n', + "README.md": _readme("tauri", "Tauri desktop application.", "cargo tauri dev"), + ".gitignore": _gitignore(_rust_gitignore + _js_gitignore), + }, +) + +# ====================================================================== +# GO TEMPLATES +# ====================================================================== + +_go_gitignore = "\n# Go\nbin/\nvendor/\n" + +_go_cli = ProjectTemplate( + name="go-cli", + description="Go CLI application", + category="go", + language="go", + framework="cobra", + files={ + "main.go": 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello from {{project_name}}")\n}\n', + "main_test.go": 'package main\n\nimport "testing"\n\nfunc TestPlaceholder(t *testing.T) {\n\tif false {\n\t\tt.Fail()\n\t}\n}\n', + "go.mod": 'module {{project_slug}}\n\ngo 1.22\n', + "README.md": _readme("go-cli", "A Go CLI application.", "go run ."), + ".gitignore": _gitignore(_go_gitignore), + }, +) + +_go_api_gin = ProjectTemplate( + name="go-api-gin", + description="Go REST API with Gin", + category="go", + language="go", + framework="gin", + files={ + "main.go": ( + 'package main\n\nimport "github.com/gin-gonic/gin"\n\n' + 'func main() {\n' + '\tr := gin.Default()\n' + '\tr.GET("/", func(c *gin.Context) {\n' + '\t\tc.JSON(200, gin.H{"message": "Hello from {{project_name}}"})\n' + '\t})\n' + '\tr.Run()\n' + '}\n' + ), + "main_test.go": 'package main\n\nimport "testing"\n\nfunc TestPlaceholder(t *testing.T) {\n\tif false {\n\t\tt.Fail()\n\t}\n}\n', + "go.mod": 'module {{project_slug}}\n\ngo 1.22\n\nrequire github.com/gin-gonic/gin v1.9.1\n', + "README.md": _readme("go-api-gin", "Go REST API with Gin.", "go run ."), + ".gitignore": _gitignore(_go_gitignore), + }, +) + +_go_api_echo = ProjectTemplate( + name="go-api-echo", + description="Go REST API with Echo", + category="go", + language="go", + framework="echo", + files={ + "main.go": ( + 'package main\n\nimport (\n\t"net/http"\n\t"github.com/labstack/echo/v4"\n)\n\n' + 'func main() {\n' + '\te := echo.New()\n' + '\te.GET("/", func(c echo.Context) error {\n' + '\t\treturn c.JSON(http.StatusOK, map[string]string{"message": "Hello from {{project_name}}"})\n' + '\t})\n' + '\te.Logger.Fatal(e.Start(":1323"))\n' + '}\n' + ), + "main_test.go": 'package main\n\nimport "testing"\n\nfunc TestPlaceholder(t *testing.T) {\n\tif false {\n\t\tt.Fail()\n\t}\n}\n', + "go.mod": 'module {{project_slug}}\n\ngo 1.22\n\nrequire github.com/labstack/echo/v4 v4.12.0\n', + "README.md": _readme("go-api-echo", "Go REST API with Echo.", "go run ."), + ".gitignore": _gitignore(_go_gitignore), + }, +) + +_go_grpc = ProjectTemplate( + name="go-grpc", + description="Go gRPC service", + category="go", + language="go", + framework="grpc", + files={ + "main.go": ( + 'package main\n\nimport (\n\t"fmt"\n\t"log"\n\t"net"\n\t"google.golang.org/grpc"\n)\n\n' + 'func main() {\n' + '\tlis, err := net.Listen("tcp", ":50051")\n' + '\tif err != nil {\n\t\tlog.Fatalf("failed to listen: %v", err)\n\t}\n' + '\ts := grpc.NewServer()\n' + '\tfmt.Println("gRPC server listening on :50051")\n' + '\tif err := s.Serve(lis); err != nil {\n\t\tlog.Fatalf("failed to serve: %v", err)\n\t}\n' + '}\n' + ), + "main_test.go": 'package main\n\nimport "testing"\n\nfunc TestPlaceholder(t *testing.T) {\n\tif false {\n\t\tt.Fail()\n\t}\n}\n', + "proto/service.proto": 'syntax = "proto3";\npackage {{project_slug}};\n\nservice Greeter {\n rpc SayHello (HelloRequest) returns (HelloReply);\n}\n\nmessage HelloRequest { string name = 1; }\nmessage HelloReply { string message = 1; }\n', + "go.mod": 'module {{project_slug}}\n\ngo 1.22\n\nrequire google.golang.org/grpc v1.63.2\n', + "README.md": _readme("go-grpc", "Go gRPC service.", "go run ."), + ".gitignore": _gitignore(_go_gitignore), + }, +) + +# ====================================================================== +# JAVA / KOTLIN TEMPLATES +# ====================================================================== + +_spring_boot = ProjectTemplate( + name="spring-boot", + description="Spring Boot REST API (Java)", + category="java", + language="java", + framework="spring-boot", + files={ + "src/main/java/com/example/app/Application.java": ( + 'package com.example.app;\n\n' + 'import org.springframework.boot.SpringApplication;\n' + 'import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n' + '@SpringBootApplication\n' + 'public class Application {\n' + ' public static void main(String[] args) {\n' + ' SpringApplication.run(Application.class, args);\n' + ' }\n' + '}\n' + ), + "src/main/java/com/example/app/HelloController.java": ( + 'package com.example.app;\n\n' + 'import org.springframework.web.bind.annotation.GetMapping;\n' + 'import org.springframework.web.bind.annotation.RestController;\n\n' + '@RestController\n' + 'public class HelloController {\n' + ' @GetMapping("/")\n' + ' public String index() {\n' + ' return "Hello from {{project_name}}";\n' + ' }\n' + '}\n' + ), + "src/test/java/com/example/app/ApplicationTests.java": ( + 'package com.example.app;\n\n' + 'import org.junit.jupiter.api.Test;\n' + 'import org.springframework.boot.test.context.SpringBootTest;\n\n' + '@SpringBootTest\n' + 'class ApplicationTests {\n' + ' @Test\n' + ' void contextLoads() {}\n' + '}\n' + ), + "pom.xml": ( + '\n' + '\n' + ' 4.0.0\n' + ' \n' + ' org.springframework.boot\n' + ' spring-boot-starter-parent\n' + ' 3.2.5\n' + ' \n' + ' com.example\n' + ' {{project_slug}}\n' + ' 0.1.0\n' + ' \n' + ' \n' + ' org.springframework.boot\n' + ' spring-boot-starter-web\n' + ' \n' + ' \n' + ' org.springframework.boot\n' + ' spring-boot-starter-test\n' + ' test\n' + ' \n' + ' \n' + '\n' + ), + "README.md": _readme("spring-boot", "Spring Boot REST API.", "mvn spring-boot:run"), + ".gitignore": _gitignore("\n# Java\ntarget/\n*.class\n*.jar\n.gradle/\n"), + }, +) + +_android_kotlin = ProjectTemplate( + name="android-kotlin", + description="Android app with Kotlin and Jetpack Compose", + category="kotlin", + language="kotlin", + framework="android", + files={ + "app/src/main/java/com/example/app/MainActivity.kt": ( + 'package com.example.app\n\n' + 'import android.os.Bundle\n' + 'import androidx.activity.ComponentActivity\n' + 'import androidx.activity.compose.setContent\n' + 'import androidx.compose.material3.Text\n\n' + 'class MainActivity : ComponentActivity() {\n' + ' override fun onCreate(savedInstanceState: Bundle?) {\n' + ' super.onCreate(savedInstanceState)\n' + ' setContent { Text("Hello from {{project_name}}") }\n' + ' }\n' + '}\n' + ), + "app/src/test/java/com/example/app/ExampleUnitTest.kt": ( + 'package com.example.app\n\nimport org.junit.Test\nimport org.junit.Assert.*\n\n' + 'class ExampleUnitTest {\n' + ' @Test\n' + ' fun addition_isCorrect() {\n' + ' assertEquals(4, 2 + 2)\n' + ' }\n' + '}\n' + ), + "app/build.gradle.kts": ( + 'plugins {\n id("com.android.application")\n id("org.jetbrains.kotlin.android")\n}\n\n' + 'android {\n namespace = "com.example.app"\n compileSdk = 34\n' + ' defaultConfig {\n applicationId = "com.example.{{project_slug}}"\n' + ' minSdk = 26\n targetSdk = 34\n }\n}\n\n' + 'dependencies {\n' + ' implementation("androidx.activity:activity-compose:1.9.0")\n' + ' implementation("androidx.compose.material3:material3:1.2.1")\n' + ' testImplementation("junit:junit:4.13.2")\n' + '}\n' + ), + "settings.gradle.kts": 'rootProject.name = "{{project_name}}"\ninclude(":app")\n', + "README.md": _readme("android-kotlin", "Android app with Kotlin + Compose.", "./gradlew assembleDebug"), + ".gitignore": _gitignore("\n# Android\nbuild/\n.gradle/\nlocal.properties\n*.apk\n"), + }, +) + +_compose_desktop = ProjectTemplate( + name="compose-desktop", + description="Compose Multiplatform desktop app (Kotlin)", + category="kotlin", + language="kotlin", + framework="compose-desktop", + files={ + "src/main/kotlin/Main.kt": ( + 'import androidx.compose.material.Text\n' + 'import androidx.compose.ui.window.Window\n' + 'import androidx.compose.ui.window.application\n\n' + 'fun main() = application {\n' + ' Window(onCloseRequest = ::exitApplication, title = "{{project_name}}") {\n' + ' Text("Hello from {{project_name}}")\n' + ' }\n' + '}\n' + ), + "src/test/kotlin/MainTest.kt": 'import org.junit.Test\nimport kotlin.test.assertTrue\n\nclass MainTest {\n @Test\n fun placeholder() {\n assertTrue(true)\n }\n}\n', + "build.gradle.kts": ( + 'plugins {\n kotlin("jvm") version "1.9.23"\n' + ' id("org.jetbrains.compose") version "1.6.2"\n}\n\n' + 'dependencies {\n implementation(compose.desktop.currentOs)\n' + ' testImplementation(kotlin("test"))\n}\n\n' + 'compose.desktop {\n application {\n' + ' mainClass = "MainKt"\n }\n}\n' + ), + "settings.gradle.kts": 'rootProject.name = "{{project_name}}"\n', + "README.md": _readme("compose-desktop", "Compose Multiplatform desktop app.", "./gradlew run"), + ".gitignore": _gitignore("\nbuild/\n.gradle/\n"), + }, +) + +# ====================================================================== +# C / C++ TEMPLATES +# ====================================================================== + +_cmake_project = ProjectTemplate( + name="cmake-project", + description="C/C++ project with CMake", + category="c-cpp", + language="c++", + framework="cmake", + files={ + "src/main.cpp": '#include \n\nint main() {\n std::cout << "Hello from {{project_name}}" << std::endl;\n return 0;\n}\n', + "tests/test_main.cpp": '#include \n\nint main() {\n assert(1 + 1 == 2);\n return 0;\n}\n', + "CMakeLists.txt": ( + 'cmake_minimum_required(VERSION 3.20)\n' + 'project({{project_slug}} VERSION 0.1.0 LANGUAGES CXX)\n\n' + 'set(CMAKE_CXX_STANDARD 20)\n' + 'set(CMAKE_CXX_STANDARD_REQUIRED ON)\n\n' + 'add_executable(${PROJECT_NAME} src/main.cpp)\n\n' + 'enable_testing()\n' + 'add_executable(tests tests/test_main.cpp)\n' + 'add_test(NAME tests COMMAND tests)\n' + ), + "README.md": _readme("cmake-project", "C++ project with CMake.", "cmake -B build && cmake --build build"), + ".gitignore": _gitignore("\n# C/C++\nbuild/\n*.o\n*.a\n*.so\n*.dylib\n"), + }, +) + +_arduino = ProjectTemplate( + name="arduino", + description="Arduino sketch project", + category="c-cpp", + language="c++", + framework="arduino", + files={ + "src/main.ino": ( + 'void setup() {\n' + ' Serial.begin(115200);\n' + ' Serial.println("{{project_name}} started");\n' + ' pinMode(LED_BUILTIN, OUTPUT);\n' + '}\n\n' + 'void loop() {\n' + ' digitalWrite(LED_BUILTIN, HIGH);\n' + ' delay(1000);\n' + ' digitalWrite(LED_BUILTIN, LOW);\n' + ' delay(1000);\n' + '}\n' + ), + "tests/test_placeholder.cpp": '#include \n\nint main() {\n assert(true);\n return 0;\n}\n', + "platformio.ini": '[env:uno]\nplatform = atmelavr\nboard = uno\nframework = arduino\nmonitor_speed = 115200\n', + "README.md": _readme("arduino", "Arduino sketch project.", "pio run --target upload"), + ".gitignore": _gitignore("\n.pio/\n.vscode/\n"), + }, +) + +_embedded_zephyr = ProjectTemplate( + name="embedded-zephyr", + description="Zephyr RTOS embedded project", + category="c-cpp", + language="c", + framework="zephyr", + files={ + "src/main.c": ( + '#include \n' + '#include \n\n' + 'int main(void) {\n' + ' printk("{{project_name}} started\\n");\n' + ' while (1) {\n' + ' k_msleep(1000);\n' + ' }\n' + ' return 0;\n' + '}\n' + ), + "tests/test_placeholder.c": '#include \n\nint main(void) {\n assert(1);\n return 0;\n}\n', + "CMakeLists.txt": 'cmake_minimum_required(VERSION 3.20.0)\nfind_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})\nproject({{project_slug}})\n\ntarget_sources(app PRIVATE src/main.c)\n', + "prj.conf": '# Zephyr project configuration\nCONFIG_PRINTK=y\nCONFIG_LOG=y\n', + "README.md": _readme("embedded-zephyr", "Zephyr RTOS embedded project.", "west build -b "), + ".gitignore": _gitignore("\nbuild/\n"), + }, +) + +# ====================================================================== +# SWIFT TEMPLATES +# ====================================================================== + +_ios_swiftui = ProjectTemplate( + name="ios-swiftui", + description="iOS app with SwiftUI", + category="swift", + language="swift", + framework="swiftui", + files={ + "Sources/App.swift": ( + 'import SwiftUI\n\n' + '@main\n' + 'struct MainApp: App {\n' + ' var body: some Scene {\n' + ' WindowGroup {\n' + ' ContentView()\n' + ' }\n' + ' }\n' + '}\n' + ), + "Sources/ContentView.swift": ( + 'import SwiftUI\n\n' + 'struct ContentView: View {\n' + ' var body: some View {\n' + ' VStack {\n' + ' Text("{{project_name}}")\n' + ' .font(.largeTitle)\n' + ' }\n' + ' .padding()\n' + ' }\n' + '}\n' + ), + "Tests/ContentViewTests.swift": ( + 'import XCTest\n' + '@testable import {{project_slug}}\n\n' + 'final class ContentViewTests: XCTestCase {\n' + ' func testPlaceholder() {\n' + ' XCTAssertTrue(true)\n' + ' }\n' + '}\n' + ), + "Package.swift": ( + '// swift-tools-version: 5.9\n' + 'import PackageDescription\n\n' + 'let package = Package(\n' + ' name: "{{project_name}}",\n' + ' platforms: [.iOS(.v17)],\n' + ' targets: [\n' + ' .executableTarget(name: "{{project_slug}}", path: "Sources"),\n' + ' .testTarget(name: "{{project_slug}}Tests", dependencies: ["{{project_slug}}"], path: "Tests"),\n' + ' ]\n' + ')\n' + ), + "README.md": _readme("ios-swiftui", "iOS app with SwiftUI.", "open in Xcode and run"), + ".gitignore": _gitignore("\n# Swift\n.build/\n*.xcodeproj/\nDerivedData/\n"), + }, +) + +_macos_app = ProjectTemplate( + name="macos-app", + description="macOS app with SwiftUI", + category="swift", + language="swift", + framework="swiftui", + files={ + "Sources/App.swift": ( + 'import SwiftUI\n\n' + '@main\nstruct MainApp: App {\n' + ' var body: some Scene {\n' + ' WindowGroup {\n' + ' Text("{{project_name}}")\n' + ' .frame(width: 400, height: 300)\n' + ' }\n' + ' }\n' + '}\n' + ), + "Tests/AppTests.swift": 'import XCTest\n\nfinal class AppTests: XCTestCase {\n func testPlaceholder() {\n XCTAssertTrue(true)\n }\n}\n', + "Package.swift": ( + '// swift-tools-version: 5.9\nimport PackageDescription\n\n' + 'let package = Package(\n' + ' name: "{{project_name}}",\n' + ' platforms: [.macOS(.v14)],\n' + ' targets: [\n' + ' .executableTarget(name: "{{project_slug}}", path: "Sources"),\n' + ' .testTarget(name: "{{project_slug}}Tests", path: "Tests"),\n' + ' ]\n' + ')\n' + ), + "README.md": _readme("macos-app", "macOS app with SwiftUI.", "swift run"), + ".gitignore": _gitignore("\n.build/\nDerivedData/\n"), + }, +) + +_vapor = ProjectTemplate( + name="vapor", + description="Vapor server-side Swift API", + category="swift", + language="swift", + framework="vapor", + files={ + "Sources/App/configure.swift": 'import Vapor\n\npublic func configure(_ app: Application) throws {\n try routes(app)\n}\n', + "Sources/App/routes.swift": ( + 'import Vapor\n\n' + 'func routes(_ app: Application) throws {\n' + ' app.get { req in\n' + ' return "Hello from {{project_name}}"\n' + ' }\n' + ' app.get("health") { req in\n' + ' return ["status": "ok"]\n' + ' }\n' + '}\n' + ), + "Sources/Run/main.swift": 'import App\nimport Vapor\n\nvar env = try Environment.detect()\nlet app = Application(env)\ndefer { app.shutdown() }\ntry configure(app)\ntry app.run()\n', + "Tests/AppTests/RouteTests.swift": 'import XCTest\n@testable import App\nimport XCTVapor\n\nfinal class RouteTests: XCTestCase {\n func testIndex() throws {\n let app = Application(.testing)\n defer { app.shutdown() }\n try configure(app)\n try app.test(.GET, "/") { res in\n XCTAssertEqual(res.status, .ok)\n }\n }\n}\n', + "Package.swift": ( + '// swift-tools-version: 5.9\nimport PackageDescription\n\n' + 'let package = Package(\n' + ' name: "{{project_name}}",\n' + ' platforms: [.macOS(.v13)],\n' + ' dependencies: [\n' + ' .package(url: "https://github.com/vapor/vapor.git", from: "4.92.0"),\n' + ' ],\n' + ' targets: [\n' + ' .target(name: "App", dependencies: [.product(name: "Vapor", package: "vapor")], path: "Sources/App"),\n' + ' .executableTarget(name: "Run", dependencies: ["App"], path: "Sources/Run"),\n' + ' .testTarget(name: "AppTests", dependencies: ["App", .product(name: "XCTVapor", package: "vapor")], path: "Tests/AppTests"),\n' + ' ]\n' + ')\n' + ), + "README.md": _readme("vapor", "Vapor server-side Swift API.", "swift run Run"), + ".gitignore": _gitignore("\n.build/\nPackage.resolved\n"), + }, +) + +# ====================================================================== +# C# / .NET TEMPLATES +# ====================================================================== + +_dotnet_api = ProjectTemplate( + name="dotnet-api", + description=".NET 8 minimal API", + category="csharp", + language="c#", + framework="dotnet", + files={ + "Program.cs": ( + 'var builder = WebApplication.CreateBuilder(args);\n' + 'var app = builder.Build();\n\n' + 'app.MapGet("/", () => new { Message = "Hello from {{project_name}}" });\n' + 'app.MapGet("/health", () => new { Status = "ok" });\n\n' + 'app.Run();\n' + ), + "Tests/ApiTests.cs": ( + 'using Xunit;\n\n' + 'public class ApiTests\n' + '{\n' + ' [Fact]\n' + ' public void Placeholder()\n' + ' {\n' + ' Assert.True(true);\n' + ' }\n' + '}\n' + ), + "{{project_slug}}.csproj": ( + '\n' + ' \n' + ' net8.0\n' + ' \n' + '\n' + ), + "README.md": _readme("dotnet-api", ".NET 8 minimal API.", "dotnet run"), + ".gitignore": _gitignore("\n# .NET\nbin/\nobj/\n*.user\n"), + }, +) + +_blazor = ProjectTemplate( + name="blazor", + description="Blazor WebAssembly app", + category="csharp", + language="c#", + framework="blazor", + files={ + "Pages/Index.razor": '@page "/"\n\n

{{project_name}}

\n

Welcome to Blazor.

\n', + "Program.cs": 'using Microsoft.AspNetCore.Components.WebAssembly.Hosting;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\nawait builder.Build().RunAsync();\n', + "Tests/IndexTests.cs": 'using Xunit;\n\npublic class IndexTests\n{\n [Fact]\n public void Placeholder() => Assert.True(true);\n}\n', + "{{project_slug}}.csproj": '\n \n net8.0\n \n\n', + "README.md": _readme("blazor", "Blazor WebAssembly app.", "dotnet run"), + ".gitignore": _gitignore("\nbin/\nobj/\n"), + }, +) + +_unity = ProjectTemplate( + name="unity", + description="Unity game project stub", + category="csharp", + language="c#", + framework="unity", + files={ + "Assets/Scripts/GameManager.cs": ( + 'using UnityEngine;\n\n' + 'public class GameManager : MonoBehaviour\n' + '{\n' + ' void Start()\n' + ' {\n' + ' Debug.Log("{{project_name}} started");\n' + ' }\n\n' + ' void Update() { }\n' + '}\n' + ), + "Assets/Tests/EditMode/GameManagerTests.cs": ( + 'using NUnit.Framework;\n\n' + 'public class GameManagerTests\n' + '{\n' + ' [Test]\n' + ' public void Placeholder()\n' + ' {\n' + ' Assert.IsTrue(true);\n' + ' }\n' + '}\n' + ), + "ProjectSettings/ProjectVersion.txt": 'm_EditorVersion: 2023.2.0f1\n', + "README.md": _readme("unity", "Unity game project.", "Open in Unity Editor"), + ".gitignore": _gitignore("\n# Unity\n[Ll]ibrary/\n[Tt]emp/\n[Oo]bj/\n[Bb]uild/\n*.csproj\n*.sln\n*.pidb\n*.userprefs\n"), + }, +) + +# ====================================================================== +# DART / FLUTTER TEMPLATES +# ====================================================================== + +_flutter_app = ProjectTemplate( + name="flutter-app", + description="Flutter cross-platform app", + category="dart", + language="dart", + framework="flutter", + files={ + "lib/main.dart": ( + 'import \'package:flutter/material.dart\';\n\n' + 'void main() => runApp(const MyApp());\n\n' + 'class MyApp extends StatelessWidget {\n' + ' const MyApp({super.key});\n\n' + ' @override\n' + ' Widget build(BuildContext context) {\n' + ' return MaterialApp(\n' + ' title: \'{{project_name}}\',\n' + ' home: const Scaffold(\n' + ' body: Center(child: Text(\'{{project_name}}\')),\n' + ' ),\n' + ' );\n' + ' }\n' + '}\n' + ), + "test/widget_test.dart": ( + 'import \'package:flutter_test/flutter_test.dart\';\n' + 'import \'package:{{project_slug}}/main.dart\';\n\n' + 'void main() {\n' + ' testWidgets(\'app renders\', (WidgetTester tester) async {\n' + ' await tester.pumpWidget(const MyApp());\n' + ' expect(find.text(\'{{project_name}}\'), findsOneWidget);\n' + ' });\n' + '}\n' + ), + "pubspec.yaml": ( + 'name: {{project_slug}}\n' + 'description: {{project_name}}\n' + 'version: 0.1.0\n\n' + 'environment:\n sdk: ">=3.3.0 <4.0.0"\n\n' + 'dependencies:\n flutter:\n sdk: flutter\n\n' + 'dev_dependencies:\n flutter_test:\n sdk: flutter\n' + ), + "README.md": _readme("flutter-app", "Flutter cross-platform app.", "flutter run"), + ".gitignore": _gitignore("\n# Flutter\nbuild/\n.dart_tool/\n.flutter-plugins\n.packages\n"), + }, +) + +_dart_package = ProjectTemplate( + name="dart-package", + description="Dart library package", + category="dart", + language="dart", + framework="dart", + files={ + "lib/{{project_slug}}.dart": ( + '/// {{project_name}} library.\n' + 'library {{project_slug}};\n\n' + 'String greet(String name) => \'Hello, $name!\';\n' + ), + "test/{{project_slug}}_test.dart": ( + 'import \'package:test/test.dart\';\n' + 'import \'package:{{project_slug}}/{{project_slug}}.dart\';\n\n' + 'void main() {\n' + ' test(\'greet\', () {\n' + ' expect(greet(\'World\'), equals(\'Hello, World!\'));\n' + ' });\n' + '}\n' + ), + "pubspec.yaml": 'name: {{project_slug}}\ndescription: {{project_name}}\nversion: 0.1.0\n\nenvironment:\n sdk: ">=3.3.0 <4.0.0"\n\ndev_dependencies:\n test: ^1.25.0\n', + "README.md": _readme("dart-package", "Dart library package.", "dart test"), + ".gitignore": _gitignore("\n.dart_tool/\n.packages\nbuild/\npubspec.lock\n"), + }, +) + + +# ====================================================================== +# Master list +# ====================================================================== + +_ALL_TEMPLATES: list[ProjectTemplate] = [ + # Python (7) + _fastapi, _flask, _django, _cli_click, _cli_typer, _library_setuptools, _library_poetry, + # JavaScript/TypeScript (10) + _react, _nextjs, _vue, _nuxt, _svelte, _angular, _express, _nestjs, _electron, _react_native, + # Rust (5) + _rust_binary, _rust_library, _actix_web, _axum, _tauri, + # Go (4) + _go_cli, _go_api_gin, _go_api_echo, _go_grpc, + # Java/Kotlin (3) + _spring_boot, _android_kotlin, _compose_desktop, + # C/C++ (3) + _cmake_project, _arduino, _embedded_zephyr, + # Swift (3) + _ios_swiftui, _macos_app, _vapor, + # C# (3) + _dotnet_api, _blazor, _unity, + # Dart (2) + _flutter_app, _dart_package, +] diff --git a/eostudio/core/simulation/__pycache__/__init__.cpython-38.pyc b/eostudio/core/simulation/__pycache__/__init__.cpython-38.pyc index e44fd85..3b36add 100644 Binary files a/eostudio/core/simulation/__pycache__/__init__.cpython-38.pyc and b/eostudio/core/simulation/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/simulation/__pycache__/engine.cpython-38.pyc b/eostudio/core/simulation/__pycache__/engine.cpython-38.pyc index d46afe0..cf95be1 100644 Binary files a/eostudio/core/simulation/__pycache__/engine.cpython-38.pyc and b/eostudio/core/simulation/__pycache__/engine.cpython-38.pyc differ diff --git a/eostudio/core/simulation/__pycache__/eosim_bridge.cpython-38.pyc b/eostudio/core/simulation/__pycache__/eosim_bridge.cpython-38.pyc new file mode 100644 index 0000000..e7a1d97 Binary files /dev/null and b/eostudio/core/simulation/__pycache__/eosim_bridge.cpython-38.pyc differ diff --git a/eostudio/core/specs/__init__.py b/eostudio/core/specs/__init__.py new file mode 100644 index 0000000..4d2975e --- /dev/null +++ b/eostudio/core/specs/__init__.py @@ -0,0 +1,15 @@ +"""Spec Engine — Requirements → Design Spec → Tech Spec → Tasks, like Kiro.dev.""" + +from eostudio.core.specs.requirement import Requirement, RequirementType, RequirementPriority +from eostudio.core.specs.design_spec import DesignSpec, DesignSection +from eostudio.core.specs.tech_spec import TechSpec, TechComponent, TechAPI, TechDataModel +from eostudio.core.specs.task_breakdown import TaskBreakdown, Task, TaskStatus +from eostudio.core.specs.spec_engine import SpecEngine + +__all__ = [ + "Requirement", "RequirementType", "RequirementPriority", + "DesignSpec", "DesignSection", + "TechSpec", "TechComponent", "TechAPI", "TechDataModel", + "TaskBreakdown", "Task", "TaskStatus", + "SpecEngine", +] diff --git a/eostudio/core/specs/__pycache__/__init__.cpython-38.pyc b/eostudio/core/specs/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..3affc99 Binary files /dev/null and b/eostudio/core/specs/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/specs/__pycache__/design_spec.cpython-38.pyc b/eostudio/core/specs/__pycache__/design_spec.cpython-38.pyc new file mode 100644 index 0000000..af52f9f Binary files /dev/null and b/eostudio/core/specs/__pycache__/design_spec.cpython-38.pyc differ diff --git a/eostudio/core/specs/__pycache__/requirement.cpython-38.pyc b/eostudio/core/specs/__pycache__/requirement.cpython-38.pyc new file mode 100644 index 0000000..919548a Binary files /dev/null and b/eostudio/core/specs/__pycache__/requirement.cpython-38.pyc differ diff --git a/eostudio/core/specs/__pycache__/spec_engine.cpython-38.pyc b/eostudio/core/specs/__pycache__/spec_engine.cpython-38.pyc new file mode 100644 index 0000000..e51937b Binary files /dev/null and b/eostudio/core/specs/__pycache__/spec_engine.cpython-38.pyc differ diff --git a/eostudio/core/specs/__pycache__/task_breakdown.cpython-38.pyc b/eostudio/core/specs/__pycache__/task_breakdown.cpython-38.pyc new file mode 100644 index 0000000..1229452 Binary files /dev/null and b/eostudio/core/specs/__pycache__/task_breakdown.cpython-38.pyc differ diff --git a/eostudio/core/specs/__pycache__/tech_spec.cpython-38.pyc b/eostudio/core/specs/__pycache__/tech_spec.cpython-38.pyc new file mode 100644 index 0000000..5a371d3 Binary files /dev/null and b/eostudio/core/specs/__pycache__/tech_spec.cpython-38.pyc differ diff --git a/eostudio/core/specs/design_spec.py b/eostudio/core/specs/design_spec.py new file mode 100644 index 0000000..645591b --- /dev/null +++ b/eostudio/core/specs/design_spec.py @@ -0,0 +1,81 @@ +"""Design Spec — high-level architecture, user flows, wireframes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class DesignSection: + """A section of the design spec (e.g., Architecture, User Flows, Data Model).""" + title: str + content: str + diagrams: List[Dict[str, Any]] = field(default_factory=list) + wireframes: List[Dict[str, Any]] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return {"title": self.title, "content": self.content, + "diagrams": self.diagrams, "wireframes": self.wireframes, "notes": self.notes} + + def to_markdown(self) -> str: + lines = [f"## {self.title}", "", self.content] + for note in self.notes: + lines.append(f"\n> {note}") + return "\n".join(lines) + + +@dataclass +class DesignSpec: + """Complete design specification document.""" + project_name: str + version: str = "1.0" + overview: str = "" + goals: List[str] = field(default_factory=list) + non_goals: List[str] = field(default_factory=list) + target_users: List[str] = field(default_factory=list) + sections: List[DesignSection] = field(default_factory=list) + open_questions: List[str] = field(default_factory=list) + risks: List[Dict[str, str]] = field(default_factory=list) + + def add_section(self, title: str, content: str) -> DesignSection: + section = DesignSection(title=title, content=content) + self.sections.append(section) + return section + + def to_dict(self) -> Dict[str, Any]: + return { + "project_name": self.project_name, "version": self.version, + "overview": self.overview, "goals": self.goals, + "non_goals": self.non_goals, "target_users": self.target_users, + "sections": [s.to_dict() for s in self.sections], + "open_questions": self.open_questions, "risks": self.risks, + } + + def to_markdown(self) -> str: + lines = [f"# Design Spec: {self.project_name} v{self.version}", "", + "## Overview", self.overview, "", + "## Goals", *[f"- {g}" for g in self.goals], "", + "## Non-Goals", *[f"- {g}" for g in self.non_goals], "", + "## Target Users", *[f"- {u}" for u in self.target_users], ""] + for section in self.sections: + lines.append(section.to_markdown()) + lines.append("") + if self.open_questions: + lines.extend(["## Open Questions", *[f"- [ ] {q}" for q in self.open_questions]]) + if self.risks: + lines.extend(["", "## Risks"]) + for r in self.risks: + lines.append(f"- **{r.get('risk', '')}** — Mitigation: {r.get('mitigation', 'TBD')}") + return "\n".join(lines) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "DesignSpec": + spec = cls(project_name=data["project_name"], version=data.get("version", "1.0"), + overview=data.get("overview", ""), goals=data.get("goals", []), + non_goals=data.get("non_goals", []), target_users=data.get("target_users", []), + open_questions=data.get("open_questions", []), risks=data.get("risks", [])) + for s in data.get("sections", []): + spec.sections.append(DesignSection(**{k: v for k, v in s.items() if k in DesignSection.__dataclass_fields__})) + return spec diff --git a/eostudio/core/specs/requirement.py b/eostudio/core/specs/requirement.py new file mode 100644 index 0000000..b736d92 --- /dev/null +++ b/eostudio/core/specs/requirement.py @@ -0,0 +1,94 @@ +"""Requirements specification — user stories, acceptance criteria, priorities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class RequirementType(Enum): + FUNCTIONAL = "functional" + NON_FUNCTIONAL = "non_functional" + USER_STORY = "user_story" + CONSTRAINT = "constraint" + ASSUMPTION = "assumption" + + +class RequirementPriority(Enum): + MUST = "must" # P0 — must have + SHOULD = "should" # P1 — should have + COULD = "could" # P2 — nice to have + WONT = "wont" # P3 — won't have this release + + +@dataclass +class AcceptanceCriteria: + """A single acceptance criterion for a requirement.""" + description: str + test_method: str = "manual" # manual, unit, integration, e2e + verified: bool = False + + def to_dict(self) -> Dict[str, Any]: + return {"description": self.description, "test_method": self.test_method, "verified": self.verified} + + +@dataclass +class Requirement: + """A single requirement/user story in the spec.""" + id: str + title: str + description: str + req_type: RequirementType = RequirementType.USER_STORY + priority: RequirementPriority = RequirementPriority.SHOULD + acceptance_criteria: List[AcceptanceCriteria] = field(default_factory=list) + dependencies: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + status: str = "draft" # draft, approved, in_progress, done + assignee: str = "" + estimated_effort: str = "" # S, M, L, XL + + def add_criteria(self, description: str, test_method: str = "manual") -> AcceptanceCriteria: + ac = AcceptanceCriteria(description=description, test_method=test_method) + self.acceptance_criteria.append(ac) + return ac + + @property + def is_complete(self) -> bool: + return all(ac.verified for ac in self.acceptance_criteria) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, "title": self.title, "description": self.description, + "type": self.req_type.value, "priority": self.priority.value, + "acceptance_criteria": [ac.to_dict() for ac in self.acceptance_criteria], + "dependencies": self.dependencies, "tags": self.tags, + "status": self.status, "assignee": self.assignee, + "estimated_effort": self.estimated_effort, + } + + def to_markdown(self) -> str: + lines = [f"### {self.id}: {self.title}", "", + f"**Type:** {self.req_type.value} | **Priority:** {self.priority.value} | **Effort:** {self.estimated_effort}", "", + self.description, "", "**Acceptance Criteria:**"] + for i, ac in enumerate(self.acceptance_criteria, 1): + check = "x" if ac.verified else " " + lines.append(f"- [{check}] {ac.description} ({ac.test_method})") + if self.dependencies: + lines.append(f"\n**Dependencies:** {', '.join(self.dependencies)}") + return "\n".join(lines) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Requirement": + req = cls( + id=data["id"], title=data["title"], description=data["description"], + req_type=RequirementType(data.get("type", "user_story")), + priority=RequirementPriority(data.get("priority", "should")), + dependencies=data.get("dependencies", []), + tags=data.get("tags", []), status=data.get("status", "draft"), + assignee=data.get("assignee", ""), + estimated_effort=data.get("estimated_effort", ""), + ) + for ac in data.get("acceptance_criteria", []): + req.acceptance_criteria.append(AcceptanceCriteria(**ac)) + return req diff --git a/eostudio/core/specs/spec_engine.py b/eostudio/core/specs/spec_engine.py new file mode 100644 index 0000000..d3ca16a --- /dev/null +++ b/eostudio/core/specs/spec_engine.py @@ -0,0 +1,490 @@ +"""Spec Engine — AI-powered spec generation: prompt → requirements → design → tech → tasks. + +Multi-pass refinement: generate → validate → refine → finalize. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from eostudio.core.ai.llm_client import LLMClient, LLMConfig +from eostudio.core.specs.requirement import Requirement, RequirementType, RequirementPriority +from eostudio.core.specs.design_spec import DesignSpec +from eostudio.core.specs.tech_spec import TechSpec +from eostudio.core.specs.task_breakdown import TaskBreakdown, Task + + +# --------------------------------------------------------------------------- +# Spec templates for common project types +# --------------------------------------------------------------------------- + +SPEC_TEMPLATES: Dict[str, Dict[str, Any]] = { + "saas": { + "required_sections": ["Authentication", "Dashboard", "Billing", "Settings", "API"], + "default_requirements": [ + "User signup/login with email + OAuth", + "Role-based access control (admin, member, viewer)", + "Subscription billing with Stripe", + "Team/org management", + "Usage analytics dashboard", + "REST API with rate limiting", + ], + "tech_defaults": { + "frontend": ["React", "TypeScript", "Tailwind CSS"], + "backend": ["FastAPI", "SQLAlchemy", "Alembic"], + "database": ["PostgreSQL", "Redis"], + "infra": ["Docker", "Vercel"], + }, + }, + "ecommerce": { + "required_sections": ["Product Catalog", "Cart", "Checkout", "Orders", "Admin"], + "default_requirements": [ + "Product listing with search and filters", + "Shopping cart with persistent state", + "Checkout with Stripe/PayPal", + "Order tracking and history", + "Admin product management", + "Inventory tracking", + ], + "tech_defaults": { + "frontend": ["Next.js", "TypeScript", "Tailwind CSS"], + "backend": ["Node.js", "Express", "Prisma"], + "database": ["PostgreSQL", "Redis"], + "infra": ["Docker", "Vercel"], + }, + }, + "mobile_app": { + "required_sections": ["Onboarding", "Core Features", "Profile", "Notifications", "Offline"], + "default_requirements": [ + "User onboarding flow", + "Push notifications", + "Offline data sync", + "Profile management", + "Deep linking", + "App analytics", + ], + "tech_defaults": { + "frontend": ["React Native", "TypeScript", "NativeWind"], + "backend": ["FastAPI", "SQLAlchemy"], + "database": ["PostgreSQL", "SQLite (local)"], + "infra": ["Docker", "AWS"], + }, + }, + "api": { + "required_sections": ["Endpoints", "Authentication", "Rate Limiting", "Documentation", "Monitoring"], + "default_requirements": [ + "RESTful API design with OpenAPI spec", + "JWT/OAuth2 authentication", + "Rate limiting and throttling", + "Request validation and error handling", + "Auto-generated API documentation", + "Health check and monitoring endpoints", + ], + "tech_defaults": { + "frontend": [], + "backend": ["FastAPI", "Python 3.10+", "Pydantic"], + "database": ["PostgreSQL", "Redis"], + "infra": ["Docker", "AWS Lambda"], + }, + }, +} + + +@dataclass +class SpecValidationResult: + """Result of spec validation with gaps identified.""" + is_valid: bool = True + missing_acceptance_criteria: List[str] = field(default_factory=list) + requirements_without_tasks: List[str] = field(default_factory=list) + components_without_tests: List[str] = field(default_factory=list) + invest_violations: List[str] = field(default_factory=list) + missing_sections: List[str] = field(default_factory=list) + score: float = 100.0 + + def to_dict(self) -> Dict[str, Any]: + return { + "is_valid": self.is_valid, "score": self.score, + "missing_acceptance_criteria": self.missing_acceptance_criteria, + "requirements_without_tasks": self.requirements_without_tasks, + "components_without_tests": self.components_without_tests, + "invest_violations": self.invest_violations, + "missing_sections": self.missing_sections, + } + + @property + def gap_summary(self) -> str: + gaps = [] + if self.missing_acceptance_criteria: + gaps.append(f"{len(self.missing_acceptance_criteria)} requirements missing acceptance criteria") + if self.requirements_without_tasks: + gaps.append(f"{len(self.requirements_without_tasks)} requirements have no mapped tasks") + if self.components_without_tests: + gaps.append(f"{len(self.components_without_tests)} components have no test tasks") + if self.invest_violations: + gaps.append(f"{len(self.invest_violations)} INVEST violations") + if self.missing_sections: + gaps.append(f"{len(self.missing_sections)} missing design sections") + return "; ".join(gaps) if gaps else "No gaps found" + + +class SpecEngine: + """Kiro-style spec-driven development: prompt → requirements → design → tech → tasks. + + Supports multi-pass refinement: generate → validate → refine → finalize. + """ + + def __init__(self, llm_client: Optional[LLMClient] = None, + max_refinement_passes: int = 2) -> None: + self._client = llm_client or LLMClient(LLMConfig()) + self.max_refinement_passes = max_refinement_passes + + def generate_full_spec(self, prompt: str, framework: str = "react", + project_type: Optional[str] = None) -> Dict[str, Any]: + """Generate complete spec pipeline with multi-pass refinement. + + Pipeline: generate → validate → refine → finalize. + """ + template = SPEC_TEMPLATES.get(project_type) if project_type else None + + # Pass 1: Generate + requirements = self.generate_requirements(prompt, template=template) + design = self.generate_design_spec(prompt, requirements, template=template) + tech = self.generate_tech_spec(design, framework, template=template) + tasks = self.generate_task_breakdown(tech, requirements) + + spec_data = { + "requirements": [r.to_dict() for r in requirements], + "design_spec": design.to_dict(), + "tech_spec": tech.to_dict(), + "task_breakdown": tasks.to_dict(), + } + + # Pass 2+: Validate → Refine loop + for _ in range(self.max_refinement_passes): + validation = self.validate_spec(spec_data) + if validation.is_valid and validation.score >= 90.0: + break + spec_data = self.refine_spec(spec_data, validation) + + spec_data["validation"] = self.validate_spec(spec_data).to_dict() + return spec_data + + def generate_requirements(self, prompt: str, + template: Optional[Dict[str, Any]] = None) -> List[Requirement]: + """Generate requirements/user stories from a project description.""" + template_hint = "" + if template: + defaults = template.get("default_requirements", []) + if defaults: + template_hint = ( + f"\n\nCommon requirements for this type of project " + f"(include these if relevant):\n" + + "\n".join(f"- {r}" for r in defaults) + ) + + messages = [{"role": "user", "content": ( + f"Generate requirements as JSON array for this project:\n{prompt}\n\n" + f"Each requirement: {{id, title, description, type (functional/user_story), " + f"priority (must/should/could), acceptance_criteria: [{{description, test_method}}], " + f"estimated_effort (S/M/L/XL)}}\n\n" + f"IMPORTANT: Every requirement MUST have at least 2 acceptance criteria " + f"with concrete, testable conditions.{template_hint}" + )}] + raw = self._client.chat(messages) + try: + data = json.loads(raw) + if isinstance(data, list): + return [Requirement.from_dict(r) for r in data] + except (json.JSONDecodeError, TypeError): + pass + return self._fallback_requirements(prompt) + + def generate_design_spec(self, prompt: str, requirements: List[Requirement], + template: Optional[Dict[str, Any]] = None) -> DesignSpec: + """Generate design spec from requirements.""" + req_summary = "\n".join(f"- {r.title}" for r in requirements) + section_hint = "" + if template: + sections = template.get("required_sections", []) + if sections: + section_hint = ( + f"\n\nRequired design sections (include all of these):\n" + + "\n".join(f"- {s}" for s in sections) + ) + messages = [{"role": "user", "content": ( + f"Generate a design spec as JSON for:\n{prompt}\n\nRequirements:\n{req_summary}\n\n" + f"Return: {{project_name, overview, goals:[], non_goals:[], target_users:[], " + f"sections:[{{title, content}}], open_questions:[], risks:[{{risk, mitigation}}]}}{section_hint}" + )}] + raw = self._client.chat(messages) + try: + data = json.loads(raw) + if "project_name" in data: + return DesignSpec.from_dict(data) + except (json.JSONDecodeError, TypeError): + pass + return self._fallback_design_spec(prompt) + + def generate_tech_spec(self, design: DesignSpec, framework: str = "react", + template: Optional[Dict[str, Any]] = None) -> TechSpec: + """Generate tech spec from design spec.""" + messages = [{"role": "user", "content": ( + f"Generate a tech spec as JSON for: {design.project_name}\n" + f"Overview: {design.overview}\nFramework: {framework}\n\n" + f"Return: {{project_name, architecture_overview, " + f"tech_stack:{{frontend:[], backend:[], database:[], infra:[]}}, " + f"components:[{{name, description, tech_stack:[], responsibilities:[], " + f"file_structure:[]}}], " + f"security:[], performance_targets:{{}}, testing_strategy:{{}}, " + f"deployment:{{}}}}" + )}] + raw = self._client.chat(messages) + try: + data = json.loads(raw) + if "project_name" in data: + return TechSpec.from_dict(data) + except (json.JSONDecodeError, TypeError): + pass + return self._fallback_tech_spec(design, framework) + + def generate_task_breakdown(self, tech: TechSpec, requirements: List[Requirement]) -> TaskBreakdown: + """Generate implementation tasks from tech spec.""" + tb = TaskBreakdown(project_name=tech.project_name) + + for comp in tech.components: + # Create tasks for each file + for f in comp.file_structure: + task = tb.add_task( + title=f"Implement {f}", + component=comp.name, + files_to_create=[f], + effort="M", + ) + # Add test task + tb.add_task( + title=f"Write tests for {comp.name}", + component=comp.name, + tests_needed=[f"test_{comp.name.lower().replace(' ', '_')}.py"], + effort="M", + ) + + # Add integration and deployment tasks + tb.add_task(title="Integration testing", component="Testing", effort="L") + tb.add_task(title="CI/CD pipeline setup", component="DevOps", effort="M") + tb.add_task(title="Documentation", component="Docs", effort="M") + tb.add_task(title="Deploy to production", component="DevOps", effort="S") + + return tb + + def validate_spec(self, spec_data: Dict[str, Any]) -> SpecValidationResult: + """Validate spec completeness — check that all pieces connect.""" + result = SpecValidationResult() + penalty = 0.0 + + # 1. All requirements must have acceptance criteria + for r in spec_data.get("requirements", []): + criteria = r.get("acceptance_criteria", []) + if len(criteria) < 1: + result.missing_acceptance_criteria.append(r.get("title", r.get("id", "?"))) + penalty += 5.0 + + # 2. INVEST validation on user stories + for r in spec_data.get("requirements", []): + violations = self._validate_invest(r) + result.invest_violations.extend(violations) + penalty += len(violations) * 2.0 + + # 3. Tech spec components should have test tasks + tasks = spec_data.get("task_breakdown", {}).get("tasks", []) + task_components = {t.get("component", "") for t in tasks if "test" in t.get("title", "").lower()} + for comp in spec_data.get("tech_spec", {}).get("components", []): + comp_name = comp.get("name", "") + if comp_name and comp_name not in task_components: + result.components_without_tests.append(comp_name) + penalty += 3.0 + + # 4. Requirements should map to tasks via component + task_titles_lower = " ".join(t.get("title", "").lower() for t in tasks) + for r in spec_data.get("requirements", []): + title_words = r.get("title", "").lower().split() + if not any(w in task_titles_lower for w in title_words if len(w) > 3): + result.requirements_without_tasks.append(r.get("title", "?")) + penalty += 4.0 + + # 5. Design spec should have key sections + sections = [s.get("title", "").lower() + for s in spec_data.get("design_spec", {}).get("sections", [])] + for expected in ["architecture", "data model", "user flow"]: + if not any(expected in s for s in sections): + result.missing_sections.append(expected) + penalty += 3.0 + + result.score = max(0.0, 100.0 - penalty) + result.is_valid = result.score >= 70.0 + return result + + def refine_spec(self, spec_data: Dict[str, Any], + validation: SpecValidationResult) -> Dict[str, Any]: + """Ask AI to fill gaps identified by validation.""" + gap_text = validation.gap_summary + if not gap_text or gap_text == "No gaps found": + return spec_data + + messages = [{"role": "user", "content": ( + f"The following spec has validation gaps:\n{gap_text}\n\n" + f"Current spec (abbreviated):\n" + f"Requirements: {json.dumps(spec_data.get('requirements', [])[:5], indent=1)[:1500]}\n" + f"Design sections: {json.dumps([s.get('title') for s in spec_data.get('design_spec', {}).get('sections', [])])}\n\n" + f"Fix the gaps:\n" + f"1. Add missing acceptance criteria (at least 2 per requirement)\n" + f"2. Add missing design sections\n" + f"3. Ensure user stories follow INVEST (Independent, Negotiable, Valuable, Estimable, Small, Testable)\n\n" + f"Return JSON with keys: requirements (array), extra_sections (array of {{title, content}})" + )}] + + raw = self._client.chat(messages) + try: + fixes = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return spec_data + + # Merge refined requirements + if isinstance(fixes.get("requirements"), list): + existing_ids = {r.get("id") for r in spec_data.get("requirements", [])} + for new_req in fixes["requirements"]: + if new_req.get("id") in existing_ids: + # Update existing + for i, old_req in enumerate(spec_data["requirements"]): + if old_req.get("id") == new_req.get("id"): + spec_data["requirements"][i] = {**old_req, **new_req} + break + else: + spec_data.setdefault("requirements", []).append(new_req) + + # Merge extra design sections + if isinstance(fixes.get("extra_sections"), list): + existing_titles = {s.get("title", "").lower() + for s in spec_data.get("design_spec", {}).get("sections", [])} + for section in fixes["extra_sections"]: + if section.get("title", "").lower() not in existing_titles: + spec_data.setdefault("design_spec", {}).setdefault("sections", []).append(section) + + return spec_data + + @staticmethod + def _validate_invest(requirement: Dict[str, Any]) -> List[str]: + """Validate a requirement against INVEST criteria for user stories.""" + violations = [] + title = requirement.get("title", "") + desc = requirement.get("description", "") + req_type = requirement.get("type", "") + + # Only validate user stories + if req_type not in ("user_story", "functional"): + return violations + + # Valuable: must describe user value, not just technical implementation + tech_only_words = ["refactor", "migrate", "upgrade", "rename", "cleanup"] + if any(w in desc.lower() for w in tech_only_words) and "user" not in desc.lower(): + violations.append(f"'{title}' may lack user value (INVEST: Valuable)") + + # Estimable: must have effort estimate + if not requirement.get("estimated_effort"): + violations.append(f"'{title}' missing effort estimate (INVEST: Estimable)") + + # Small: XL effort may be too large to be a single story + if requirement.get("estimated_effort") == "XL": + violations.append(f"'{title}' is XL — consider splitting (INVEST: Small)") + + # Testable: must have acceptance criteria + criteria = requirement.get("acceptance_criteria", []) + if len(criteria) < 1: + violations.append(f"'{title}' has no acceptance criteria (INVEST: Testable)") + + return violations + + def export_markdown(self, spec_data: Dict[str, Any]) -> str: + """Export the full spec as a single markdown document.""" + lines = [] + if "requirements" in spec_data: + lines.append("# Requirements\n") + for r in spec_data["requirements"]: + req = Requirement.from_dict(r) + lines.append(req.to_markdown()) + lines.append("") + + if "design_spec" in spec_data: + ds = DesignSpec.from_dict(spec_data["design_spec"]) + lines.append(ds.to_markdown()) + lines.append("") + + if "tech_spec" in spec_data: + ts = TechSpec.from_dict(spec_data["tech_spec"]) + lines.append(ts.to_markdown()) + lines.append("") + + if "task_breakdown" in spec_data: + tb = TaskBreakdown.from_dict(spec_data["task_breakdown"]) + lines.append(tb.to_markdown()) + + return "\n".join(lines) + + # Fallbacks + def _fallback_requirements(self, prompt: str) -> List[Requirement]: + words = prompt.split() + name = " ".join(words[:3]) if len(words) >= 3 else prompt + reqs = [ + Requirement(id="REQ-001", title=f"Core {name} functionality", + description=f"Implement the main features described: {prompt}", + req_type=RequirementType.FUNCTIONAL, priority=RequirementPriority.MUST, + estimated_effort="L"), + Requirement(id="REQ-002", title="User authentication", + description="User login, signup, and session management", + req_type=RequirementType.FUNCTIONAL, priority=RequirementPriority.MUST, + estimated_effort="M"), + Requirement(id="REQ-003", title="Responsive UI", + description="Mobile-first responsive design", + req_type=RequirementType.NON_FUNCTIONAL, priority=RequirementPriority.SHOULD, + estimated_effort="M"), + Requirement(id="REQ-004", title="API endpoints", + description="REST API for all CRUD operations", + req_type=RequirementType.FUNCTIONAL, priority=RequirementPriority.MUST, + estimated_effort="L"), + ] + for r in reqs: + r.add_criteria(f"{r.title} works as expected", "integration") + r.add_criteria(f"{r.title} has error handling", "unit") + return reqs + + def _fallback_design_spec(self, prompt: str) -> DesignSpec: + spec = DesignSpec(project_name=prompt[:30], overview=prompt) + spec.goals = ["Deliver a production-ready application", "Clean, maintainable code"] + spec.non_goals = ["Native mobile app (web-first)", "Offline support"] + spec.add_section("Architecture", "Client-server architecture with React frontend and REST API backend.") + spec.add_section("User Flows", "1. Landing → Signup → Dashboard\n2. Login → Dashboard → Features") + spec.add_section("Data Model", "Core entities derived from requirements.") + return spec + + def _fallback_tech_spec(self, design: DesignSpec, framework: str) -> TechSpec: + spec = TechSpec(project_name=design.project_name, + architecture_overview="Modern web application with component-based frontend and REST API backend.") + spec.tech_stack = { + "frontend": [framework, "TypeScript", "Tailwind CSS", "Framer Motion"], + "backend": ["FastAPI", "Python 3.10+", "SQLAlchemy"], + "database": ["PostgreSQL", "Redis"], + "infra": ["Docker", "Vercel/Netlify"], + } + frontend = spec.add_component("Frontend", description="React SPA with routing and state management", + tech_stack=[framework, "TypeScript"], + responsibilities=["UI rendering", "Client-side routing", "API calls"], + file_structure=["src/App.tsx", "src/pages/", "src/components/", "src/hooks/"]) + backend = spec.add_component("Backend", description="REST API server", + tech_stack=["FastAPI", "Python"], + responsibilities=["Business logic", "Authentication", "Database access"], + file_structure=["api/main.py", "api/routes/", "api/models/", "api/services/"]) + spec.security = ["JWT authentication", "CORS configuration", "Input validation", "SQL injection prevention"] + spec.testing_strategy = {"unit": "pytest + jest", "integration": "API tests", "e2e": "Playwright"} + spec.deployment = {"platform": "Docker + Vercel", "ci": "GitHub Actions"} + return spec diff --git a/eostudio/core/specs/task_breakdown.py b/eostudio/core/specs/task_breakdown.py new file mode 100644 index 0000000..0bb4ce6 --- /dev/null +++ b/eostudio/core/specs/task_breakdown.py @@ -0,0 +1,132 @@ +"""Task Breakdown — converts specs into actionable implementation tasks.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional +from datetime import datetime + + +class TaskStatus(Enum): + TODO = "todo" + IN_PROGRESS = "in_progress" + IN_REVIEW = "in_review" + BLOCKED = "blocked" + DONE = "done" + + +@dataclass +class Task: + """A single implementation task.""" + id: str + title: str + description: str = "" + status: TaskStatus = TaskStatus.TODO + requirement_id: str = "" + component: str = "" + files_to_create: List[str] = field(default_factory=list) + files_to_modify: List[str] = field(default_factory=list) + tests_needed: List[str] = field(default_factory=list) + depends_on: List[str] = field(default_factory=list) + assignee: str = "" + effort: str = "M" # S, M, L, XL + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + completed_at: Optional[str] = None + + def complete(self) -> None: + self.status = TaskStatus.DONE + self.completed_at = datetime.now().isoformat() + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, "title": self.title, "description": self.description, + "status": self.status.value, "requirement_id": self.requirement_id, + "component": self.component, "files_to_create": self.files_to_create, + "files_to_modify": self.files_to_modify, "tests_needed": self.tests_needed, + "depends_on": self.depends_on, "effort": self.effort, + } + + def to_markdown(self) -> str: + check = "x" if self.status == TaskStatus.DONE else " " + lines = [f"- [{check}] **{self.id}**: {self.title} [{self.effort}]"] + if self.files_to_create: + lines.append(f" - Create: {', '.join(self.files_to_create)}") + if self.files_to_modify: + lines.append(f" - Modify: {', '.join(self.files_to_modify)}") + if self.tests_needed: + lines.append(f" - Tests: {', '.join(self.tests_needed)}") + return "\n".join(lines) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Task": + return cls(**{k: TaskStatus(v) if k == "status" else v + for k, v in data.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class TaskBreakdown: + """A collection of tasks derived from specs.""" + project_name: str + tasks: List[Task] = field(default_factory=list) + milestones: List[Dict[str, Any]] = field(default_factory=list) + + def add_task(self, title: str, **kwargs: Any) -> Task: + task_id = f"T-{len(self.tasks) + 1:03d}" + task = Task(id=task_id, title=title, **kwargs) + self.tasks.append(task) + return task + + def get_task(self, task_id: str) -> Optional[Task]: + return next((t for t in self.tasks if t.id == task_id), None) + + def by_status(self, status: TaskStatus) -> List[Task]: + return [t for t in self.tasks if t.status == status] + + def by_component(self, component: str) -> List[Task]: + return [t for t in self.tasks if t.component == component] + + @property + def progress(self) -> float: + if not self.tasks: + return 0.0 + done = len([t for t in self.tasks if t.status == TaskStatus.DONE]) + return done / len(self.tasks) * 100 + + def next_tasks(self) -> List[Task]: + """Get tasks that are ready to work on (no unmet dependencies).""" + done_ids = {t.id for t in self.tasks if t.status == TaskStatus.DONE} + return [t for t in self.tasks if t.status == TaskStatus.TODO + and all(d in done_ids for d in t.depends_on)] + + def add_milestone(self, name: str, task_ids: List[str], deadline: str = "") -> None: + self.milestones.append({"name": name, "tasks": task_ids, "deadline": deadline}) + + def to_dict(self) -> Dict[str, Any]: + return {"project": self.project_name, + "tasks": [t.to_dict() for t in self.tasks], + "milestones": self.milestones, + "progress": self.progress} + + def to_markdown(self) -> str: + lines = [f"# Task Breakdown: {self.project_name}", + f"\nProgress: {self.progress:.0f}% ({len(self.by_status(TaskStatus.DONE))}/{len(self.tasks)})\n"] + components = sorted(set(t.component for t in self.tasks if t.component)) + for comp in components: + lines.append(f"\n## {comp}") + for task in self.by_component(comp): + lines.append(task.to_markdown()) + uncategorized = [t for t in self.tasks if not t.component] + if uncategorized: + lines.append("\n## Other") + for task in uncategorized: + lines.append(task.to_markdown()) + return "\n".join(lines) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TaskBreakdown": + tb = cls(project_name=data.get("project", "")) + for t in data.get("tasks", []): + tb.tasks.append(Task.from_dict(t)) + tb.milestones = data.get("milestones", []) + return tb diff --git a/eostudio/core/specs/tech_spec.py b/eostudio/core/specs/tech_spec.py new file mode 100644 index 0000000..ea00750 --- /dev/null +++ b/eostudio/core/specs/tech_spec.py @@ -0,0 +1,156 @@ +"""Tech Spec — components, APIs, data models, implementation details.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class TechDataModel: + """A data model/entity in the tech spec.""" + name: str + fields: List[Dict[str, str]] = field(default_factory=list) + relationships: List[str] = field(default_factory=list) + description: str = "" + + def to_dict(self) -> Dict[str, Any]: + return {"name": self.name, "fields": self.fields, + "relationships": self.relationships, "description": self.description} + + def to_markdown(self) -> str: + lines = [f"#### {self.name}", self.description, ""] + lines.append("| Field | Type | Description |") + lines.append("|-------|------|-------------|") + for f in self.fields: + lines.append(f"| {f.get('name','')} | {f.get('type','')} | {f.get('description','')} |") + return "\n".join(lines) + + +@dataclass +class TechAPI: + """An API endpoint in the tech spec.""" + method: str # GET, POST, PUT, DELETE + path: str + description: str = "" + request_body: Optional[Dict[str, Any]] = None + response: Optional[Dict[str, Any]] = None + auth_required: bool = True + rate_limit: str = "" + + def to_dict(self) -> Dict[str, Any]: + return {"method": self.method, "path": self.path, "description": self.description, + "request_body": self.request_body, "response": self.response, + "auth_required": self.auth_required} + + def to_markdown(self) -> str: + auth = "Auth required" if self.auth_required else "Public" + return f"- `{self.method} {self.path}` — {self.description} ({auth})" + + +@dataclass +class TechComponent: + """A system component (service, module, package).""" + name: str + description: str = "" + tech_stack: List[str] = field(default_factory=list) + responsibilities: List[str] = field(default_factory=list) + dependencies: List[str] = field(default_factory=list) + apis: List[TechAPI] = field(default_factory=list) + data_models: List[TechDataModel] = field(default_factory=list) + file_structure: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, "description": self.description, + "tech_stack": self.tech_stack, "responsibilities": self.responsibilities, + "dependencies": self.dependencies, + "apis": [a.to_dict() for a in self.apis], + "data_models": [d.to_dict() for d in self.data_models], + "file_structure": self.file_structure, + } + + def to_markdown(self) -> str: + lines = [f"### {self.name}", self.description, "", + f"**Stack:** {', '.join(self.tech_stack)}", "", + "**Responsibilities:**", *[f"- {r}" for r in self.responsibilities]] + if self.apis: + lines.extend(["", "**APIs:**", *[a.to_markdown() for a in self.apis]]) + if self.data_models: + lines.extend(["", "**Data Models:**", *[d.to_markdown() for d in self.data_models]]) + if self.file_structure: + lines.extend(["", "**Files:**", "```", *self.file_structure, "```"]) + return "\n".join(lines) + + +@dataclass +class TechSpec: + """Complete technical specification.""" + project_name: str + version: str = "1.0" + architecture_overview: str = "" + tech_stack: Dict[str, List[str]] = field(default_factory=dict) + components: List[TechComponent] = field(default_factory=list) + infrastructure: Dict[str, Any] = field(default_factory=dict) + security: List[str] = field(default_factory=list) + performance_targets: Dict[str, str] = field(default_factory=dict) + testing_strategy: Dict[str, str] = field(default_factory=dict) + deployment: Dict[str, Any] = field(default_factory=dict) + + def add_component(self, name: str, description: str = "", **kwargs: Any) -> TechComponent: + comp = TechComponent(name=name, description=description, **kwargs) + self.components.append(comp) + return comp + + def to_dict(self) -> Dict[str, Any]: + return { + "project_name": self.project_name, "version": self.version, + "architecture_overview": self.architecture_overview, + "tech_stack": self.tech_stack, + "components": [c.to_dict() for c in self.components], + "infrastructure": self.infrastructure, "security": self.security, + "performance_targets": self.performance_targets, + "testing_strategy": self.testing_strategy, "deployment": self.deployment, + } + + def to_markdown(self) -> str: + lines = [f"# Tech Spec: {self.project_name} v{self.version}", "", + "## Architecture", self.architecture_overview, ""] + if self.tech_stack: + lines.append("## Tech Stack") + for cat, items in self.tech_stack.items(): + lines.append(f"- **{cat}:** {', '.join(items)}") + lines.append("") + lines.append("## Components") + for comp in self.components: + lines.append(comp.to_markdown()) + lines.append("") + if self.security: + lines.extend(["## Security", *[f"- {s}" for s in self.security], ""]) + if self.performance_targets: + lines.append("## Performance Targets") + for k, v in self.performance_targets.items(): + lines.append(f"- **{k}:** {v}") + if self.testing_strategy: + lines.extend(["", "## Testing Strategy"]) + for k, v in self.testing_strategy.items(): + lines.append(f"- **{k}:** {v}") + return "\n".join(lines) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TechSpec": + spec = cls(project_name=data["project_name"], version=data.get("version", "1.0"), + architecture_overview=data.get("architecture_overview", ""), + tech_stack=data.get("tech_stack", {}), + infrastructure=data.get("infrastructure", {}), + security=data.get("security", []), + performance_targets=data.get("performance_targets", {}), + testing_strategy=data.get("testing_strategy", {}), + deployment=data.get("deployment", {})) + for c in data.get("components", []): + comp = TechComponent(name=c["name"], description=c.get("description", ""), + tech_stack=c.get("tech_stack", []), + responsibilities=c.get("responsibilities", []), + file_structure=c.get("file_structure", [])) + spec.components.append(comp) + return spec diff --git a/eostudio/core/ui_flow/__pycache__/__init__.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..9531f1e Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/ui_flow/__pycache__/auto_layout.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/auto_layout.cpython-38.pyc new file mode 100644 index 0000000..0c9b873 Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/auto_layout.cpython-38.pyc differ diff --git a/eostudio/core/ui_flow/__pycache__/design_system.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/design_system.cpython-38.pyc new file mode 100644 index 0000000..275a647 Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/design_system.cpython-38.pyc differ diff --git a/eostudio/core/ui_flow/__pycache__/design_tokens.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/design_tokens.cpython-38.pyc new file mode 100644 index 0000000..deea991 Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/design_tokens.cpython-38.pyc differ diff --git a/eostudio/core/ui_flow/__pycache__/responsive.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/responsive.cpython-38.pyc new file mode 100644 index 0000000..0ccc3fb Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/responsive.cpython-38.pyc differ diff --git a/eostudio/core/ui_flow/__pycache__/variants.cpython-38.pyc b/eostudio/core/ui_flow/__pycache__/variants.cpython-38.pyc new file mode 100644 index 0000000..1316cf3 Binary files /dev/null and b/eostudio/core/ui_flow/__pycache__/variants.cpython-38.pyc differ diff --git a/eostudio/core/uml/__pycache__/__init__.cpython-38.pyc b/eostudio/core/uml/__pycache__/__init__.cpython-38.pyc index 89ba7dc..84ec3bc 100644 Binary files a/eostudio/core/uml/__pycache__/__init__.cpython-38.pyc and b/eostudio/core/uml/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/uml/__pycache__/code_gen.cpython-38.pyc b/eostudio/core/uml/__pycache__/code_gen.cpython-38.pyc index 7561241..16638f5 100644 Binary files a/eostudio/core/uml/__pycache__/code_gen.cpython-38.pyc and b/eostudio/core/uml/__pycache__/code_gen.cpython-38.pyc differ diff --git a/eostudio/core/uml/__pycache__/diagrams.cpython-38.pyc b/eostudio/core/uml/__pycache__/diagrams.cpython-38.pyc index 98338cf..f657628 100644 Binary files a/eostudio/core/uml/__pycache__/diagrams.cpython-38.pyc and b/eostudio/core/uml/__pycache__/diagrams.cpython-38.pyc differ diff --git a/eostudio/core/video/__pycache__/__init__.cpython-38.pyc b/eostudio/core/video/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..2621513 Binary files /dev/null and b/eostudio/core/video/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/core/video/__pycache__/compositor.cpython-38.pyc b/eostudio/core/video/__pycache__/compositor.cpython-38.pyc new file mode 100644 index 0000000..fb878f9 Binary files /dev/null and b/eostudio/core/video/__pycache__/compositor.cpython-38.pyc differ diff --git a/eostudio/core/video/__pycache__/export.cpython-38.pyc b/eostudio/core/video/__pycache__/export.cpython-38.pyc new file mode 100644 index 0000000..f048f0c Binary files /dev/null and b/eostudio/core/video/__pycache__/export.cpython-38.pyc differ diff --git a/eostudio/core/video/__pycache__/promo_templates.cpython-38.pyc b/eostudio/core/video/__pycache__/promo_templates.cpython-38.pyc new file mode 100644 index 0000000..0850794 Binary files /dev/null and b/eostudio/core/video/__pycache__/promo_templates.cpython-38.pyc differ diff --git a/eostudio/core/video/__pycache__/recorder.cpython-38.pyc b/eostudio/core/video/__pycache__/recorder.cpython-38.pyc new file mode 100644 index 0000000..8992cc1 Binary files /dev/null and b/eostudio/core/video/__pycache__/recorder.cpython-38.pyc differ diff --git a/eostudio/core/video/promo_templates.py b/eostudio/core/video/promo_templates.py index 8d77fa9..1c9a097 100644 --- a/eostudio/core/video/promo_templates.py +++ b/eostudio/core/video/promo_templates.py @@ -1,4 +1,7 @@ -"""Promo templates — app store previews, social media, product launch videos.""" +"""Promo templates — app store previews, social media, product launch videos. + +Includes subtitle overlays, social media aspect ratio presets, and screen capture templates. +""" from __future__ import annotations @@ -10,6 +13,35 @@ ) +# --------------------------------------------------------------------------- +# Social media aspect ratio presets +# --------------------------------------------------------------------------- + +ASPECT_RATIO_PRESETS: Dict[str, Dict[str, int]] = { + "landscape_16_9": {"width": 1920, "height": 1080}, + "square_1_1": {"width": 1080, "height": 1080}, + "portrait_9_16": {"width": 1080, "height": 1920}, + "twitter_card": {"width": 1200, "height": 675}, + "linkedin_post": {"width": 1200, "height": 627}, + "facebook_cover": {"width": 820, "height": 312}, + "instagram_story": {"width": 1080, "height": 1920}, + "youtube_thumbnail": {"width": 1280, "height": 720}, + "tiktok_reel": {"width": 1080, "height": 1920}, +} + + +@dataclass +class SubtitleEntry: + """A single subtitle/caption entry with timing.""" + text: str + start_time: float + end_time: float + position: str = "bottom" # "bottom", "top", "center" + font_size: int = 32 + color: str = "#ffffff" + bg_color: str = "rgba(0,0,0,0.7)" + + @dataclass class PromoTemplate: """A reusable promotional video/image template.""" @@ -21,6 +53,19 @@ class PromoTemplate: description: str = "" layers_config: List[Dict[str, Any]] = field(default_factory=list) variables: Dict[str, str] = field(default_factory=dict) + subtitles: List[SubtitleEntry] = field(default_factory=list) + + @classmethod + def from_aspect_ratio(cls, name: str, preset: str, duration: float = 5.0, + **kwargs: Any) -> "PromoTemplate": + """Create a template from an aspect ratio preset name.""" + dims = ASPECT_RATIO_PRESETS.get(preset, ASPECT_RATIO_PRESETS["landscape_16_9"]) + return cls(name=name, category="social", width=dims["width"], + height=dims["height"], duration=duration, **kwargs) + + def add_subtitles(self, entries: List[SubtitleEntry]) -> None: + """Add subtitle/caption entries to the template.""" + self.subtitles = entries def create_compositor(self, **overrides: Any) -> VideoCompositor: """Create a VideoCompositor from this template with variable substitutions.""" @@ -56,6 +101,38 @@ def create_compositor(self, **overrides: Any) -> VideoCompositor: end_time=lc.get("end", self.duration), ) comp.add_layer(layer) + + # Add subtitle layers + for idx, sub in enumerate(self.subtitles): + y_pos = { + "bottom": int(self.height * 0.88), + "top": int(self.height * 0.08), + "center": int(self.height * 0.5), + }.get(sub.position, int(self.height * 0.88)) + + sub_layer = Layer( + id=f"subtitle_{idx}", + name=f"Subtitle {idx}", + layer_type=LayerType("text"), + transform=LayerTransform( + x=self.width // 2, y=y_pos, + width=int(self.width * 0.9), height=60, + opacity=1.0, + ), + content={ + "text": sub.text, + "font_size": sub.font_size, + "color": sub.color, + "bg": sub.bg_color, + "text_align": "center", + "padding": "8px 16px", + "border_radius": "8px", + }, + start_time=sub.start_time, + end_time=sub.end_time, + ) + comp.add_layer(sub_layer) + return comp def to_dict(self) -> Dict[str, Any]: @@ -270,3 +347,142 @@ def list_templates(category: Optional[str] = None) -> List[PromoTemplate]: def template_categories() -> List[str]: return sorted(set(t.category for t in PROMO_TEMPLATES.values())) + + +# ---- Instagram Reel (9:16 portrait) ---- +_register(PromoTemplate( + name="instagram_reel", + category="social", + width=1080, height=1920, + duration=15.0, + description="Instagram/TikTok vertical reel with product showcase", + variables={"product_name": "Product", "tagline": "Your tagline", "cta": "Download Now"}, + layers_config=[ + {"id": "bg", "name": "Background", "type": "gradient", + "content": {"colors": ["#0f0f0f", "#1a1a2e"], "direction": "180deg"}, + "transform": {"width": 1080, "height": 1920}}, + {"id": "product", "name": "Product Name", "type": "text", + "content": {"text": "{product_name}", "font_size": 72, "color": "#ffffff", + "font_weight": 800, "text_align": "center"}, + "start": 0, "end": 5, + "transform": {"x": 540, "y": 400}}, + {"id": "tagline", "name": "Tagline", "type": "text", + "content": {"text": "{tagline}", "font_size": 32, "color": "#a0a0a0", + "text_align": "center"}, + "start": 1, "end": 5, + "transform": {"x": 540, "y": 500}}, + {"id": "device", "name": "Device", "type": "device_frame", + "content": {"device": "iphone_15_pro"}, + "start": 3, "end": 12, + "transform": {"x": 540, "y": 1000, "scale_x": 0.65, "scale_y": 0.65}}, + {"id": "cta", "name": "CTA", "type": "text", + "content": {"text": "{cta}", "font_size": 36, "color": "#3b82f6", + "font_weight": 700, "text_align": "center"}, + "start": 12, "end": 15, + "transform": {"x": 540, "y": 1700}}, + ], +)) + +# ---- Screen Capture Template ---- +_register(PromoTemplate( + name="screen_capture", + category="demo", + width=1920, height=1080, + duration=20.0, + description="Simulated IDE/browser screenshot with code typing effect", + variables={"title": "Demo", "code_snippet": "const app = new App();", + "browser_url": "https://myapp.com"}, + layers_config=[ + {"id": "bg", "name": "Background", "type": "gradient", + "content": {"colors": ["#1e1e2e", "#181825"], "direction": "180deg"}, + "transform": {"width": 1920, "height": 1080}}, + {"id": "title_bar", "name": "Title Bar", "type": "shape", + "content": {"type": "rectangle", "color": "#313244"}, + "transform": {"x": 960, "y": 20, "width": 1800, "height": 40}}, + {"id": "dots", "name": "Window Dots", "type": "text", + "content": {"text": "● ● ●", "font_size": 14, "color": "#f38ba8"}, + "transform": {"x": 80, "y": 20}}, + {"id": "title_text", "name": "Window Title", "type": "text", + "content": {"text": "{title}", "font_size": 14, "color": "#cdd6f4"}, + "transform": {"x": 960, "y": 20}}, + {"id": "code_area", "name": "Code Area", "type": "text", + "content": {"text": "{code_snippet}", "font_size": 16, + "color": "#cdd6f4", "font_family": "monospace"}, + "start": 2, "end": 18, + "transform": {"x": 200, "y": 300, "width": 1600}}, + {"id": "browser", "name": "Browser Preview", "type": "shape", + "content": {"type": "rectangle", "color": "#45475a"}, + "start": 8, "end": 18, + "transform": {"x": 1400, "y": 540, "width": 800, "height": 900}}, + {"id": "browser_url", "name": "Browser URL", "type": "text", + "content": {"text": "{browser_url}", "font_size": 12, "color": "#a6adc8"}, + "start": 8, "end": 18, + "transform": {"x": 1400, "y": 120}}, + ], +)) + +# ---- Product Demo Template ---- +_register(PromoTemplate( + name="product_demo", + category="demo", + width=1920, height=1080, + duration=30.0, + description="Product demo with simulated typing, UI transitions, and feature callouts", + variables={"product_name": "MyApp", "feature_1": "Feature 1", + "feature_2": "Feature 2", "feature_3": "Feature 3", + "url": "https://myapp.com"}, + layers_config=[ + {"id": "bg", "name": "Background", "type": "gradient", + "content": {"colors": ["#020617", "#0f172a"], "direction": "180deg"}, + "transform": {"width": 1920, "height": 1080}}, + # Intro + {"id": "intro_title", "name": "Product Name", "type": "text", + "content": {"text": "{product_name}", "font_size": 96, "color": "#f8fafc", + "font_weight": 800}, + "start": 0, "end": 5, + "transform": {"x": 960, "y": 480}}, + {"id": "intro_sub", "name": "See it in action", "type": "text", + "content": {"text": "See it in action →", "font_size": 28, "color": "#94a3b8"}, + "start": 2, "end": 5, + "transform": {"x": 960, "y": 580}}, + # Feature demos + {"id": "f1_title", "name": "Feature 1", "type": "text", + "content": {"text": "{feature_1}", "font_size": 48, "color": "#f8fafc", + "font_weight": 700}, + "start": 5, "end": 12, + "transform": {"x": 300, "y": 100}}, + {"id": "f1_demo", "name": "Feature 1 Demo", "type": "device_frame", + "content": {"device": "browser"}, + "start": 6, "end": 12, + "transform": {"x": 960, "y": 580, "scale_x": 0.8, "scale_y": 0.8}}, + {"id": "f2_title", "name": "Feature 2", "type": "text", + "content": {"text": "{feature_2}", "font_size": 48, "color": "#f8fafc", + "font_weight": 700}, + "start": 12, "end": 19, + "transform": {"x": 300, "y": 100}}, + {"id": "f2_demo", "name": "Feature 2 Demo", "type": "device_frame", + "content": {"device": "browser"}, + "start": 13, "end": 19, + "transform": {"x": 960, "y": 580, "scale_x": 0.8, "scale_y": 0.8}}, + {"id": "f3_title", "name": "Feature 3", "type": "text", + "content": {"text": "{feature_3}", "font_size": 48, "color": "#f8fafc", + "font_weight": 700}, + "start": 19, "end": 26, + "transform": {"x": 300, "y": 100}}, + {"id": "f3_demo", "name": "Feature 3 Demo", "type": "device_frame", + "content": {"device": "browser"}, + "start": 20, "end": 26, + "transform": {"x": 960, "y": 580, "scale_x": 0.8, "scale_y": 0.8}}, + # CTA + {"id": "cta_text", "name": "CTA", "type": "text", + "content": {"text": "Try it now", "font_size": 56, "color": "#f8fafc", + "font_weight": 800}, + "start": 26, "end": 30, + "transform": {"x": 960, "y": 440}}, + {"id": "cta_url", "name": "URL", "type": "text", + "content": {"text": "{url}", "font_size": 32, "color": "#3b82f6", + "font_weight": 600}, + "start": 26, "end": 30, + "transform": {"x": 960, "y": 540}}, + ], +)) diff --git a/eostudio/formats/__pycache__/__init__.cpython-38.pyc b/eostudio/formats/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..f1912ff Binary files /dev/null and b/eostudio/formats/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/dxf.cpython-38.pyc b/eostudio/formats/__pycache__/dxf.cpython-38.pyc new file mode 100644 index 0000000..7920986 Binary files /dev/null and b/eostudio/formats/__pycache__/dxf.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/gltf.cpython-38.pyc b/eostudio/formats/__pycache__/gltf.cpython-38.pyc new file mode 100644 index 0000000..34df0b8 Binary files /dev/null and b/eostudio/formats/__pycache__/gltf.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/obj.cpython-38.pyc b/eostudio/formats/__pycache__/obj.cpython-38.pyc new file mode 100644 index 0000000..f1ebf42 Binary files /dev/null and b/eostudio/formats/__pycache__/obj.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/project.cpython-38.pyc b/eostudio/formats/__pycache__/project.cpython-38.pyc new file mode 100644 index 0000000..d121152 Binary files /dev/null and b/eostudio/formats/__pycache__/project.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/stl.cpython-38.pyc b/eostudio/formats/__pycache__/stl.cpython-38.pyc new file mode 100644 index 0000000..3a8d0af Binary files /dev/null and b/eostudio/formats/__pycache__/stl.cpython-38.pyc differ diff --git a/eostudio/formats/__pycache__/svg.cpython-38.pyc b/eostudio/formats/__pycache__/svg.cpython-38.pyc new file mode 100644 index 0000000..52c5e68 Binary files /dev/null and b/eostudio/formats/__pycache__/svg.cpython-38.pyc differ diff --git a/eostudio/gui/__pycache__/__init__.cpython-38.pyc b/eostudio/gui/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..ab72e68 Binary files /dev/null and b/eostudio/gui/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/gui/__pycache__/app.cpython-38.pyc b/eostudio/gui/__pycache__/app.cpython-38.pyc new file mode 100644 index 0000000..10fa88c Binary files /dev/null and b/eostudio/gui/__pycache__/app.cpython-38.pyc differ diff --git a/eostudio/gui/dialogs/__pycache__/__init__.cpython-38.pyc b/eostudio/gui/dialogs/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..151acf2 Binary files /dev/null and b/eostudio/gui/dialogs/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/gui/dialogs/__pycache__/ai_chat.cpython-38.pyc b/eostudio/gui/dialogs/__pycache__/ai_chat.cpython-38.pyc new file mode 100644 index 0000000..8b6e1bb Binary files /dev/null and b/eostudio/gui/dialogs/__pycache__/ai_chat.cpython-38.pyc differ diff --git a/eostudio/gui/dialogs/__pycache__/design_system_dialog.cpython-38.pyc b/eostudio/gui/dialogs/__pycache__/design_system_dialog.cpython-38.pyc new file mode 100644 index 0000000..df03a71 Binary files /dev/null and b/eostudio/gui/dialogs/__pycache__/design_system_dialog.cpython-38.pyc differ diff --git a/eostudio/gui/dialogs/__pycache__/export_dialog.cpython-38.pyc b/eostudio/gui/dialogs/__pycache__/export_dialog.cpython-38.pyc new file mode 100644 index 0000000..1cb8c39 Binary files /dev/null and b/eostudio/gui/dialogs/__pycache__/export_dialog.cpython-38.pyc differ diff --git a/eostudio/gui/dialogs/__pycache__/settings_dialog.cpython-38.pyc b/eostudio/gui/dialogs/__pycache__/settings_dialog.cpython-38.pyc new file mode 100644 index 0000000..cd3f7b7 Binary files /dev/null and b/eostudio/gui/dialogs/__pycache__/settings_dialog.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/__init__.cpython-38.pyc b/eostudio/gui/editors/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..827db89 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/cad_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/cad_editor.cpython-38.pyc new file mode 100644 index 0000000..241578d Binary files /dev/null and b/eostudio/gui/editors/__pycache__/cad_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/database_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/database_editor.cpython-38.pyc new file mode 100644 index 0000000..4de40ad Binary files /dev/null and b/eostudio/gui/editors/__pycache__/database_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/game_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/game_editor.cpython-38.pyc new file mode 100644 index 0000000..b1e7b55 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/game_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/hardware_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/hardware_editor.cpython-38.pyc new file mode 100644 index 0000000..0e992ee Binary files /dev/null and b/eostudio/gui/editors/__pycache__/hardware_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/ide_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/ide_editor.cpython-38.pyc new file mode 100644 index 0000000..871ef04 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/ide_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/image_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/image_editor.cpython-38.pyc new file mode 100644 index 0000000..2f33834 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/image_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/interior_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/interior_editor.cpython-38.pyc new file mode 100644 index 0000000..48b483a Binary files /dev/null and b/eostudio/gui/editors/__pycache__/interior_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/modeler_3d.cpython-38.pyc b/eostudio/gui/editors/__pycache__/modeler_3d.cpython-38.pyc new file mode 100644 index 0000000..a64ee47 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/modeler_3d.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/product_designer.cpython-38.pyc b/eostudio/gui/editors/__pycache__/product_designer.cpython-38.pyc new file mode 100644 index 0000000..7bd8a91 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/product_designer.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/promo_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/promo_editor.cpython-38.pyc new file mode 100644 index 0000000..9e511a7 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/promo_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/simulation_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/simulation_editor.cpython-38.pyc new file mode 100644 index 0000000..ee98a18 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/simulation_editor.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/ui_designer.cpython-38.pyc b/eostudio/gui/editors/__pycache__/ui_designer.cpython-38.pyc new file mode 100644 index 0000000..a8e602b Binary files /dev/null and b/eostudio/gui/editors/__pycache__/ui_designer.cpython-38.pyc differ diff --git a/eostudio/gui/editors/__pycache__/uml_editor.cpython-38.pyc b/eostudio/gui/editors/__pycache__/uml_editor.cpython-38.pyc new file mode 100644 index 0000000..38d5ff9 Binary files /dev/null and b/eostudio/gui/editors/__pycache__/uml_editor.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/__init__.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..8bd0bc1 Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/canvas_2d.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/canvas_2d.cpython-38.pyc new file mode 100644 index 0000000..874ed4e Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/canvas_2d.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/color_picker.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/color_picker.cpython-38.pyc new file mode 100644 index 0000000..37f2e6b Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/color_picker.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/hierarchy.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/hierarchy.cpython-38.pyc new file mode 100644 index 0000000..fda7b13 Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/hierarchy.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/layers_panel.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/layers_panel.cpython-38.pyc new file mode 100644 index 0000000..4862e16 Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/layers_panel.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/properties.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/properties.cpython-38.pyc new file mode 100644 index 0000000..b7ed428 Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/properties.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/timeline.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/timeline.cpython-38.pyc new file mode 100644 index 0000000..32c36af Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/timeline.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/toolbar.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/toolbar.cpython-38.pyc new file mode 100644 index 0000000..04925dc Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/toolbar.cpython-38.pyc differ diff --git a/eostudio/gui/widgets/__pycache__/viewport_3d.cpython-38.pyc b/eostudio/gui/widgets/__pycache__/viewport_3d.cpython-38.pyc new file mode 100644 index 0000000..0cd2b8e Binary files /dev/null and b/eostudio/gui/widgets/__pycache__/viewport_3d.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/__init__.cpython-38.pyc b/eostudio/platform/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..bf41b08 Binary files /dev/null and b/eostudio/platform/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/display_backend.cpython-38.pyc b/eostudio/platform/__pycache__/display_backend.cpython-38.pyc new file mode 100644 index 0000000..67c0cfc Binary files /dev/null and b/eostudio/platform/__pycache__/display_backend.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/electron_backend.cpython-38.pyc b/eostudio/platform/__pycache__/electron_backend.cpython-38.pyc new file mode 100644 index 0000000..8772eaf Binary files /dev/null and b/eostudio/platform/__pycache__/electron_backend.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/eos_display.cpython-38.pyc b/eostudio/platform/__pycache__/eos_display.cpython-38.pyc new file mode 100644 index 0000000..cff62c9 Binary files /dev/null and b/eostudio/platform/__pycache__/eos_display.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/macos_backend.cpython-38.pyc b/eostudio/platform/__pycache__/macos_backend.cpython-38.pyc new file mode 100644 index 0000000..ff965d7 Binary files /dev/null and b/eostudio/platform/__pycache__/macos_backend.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/pwa_backend.cpython-38.pyc b/eostudio/platform/__pycache__/pwa_backend.cpython-38.pyc new file mode 100644 index 0000000..6989e39 Binary files /dev/null and b/eostudio/platform/__pycache__/pwa_backend.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/responsive.cpython-38.pyc b/eostudio/platform/__pycache__/responsive.cpython-38.pyc new file mode 100644 index 0000000..6ba9ae1 Binary files /dev/null and b/eostudio/platform/__pycache__/responsive.cpython-38.pyc differ diff --git a/eostudio/platform/__pycache__/tkinter_backend.cpython-38.pyc b/eostudio/platform/__pycache__/tkinter_backend.cpython-38.pyc new file mode 100644 index 0000000..f584433 Binary files /dev/null and b/eostudio/platform/__pycache__/tkinter_backend.cpython-38.pyc differ diff --git a/eostudio/platform/electron_backend.py b/eostudio/platform/electron_backend.py new file mode 100755 index 0000000..c8a69d7 --- /dev/null +++ b/eostudio/platform/electron_backend.py @@ -0,0 +1,243 @@ +""" +EoStudio Electron Backend — Electron/Node.js display backend. + +Phase 3: Cross-Platform Universal Support. +""" +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from eostudio.platform.display_backend import ( + DisplayBackend, + EventType, + InputEvent, + WindowConfig, +) + + +# --------------------------------------------------------------------------- +# Configuration dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class ElectronConfig: + """Configuration for the Electron runtime.""" + + node_path: str = "node" + electron_path: str = "electron" + dev_mode: bool = True + auto_update_url: str = "" + + +@dataclass +class NativeMenuConfig: + """Configuration for native application menus.""" + + label: str = "" + items: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict: + return {"label": self.label, "items": self.items} + + +@dataclass +class SystemTrayConfig: + """Configuration for the system tray icon and menu.""" + + icon_path: str = "" + tooltip: str = "" + menu_items: List[Dict[str, str]] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "iconPath": self.icon_path, + "tooltip": self.tooltip, + "menuItems": self.menu_items, + } + + +@dataclass +class NotificationConfig: + """Configuration for native desktop notifications.""" + + title: str = "" + body: str = "" + icon: str = "" + silent: bool = False + urgency: str = "normal" # low | normal | critical + + def to_dict(self) -> dict: + return { + "title": self.title, + "body": self.body, + "icon": self.icon, + "silent": self.silent, + "urgency": self.urgency, + } + + +@dataclass +class AutoUpdateConfig: + """Configuration for Electron auto-update (electron-updater).""" + + feed_url: str = "" + channel: str = "latest" + auto_download: bool = True + auto_install_on_quit: bool = True + + def to_dict(self) -> dict: + return { + "feedUrl": self.feed_url, + "channel": self.channel, + "autoDownload": self.auto_download, + "autoInstallOnAppQuit": self.auto_install_on_quit, + } + + +# --------------------------------------------------------------------------- +# ElectronBridge — IPC protocol between Python ↔ Node.js/Electron +# --------------------------------------------------------------------------- + +class ElectronBridge: + """Manages the IPC channel between the Python core and the Electron renderer.""" + + def __init__(self, config: ElectronConfig) -> None: + self._config = config + self._process: Optional[subprocess.Popen] = None + + def start(self) -> bool: + """Launch the Electron process.""" + try: + env = os.environ.copy() + env["EOSTUDIO_IPC"] = "1" + self._process = subprocess.Popen( + [self._config.electron_path, "."], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return True + except FileNotFoundError: + return False + + def stop(self) -> None: + """Terminate the Electron process.""" + if self._process is not None: + self._process.terminate() + self._process = None + + def send(self, channel: str, data: Any) -> None: + """Send a JSON message to the Electron renderer via stdin IPC.""" + if self._process and self._process.stdin: + msg = json.dumps({"channel": channel, "data": data}) + "\n" + self._process.stdin.write(msg.encode()) + self._process.stdin.flush() + + def receive(self) -> Optional[dict]: + """Read a single JSON message from the Electron renderer via stdout.""" + if self._process and self._process.stdout: + line = self._process.stdout.readline() + if line: + try: + return json.loads(line) + except json.JSONDecodeError: + return None + return None + + @property + def is_running(self) -> bool: + return self._process is not None and self._process.poll() is None + + +# --------------------------------------------------------------------------- +# ElectronBackend +# --------------------------------------------------------------------------- + +class ElectronBackend(DisplayBackend): + """Display backend that renders the UI via an Electron shell.""" + + def __init__(self, electron_config: Optional[ElectronConfig] = None) -> None: + self._config = electron_config or ElectronConfig() + self._bridge = ElectronBridge(self._config) + self._menus: List[NativeMenuConfig] = [] + self._tray: Optional[SystemTrayConfig] = None + self._auto_update: Optional[AutoUpdateConfig] = None + + # -- DisplayBackend interface ------------------------------------------- + + def initialize(self) -> bool: + """Start the Electron process and establish IPC.""" + return self._bridge.start() + + def create_window(self, config: WindowConfig) -> bool: + """Ask Electron to create a BrowserWindow.""" + self._bridge.send("create-window", { + "title": config.title, + "width": config.width, + "height": config.height, + }) + return True + + def destroy_window(self) -> None: + """Close the Electron window.""" + self._bridge.send("close-window", {}) + + def poll_events(self) -> List[InputEvent]: + """Read pending input events from Electron.""" + events: List[InputEvent] = [] + msg = self._bridge.receive() + while msg is not None: + if msg.get("channel") == "input-event": + data = msg.get("data", {}) + events.append(InputEvent( + type=EventType(data.get("type", "unknown")), + data=data, + )) + msg = self._bridge.receive() + return events + + def render(self, scene: Any) -> None: + """Send a scene payload to Electron for rendering.""" + self._bridge.send("render", scene) + + def shutdown(self) -> None: + """Shut down the Electron process.""" + self._bridge.stop() + + # -- Electron-specific features ----------------------------------------- + + def set_menu(self, menus: List[NativeMenuConfig]) -> None: + """Configure native application menus.""" + self._menus = menus + self._bridge.send("set-menu", [m.to_dict() for m in menus]) + + def set_tray(self, tray: SystemTrayConfig) -> None: + """Configure the system tray.""" + self._tray = tray + self._bridge.send("set-tray", tray.to_dict()) + + def show_notification(self, notification: NotificationConfig) -> None: + """Show a native desktop notification.""" + self._bridge.send("notification", notification.to_dict()) + + def configure_auto_update(self, config: AutoUpdateConfig) -> None: + """Configure Electron auto-update settings.""" + self._auto_update = config + self._bridge.send("auto-update", config.to_dict()) + + def check_for_updates(self) -> None: + """Trigger an update check.""" + self._bridge.send("check-updates", {}) + + def get_electron_version(self) -> Optional[str]: + """Query the running Electron version.""" + self._bridge.send("get-version", {}) + resp = self._bridge.receive() + if resp and resp.get("channel") == "version": + return resp.get("data", {}).get("electron") + return None diff --git a/eostudio/platform/pwa_backend.py b/eostudio/platform/pwa_backend.py new file mode 100755 index 0000000..d9742f8 --- /dev/null +++ b/eostudio/platform/pwa_backend.py @@ -0,0 +1,234 @@ +""" +EoStudio PWA Backend — Progressive Web App display backend. + +Phase 3: Cross-Platform Universal Support. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from eostudio.platform.display_backend import ( + DisplayBackend, + EventType, + InputEvent, + WindowConfig, +) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +@dataclass +class PWAIcon: + """A single icon entry for the PWA manifest.""" + + src: str + sizes: str # e.g. "192x192" + type: str = "image/png" + purpose: str = "any maskable" + + def to_dict(self) -> dict: + return { + "src": self.src, + "sizes": self.sizes, + "type": self.type, + "purpose": self.purpose, + } + + +@dataclass +class PWAConfig: + """Configuration for the Progressive Web App manifest and service worker.""" + + app_name: str = "EoStudio" + short_name: str = "EoStudio" + theme_color: str = "#1a1a2e" + background_color: str = "#0f0f1a" + display: str = "standalone" # fullscreen | standalone | minimal-ui | browser + start_url: str = "/" + scope: str = "/" + orientation: str = "any" + icons: List[PWAIcon] = field(default_factory=lambda: [ + PWAIcon(src="/icons/icon-192.png", sizes="192x192"), + PWAIcon(src="/icons/icon-512.png", sizes="512x512"), + ]) + categories: List[str] = field(default_factory=lambda: ["development", "productivity"]) + description: str = "EoStudio — the universal code editor" + cache_name: str = "eostudio-v1" + precache_urls: List[str] = field(default_factory=lambda: [ + "/", + "/index.html", + "/app.js", + "/app.css", + ]) + offline_fallback: str = "/offline.html" + + +# --------------------------------------------------------------------------- +# Manifest & Service Worker generators +# --------------------------------------------------------------------------- + +def generate_manifest(config: Optional[PWAConfig] = None) -> dict: + """Generate a W3C Web App Manifest dict from *config*.""" + cfg = config or PWAConfig() + return { + "name": cfg.app_name, + "short_name": cfg.short_name, + "start_url": cfg.start_url, + "scope": cfg.scope, + "display": cfg.display, + "orientation": cfg.orientation, + "theme_color": cfg.theme_color, + "background_color": cfg.background_color, + "description": cfg.description, + "categories": cfg.categories, + "icons": [icon.to_dict() for icon in cfg.icons], + } + + +def generate_service_worker(config: Optional[PWAConfig] = None) -> str: + """Generate a service-worker JavaScript source string.""" + cfg = config or PWAConfig() + precache = json.dumps(cfg.precache_urls, indent=2) + return f"""\ +// EoStudio Service Worker — auto-generated +const CACHE_NAME = "{cfg.cache_name}"; +const PRECACHE_URLS = {precache}; + +// Install: precache core assets +self.addEventListener("install", (event) => {{ + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}}); + +// Activate: clean old caches +self.addEventListener("activate", (event) => {{ + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}}); + +// Fetch: cache-first, falling back to network, then offline page +self.addEventListener("fetch", (event) => {{ + if (event.request.method !== "GET") return; + + event.respondWith( + caches.match(event.request).then((cached) => {{ + if (cached) return cached; + + return fetch(event.request) + .then((response) => {{ + if (!response || response.status !== 200 || response.type !== "basic") {{ + return response; + }} + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + return response; + }}) + .catch(() => caches.match("{cfg.offline_fallback}")); + }}) + ); +}}); +""" + + +def generate_registration_script() -> str: + """Generate the JS snippet that registers the service worker.""" + return """\ +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/service-worker.js") + .then((reg) => console.log("SW registered:", reg.scope)) + .catch((err) => console.error("SW registration failed:", err)); + }); +} +""" + + +# --------------------------------------------------------------------------- +# PWABackend +# --------------------------------------------------------------------------- + +class PWABackend(DisplayBackend): + """Display backend that serves the UI as a Progressive Web App. + + In practice the Python process runs an HTTP server that delivers the + PWA shell (manifest, service worker, HTML/JS/CSS). The actual + rendering happens in the user's browser. + """ + + def __init__(self, pwa_config: Optional[PWAConfig] = None) -> None: + self._config = pwa_config or PWAConfig() + self._running = False + self._events: List[InputEvent] = [] + + # -- DisplayBackend interface ------------------------------------------- + + def initialize(self) -> bool: + """Prepare the PWA assets (manifest, service worker).""" + self._manifest = generate_manifest(self._config) + self._sw_source = generate_service_worker(self._config) + self._reg_script = generate_registration_script() + self._running = True + return True + + def create_window(self, config: WindowConfig) -> bool: + """In PWA mode, 'creating a window' means starting the HTTP server.""" + # The HTTP server would be started here in a real implementation. + return self._running + + def destroy_window(self) -> None: + """Stop serving.""" + self._running = False + + def poll_events(self) -> List[InputEvent]: + """Return and clear buffered input events received via WebSocket/SSE.""" + events = list(self._events) + self._events.clear() + return events + + def render(self, scene: Any) -> None: + """Push a scene update to connected browser clients.""" + # In a real implementation this would broadcast via WebSocket. + pass + + def shutdown(self) -> None: + """Tear down the PWA backend.""" + self._running = False + + # -- PWA-specific API --------------------------------------------------- + + def get_manifest(self) -> dict: + """Return the generated Web App Manifest.""" + return generate_manifest(self._config) + + def get_manifest_json(self) -> str: + """Return the manifest as a JSON string.""" + return json.dumps(self.get_manifest(), indent=2) + + def get_service_worker(self) -> str: + """Return the generated service-worker source.""" + return generate_service_worker(self._config) + + def get_registration_script(self) -> str: + """Return the SW registration JS snippet.""" + return generate_registration_script() + + def inject_event(self, event: InputEvent) -> None: + """Buffer an input event (called by the WebSocket handler).""" + self._events.append(event) + + @property + def is_running(self) -> bool: + return self._running diff --git a/eostudio/plugins/__pycache__/__init__.cpython-38.pyc b/eostudio/plugins/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..41528aa Binary files /dev/null and b/eostudio/plugins/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/plugins/__pycache__/eoffice_plugin.cpython-38.pyc b/eostudio/plugins/__pycache__/eoffice_plugin.cpython-38.pyc new file mode 100644 index 0000000..f2d2257 Binary files /dev/null and b/eostudio/plugins/__pycache__/eoffice_plugin.cpython-38.pyc differ diff --git a/eostudio/plugins/__pycache__/eosim_plugin.cpython-38.pyc b/eostudio/plugins/__pycache__/eosim_plugin.cpython-38.pyc new file mode 100644 index 0000000..b93a006 Binary files /dev/null and b/eostudio/plugins/__pycache__/eosim_plugin.cpython-38.pyc differ diff --git a/eostudio/plugins/__pycache__/plugin_base.cpython-38.pyc b/eostudio/plugins/__pycache__/plugin_base.cpython-38.pyc new file mode 100644 index 0000000..3134cd4 Binary files /dev/null and b/eostudio/plugins/__pycache__/plugin_base.cpython-38.pyc differ diff --git a/eostudio/promo/__init__.py b/eostudio/promo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eostudio/promo/__pycache__/__init__.cpython-38.pyc b/eostudio/promo/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..d84ad53 Binary files /dev/null and b/eostudio/promo/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/promo/templates/__init__.py b/eostudio/promo/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eostudio/promo/templates/__pycache__/__init__.cpython-38.pyc b/eostudio/promo/templates/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..19f4044 Binary files /dev/null and b/eostudio/promo/templates/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/promo/templates/__pycache__/demo_template.cpython-38.pyc b/eostudio/promo/templates/__pycache__/demo_template.cpython-38.pyc new file mode 100644 index 0000000..ea98001 Binary files /dev/null and b/eostudio/promo/templates/__pycache__/demo_template.cpython-38.pyc differ diff --git a/eostudio/promo/templates/demo_template.py b/eostudio/promo/templates/demo_template.py new file mode 100644 index 0000000..1100b96 --- /dev/null +++ b/eostudio/promo/templates/demo_template.py @@ -0,0 +1,204 @@ +"""Demo video template — Manim-based product demo with typing, transitions, and subtitles.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class TypingSequence: + """A simulated typing sequence for demo videos.""" + text: str + start_time: float + typing_speed: float = 0.05 # seconds per character + cursor_blink: bool = True + + +@dataclass +class UITransition: + """A UI state transition for demo videos.""" + from_state: str + to_state: str + start_time: float + duration: float = 0.5 + effect: str = "fade" # "fade", "slide_left", "slide_right", "zoom" + + +@dataclass +class DemoScene: + """A single scene in a product demo video.""" + title: str + duration: float + description: str = "" + typing_sequences: List[TypingSequence] = field(default_factory=list) + transitions: List[UITransition] = field(default_factory=list) + narration: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "title": self.title, "duration": self.duration, + "description": self.description, "narration": self.narration, + "typing_count": len(self.typing_sequences), + "transition_count": len(self.transitions), + } + + +@dataclass +class DemoTemplate: + """Complete product demo video template with scenes, typing, and transitions. + + Generates Manim scene code for rendering product demo videos with: + - Simulated IDE typing effects + - UI state transitions + - Feature callout overlays + - Subtitle/narration support + """ + product_name: str + scenes: List[DemoScene] = field(default_factory=list) + width: int = 1920 + height: int = 1080 + fps: int = 30 + background_color: str = "#0f172a" + accent_color: str = "#3b82f6" + + def add_scene(self, title: str, duration: float, **kwargs: Any) -> DemoScene: + """Add a scene to the demo.""" + scene = DemoScene(title=title, duration=duration, **kwargs) + self.scenes.append(scene) + return scene + + @property + def total_duration(self) -> float: + return sum(s.duration for s in self.scenes) + + def to_manim_script(self) -> str: + """Generate a Manim Python script for the demo video.""" + lines = [ + "from manim import *", + "", + "", + f"class {self._class_name}(Scene):", + f' """Product demo for {self.product_name}."""', + "", + " def construct(self):", + f' self.camera.background_color = "{self.background_color}"', + "", + ] + + for i, scene in enumerate(self.scenes): + lines.append(f" # --- Scene {i+1}: {scene.title} ---") + + if i == 0: + # Intro: Product name reveal + lines.extend([ + f' title = Text("{self.product_name}", font_size=72, color=WHITE)', + f" title.set_weight(BOLD)", + f" self.play(Write(title), run_time=1.5)", + ]) + if scene.description: + lines.extend([ + f' subtitle = Text("{scene.description}", font_size=28, color=GRAY)', + f" subtitle.next_to(title, DOWN, buff=0.5)", + f" self.play(FadeIn(subtitle), run_time=0.8)", + ]) + lines.append(f" self.wait({scene.duration - 2.5})") + lines.append(f" self.play(FadeOut(title), FadeOut(subtitle) if 'subtitle' in dir() else Wait(0))") + else: + # Feature scene with title + lines.extend([ + f' scene_title = Text("{scene.title}", font_size=48, color=WHITE)', + f" scene_title.set_weight(BOLD)", + f" scene_title.to_edge(UP, buff=0.5)", + f" self.play(FadeIn(scene_title), run_time=0.5)", + ]) + + # Add typing sequences + for j, ts in enumerate(scene.typing_sequences): + lines.extend([ + f' code_{j} = Code(', + f' code="""{ts.text}""",', + f' language="typescript",', + f' font_size=16,', + f' background="rectangle",', + f' background_stroke_color="{self.accent_color}",', + f" )", + f" self.play(Create(code_{j}), run_time={len(ts.text) * ts.typing_speed})", + ]) + + # Add transitions + for t in scene.transitions: + effect_fn = { + "fade": "FadeIn", + "slide_left": "FadeIn", + "slide_right": "FadeIn", + "zoom": "GrowFromCenter", + }.get(t.effect, "FadeIn") + lines.extend([ + f' transition_text = Text("{t.to_state}", font_size=24, color=GRAY)', + f" self.play({effect_fn}(transition_text), run_time={t.duration})", + ]) + + remaining = scene.duration - 1.0 + if remaining > 0: + lines.append(f" self.wait({remaining:.1f})") + lines.append(f" self.clear()") + + lines.append("") + + # Final CTA + lines.extend([ + f" # --- Final CTA ---", + f' cta = Text("Try it now", font_size=56, color=WHITE)', + f" cta.set_weight(BOLD)", + f" self.play(Write(cta), run_time=1.0)", + f" self.wait(2)", + ]) + + return "\n".join(lines) + "\n" + + def to_dict(self) -> Dict[str, Any]: + return { + "product_name": self.product_name, + "total_duration": self.total_duration, + "scene_count": len(self.scenes), + "scenes": [s.to_dict() for s in self.scenes], + "dimensions": f"{self.width}x{self.height}", + "fps": self.fps, + } + + @property + def _class_name(self) -> str: + return "".join(w.capitalize() for w in self.product_name.split()) + "Demo" + + +def create_quick_demo(product_name: str, features: List[str], + url: str = "") -> DemoTemplate: + """Create a quick product demo template from a product name and feature list.""" + demo = DemoTemplate(product_name=product_name) + + # Intro scene + demo.add_scene( + title=product_name, + duration=5.0, + description=f"Introducing {product_name}", + ) + + # Feature scenes + for feat in features: + scene = demo.add_scene(title=feat, duration=8.0) + scene.typing_sequences.append( + TypingSequence( + text=f"// {feat} implementation\nconst {feat.lower().replace(' ', '_')} = new Feature();", + start_time=1.0, + ) + ) + scene.transitions.append( + UITransition(from_state="code", to_state="preview", start_time=4.0) + ) + + # CTA scene + if url: + demo.add_scene(title="Get Started", duration=4.0, description=url) + + return demo diff --git a/eostudio/templates/__pycache__/__init__.cpython-38.pyc b/eostudio/templates/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..4292947 Binary files /dev/null and b/eostudio/templates/__pycache__/__init__.cpython-38.pyc differ diff --git a/eostudio/templates/__pycache__/samples.cpython-38.pyc b/eostudio/templates/__pycache__/samples.cpython-38.pyc new file mode 100644 index 0000000..b37f811 Binary files /dev/null and b/eostudio/templates/__pycache__/samples.cpython-38.pyc differ diff --git a/promo/_test_tts2.py b/promo/_test_tts2.py new file mode 100644 index 0000000..bf9bbb7 --- /dev/null +++ b/promo/_test_tts2.py @@ -0,0 +1,7 @@ +import os +os.environ["LD_LIBRARY_PATH"] = "/home/spatchava/miniconda3/envs/tts/lib:" + os.environ.get("LD_LIBRARY_PATH", "") +try: + from TTS.api import TTS + print("TTS_OK") +except Exception as e: + print(f"ERR: {e}") diff --git a/promo/generate_audio.py b/promo/generate_audio.py new file mode 100644 index 0000000..46ee1dc --- /dev/null +++ b/promo/generate_audio.py @@ -0,0 +1,54 @@ +"""Generate per-segment narration using edge-tts (US English neural voice).""" +import asyncio +import json +import edge_tts +from mutagen.mp3 import MP3 + +# en-US-GuyNeural = neutral male US voice (Silicon Valley style) +VOICE = "en-US-GuyNeural" +RATE = "+0%" # natural pace + +SEGMENTS = [ + {"id": "intro", "text": "Introducing EoStudio. A cross-platform design suite powered by AI."}, + {"id": "f1", "text": "Feature one. AI Code Generation. Natural language prompts generate production-ready TypeScript and Python code."}, + {"id": "f2", "text": "Feature two. Spec-Driven Development. Auto-generates requirements, design specs, tech specs, and task breakdowns."}, + {"id": "f3", "text": "Feature three. Production UI Kit. 39 accessible React components with responsive variants and Framer Motion animations."}, + {"id": "arch", "text": "Under the hood, EoStudio is built with Python, React, and TypeScript. The architecture flows from LLM Client, to Spec Engine, to Code Gen, to UI Kit, to Deploy."}, + {"id": "cta", "text": "EoStudio. Open source and AI powered. Visit github dot com slash embeddedos-org slash EoStudio."} +] + + +async def generate(): + durations = {} + audio_files = [] + + for seg in SEGMENTS: + filename = f"seg_{seg['id']}.mp3" + communicate = edge_tts.Communicate(seg["text"], VOICE, rate=RATE) + await communicate.save(filename) + dur = MP3(filename).info.length + durations[seg["id"]] = round(dur + 0.5, 1) + audio_files.append(filename) + print(f" {seg['id']}: {dur:.1f}s -> padded {durations[seg['id']]}s") + + with open("durations.json", "w") as f: + json.dump(durations, f, indent=2) + + # Concatenate + import subprocess + with open("concat_list.txt", "w") as f: + for af in audio_files: + f.write(f"file '{af}'\n") + + subprocess.run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", + "-i", "concat_list.txt", "-c", "copy", "narration.mp3" + ], check=True) + + total = sum(durations.values()) + print(f"\nVoice: {VOICE}") + print(f"Total narration: {total:.1f}s") + print(f"Durations: {json.dumps(durations)}") + + +asyncio.run(generate()) diff --git a/promo/narrated_cloned/EoStudio_VoiceCloned_1080p.mp4 b/promo/narrated_cloned/EoStudio_VoiceCloned_1080p.mp4 new file mode 100644 index 0000000..99c6d52 Binary files /dev/null and b/promo/narrated_cloned/EoStudio_VoiceCloned_1080p.mp4 differ diff --git a/promo/narrated_cloned/list.txt b/promo/narrated_cloned/list.txt new file mode 100644 index 0000000..8948538 --- /dev/null +++ b/promo/narrated_cloned/list.txt @@ -0,0 +1,13 @@ +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_00.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_01.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_02.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_03.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_04.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_05.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned/seg_06.wav' diff --git a/promo/narrated_cloned/narration.mp3 b/promo/narrated_cloned/narration.mp3 new file mode 100644 index 0000000..3c406db Binary files /dev/null and b/promo/narrated_cloned/narration.mp3 differ diff --git a/promo/narrated_cloned/narration.wav b/promo/narrated_cloned/narration.wav new file mode 100644 index 0000000..e0b333b Binary files /dev/null and b/promo/narrated_cloned/narration.wav differ diff --git a/promo/narrated_cloned/seg_00.wav b/promo/narrated_cloned/seg_00.wav new file mode 100644 index 0000000..5b03f5d Binary files /dev/null and b/promo/narrated_cloned/seg_00.wav differ diff --git a/promo/narrated_cloned/seg_01.wav b/promo/narrated_cloned/seg_01.wav new file mode 100644 index 0000000..f11e691 Binary files /dev/null and b/promo/narrated_cloned/seg_01.wav differ diff --git a/promo/narrated_cloned/seg_02.wav b/promo/narrated_cloned/seg_02.wav new file mode 100644 index 0000000..864d0d0 Binary files /dev/null and b/promo/narrated_cloned/seg_02.wav differ diff --git a/promo/narrated_cloned/seg_03.wav b/promo/narrated_cloned/seg_03.wav new file mode 100644 index 0000000..2183b23 Binary files /dev/null and b/promo/narrated_cloned/seg_03.wav differ diff --git a/promo/narrated_cloned/seg_04.wav b/promo/narrated_cloned/seg_04.wav new file mode 100644 index 0000000..21731b9 Binary files /dev/null and b/promo/narrated_cloned/seg_04.wav differ diff --git a/promo/narrated_cloned/seg_05.wav b/promo/narrated_cloned/seg_05.wav new file mode 100644 index 0000000..3ca57c0 Binary files /dev/null and b/promo/narrated_cloned/seg_05.wav differ diff --git a/promo/narrated_cloned/seg_06.wav b/promo/narrated_cloned/seg_06.wav new file mode 100644 index 0000000..6cee5d8 Binary files /dev/null and b/promo/narrated_cloned/seg_06.wav differ diff --git a/promo/narrated_cloned/silence.wav b/promo/narrated_cloned/silence.wav new file mode 100644 index 0000000..dc04ab4 Binary files /dev/null and b/promo/narrated_cloned/silence.wav differ diff --git a/promo/narrated_xtts/EoStudio_XTTS_1080p.mp4 b/promo/narrated_xtts/EoStudio_XTTS_1080p.mp4 new file mode 100644 index 0000000..a86493a Binary files /dev/null and b/promo/narrated_xtts/EoStudio_XTTS_1080p.mp4 differ diff --git a/promo/narrated_xtts/list.txt b/promo/narrated_xtts/list.txt new file mode 100644 index 0000000..7031ae9 --- /dev/null +++ b/promo/narrated_xtts/list.txt @@ -0,0 +1,53 @@ +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_00.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_01.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_02.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_03.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_04.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_05.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_06.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_07.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_08.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_09.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_10.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_11.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_12.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_13.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_14.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_15.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_16.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_17.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_18.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_19.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_20.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_21.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_22.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_23.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_24.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_25.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/silence.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/seg_26.wav' diff --git a/promo/narrated_xtts/narration.mp3 b/promo/narrated_xtts/narration.mp3 new file mode 100644 index 0000000..8720358 Binary files /dev/null and b/promo/narrated_xtts/narration.mp3 differ diff --git a/promo/narrated_xtts/narration.wav b/promo/narrated_xtts/narration.wav new file mode 100644 index 0000000..6da5c27 Binary files /dev/null and b/promo/narrated_xtts/narration.wav differ diff --git a/promo/narrated_xtts/reference.wav b/promo/narrated_xtts/reference.wav new file mode 100644 index 0000000..86a6393 Binary files /dev/null and b/promo/narrated_xtts/reference.wav differ diff --git a/promo/narrated_xtts/seg_00.wav b/promo/narrated_xtts/seg_00.wav new file mode 100644 index 0000000..512827b Binary files /dev/null and b/promo/narrated_xtts/seg_00.wav differ diff --git a/promo/narrated_xtts/seg_01.wav b/promo/narrated_xtts/seg_01.wav new file mode 100644 index 0000000..3b84c02 Binary files /dev/null and b/promo/narrated_xtts/seg_01.wav differ diff --git a/promo/narrated_xtts/seg_02.wav b/promo/narrated_xtts/seg_02.wav new file mode 100644 index 0000000..92df0ed Binary files /dev/null and b/promo/narrated_xtts/seg_02.wav differ diff --git a/promo/narrated_xtts/seg_03.wav b/promo/narrated_xtts/seg_03.wav new file mode 100644 index 0000000..8731e51 Binary files /dev/null and b/promo/narrated_xtts/seg_03.wav differ diff --git a/promo/narrated_xtts/seg_04.wav b/promo/narrated_xtts/seg_04.wav new file mode 100644 index 0000000..ee6786d Binary files /dev/null and b/promo/narrated_xtts/seg_04.wav differ diff --git a/promo/narrated_xtts/seg_05.wav b/promo/narrated_xtts/seg_05.wav new file mode 100644 index 0000000..612c1ba Binary files /dev/null and b/promo/narrated_xtts/seg_05.wav differ diff --git a/promo/narrated_xtts/seg_06.wav b/promo/narrated_xtts/seg_06.wav new file mode 100644 index 0000000..c23e6e7 Binary files /dev/null and b/promo/narrated_xtts/seg_06.wav differ diff --git a/promo/narrated_xtts/seg_07.wav b/promo/narrated_xtts/seg_07.wav new file mode 100644 index 0000000..98557b1 Binary files /dev/null and b/promo/narrated_xtts/seg_07.wav differ diff --git a/promo/narrated_xtts/seg_08.wav b/promo/narrated_xtts/seg_08.wav new file mode 100644 index 0000000..f4850ca Binary files /dev/null and b/promo/narrated_xtts/seg_08.wav differ diff --git a/promo/narrated_xtts/seg_09.wav b/promo/narrated_xtts/seg_09.wav new file mode 100644 index 0000000..e0fe608 Binary files /dev/null and b/promo/narrated_xtts/seg_09.wav differ diff --git a/promo/narrated_xtts/seg_10.wav b/promo/narrated_xtts/seg_10.wav new file mode 100644 index 0000000..1ecec86 Binary files /dev/null and b/promo/narrated_xtts/seg_10.wav differ diff --git a/promo/narrated_xtts/seg_11.wav b/promo/narrated_xtts/seg_11.wav new file mode 100644 index 0000000..f17b6c3 Binary files /dev/null and b/promo/narrated_xtts/seg_11.wav differ diff --git a/promo/narrated_xtts/seg_12.wav b/promo/narrated_xtts/seg_12.wav new file mode 100644 index 0000000..899e169 Binary files /dev/null and b/promo/narrated_xtts/seg_12.wav differ diff --git a/promo/narrated_xtts/seg_13.wav b/promo/narrated_xtts/seg_13.wav new file mode 100644 index 0000000..53ac9ed Binary files /dev/null and b/promo/narrated_xtts/seg_13.wav differ diff --git a/promo/narrated_xtts/seg_14.wav b/promo/narrated_xtts/seg_14.wav new file mode 100644 index 0000000..49a4029 Binary files /dev/null and b/promo/narrated_xtts/seg_14.wav differ diff --git a/promo/narrated_xtts/seg_15.wav b/promo/narrated_xtts/seg_15.wav new file mode 100644 index 0000000..55d720a Binary files /dev/null and b/promo/narrated_xtts/seg_15.wav differ diff --git a/promo/narrated_xtts/seg_16.wav b/promo/narrated_xtts/seg_16.wav new file mode 100644 index 0000000..95fb016 Binary files /dev/null and b/promo/narrated_xtts/seg_16.wav differ diff --git a/promo/narrated_xtts/seg_17.wav b/promo/narrated_xtts/seg_17.wav new file mode 100644 index 0000000..816f7e4 Binary files /dev/null and b/promo/narrated_xtts/seg_17.wav differ diff --git a/promo/narrated_xtts/seg_18.wav b/promo/narrated_xtts/seg_18.wav new file mode 100644 index 0000000..c99b581 Binary files /dev/null and b/promo/narrated_xtts/seg_18.wav differ diff --git a/promo/narrated_xtts/seg_19.wav b/promo/narrated_xtts/seg_19.wav new file mode 100644 index 0000000..5c807cc Binary files /dev/null and b/promo/narrated_xtts/seg_19.wav differ diff --git a/promo/narrated_xtts/seg_20.wav b/promo/narrated_xtts/seg_20.wav new file mode 100644 index 0000000..6cdec5d Binary files /dev/null and b/promo/narrated_xtts/seg_20.wav differ diff --git a/promo/narrated_xtts/seg_21.wav b/promo/narrated_xtts/seg_21.wav new file mode 100644 index 0000000..ed4482d Binary files /dev/null and b/promo/narrated_xtts/seg_21.wav differ diff --git a/promo/narrated_xtts/seg_22.wav b/promo/narrated_xtts/seg_22.wav new file mode 100644 index 0000000..ea86cee Binary files /dev/null and b/promo/narrated_xtts/seg_22.wav differ diff --git a/promo/narrated_xtts/seg_23.wav b/promo/narrated_xtts/seg_23.wav new file mode 100644 index 0000000..c0ae15a Binary files /dev/null and b/promo/narrated_xtts/seg_23.wav differ diff --git a/promo/narrated_xtts/seg_24.wav b/promo/narrated_xtts/seg_24.wav new file mode 100644 index 0000000..5e52bbd Binary files /dev/null and b/promo/narrated_xtts/seg_24.wav differ diff --git a/promo/narrated_xtts/seg_25.wav b/promo/narrated_xtts/seg_25.wav new file mode 100644 index 0000000..6ae3dfd Binary files /dev/null and b/promo/narrated_xtts/seg_25.wav differ diff --git a/promo/narrated_xtts/seg_26.wav b/promo/narrated_xtts/seg_26.wav new file mode 100644 index 0000000..f927467 Binary files /dev/null and b/promo/narrated_xtts/seg_26.wav differ diff --git a/promo/narrated_xtts/silence.wav b/promo/narrated_xtts/silence.wav new file mode 100644 index 0000000..7923f07 Binary files /dev/null and b/promo/narrated_xtts/silence.wav differ diff --git a/promo/narrated_xtts2/EoStudio_XTTS_Final_1080p.mp4 b/promo/narrated_xtts2/EoStudio_XTTS_Final_1080p.mp4 new file mode 100644 index 0000000..5e03b97 Binary files /dev/null and b/promo/narrated_xtts2/EoStudio_XTTS_Final_1080p.mp4 differ diff --git a/promo/narrated_xtts2/breath.wav b/promo/narrated_xtts2/breath.wav new file mode 100644 index 0000000..e7a1d86 Binary files /dev/null and b/promo/narrated_xtts2/breath.wav differ diff --git a/promo/narrated_xtts2/faded_00.wav b/promo/narrated_xtts2/faded_00.wav new file mode 100644 index 0000000..bde9e3e Binary files /dev/null and b/promo/narrated_xtts2/faded_00.wav differ diff --git a/promo/narrated_xtts2/faded_01.wav b/promo/narrated_xtts2/faded_01.wav new file mode 100644 index 0000000..e9e17a1 Binary files /dev/null and b/promo/narrated_xtts2/faded_01.wav differ diff --git a/promo/narrated_xtts2/faded_02.wav b/promo/narrated_xtts2/faded_02.wav new file mode 100644 index 0000000..95135f1 Binary files /dev/null and b/promo/narrated_xtts2/faded_02.wav differ diff --git a/promo/narrated_xtts2/faded_03.wav b/promo/narrated_xtts2/faded_03.wav new file mode 100644 index 0000000..de362b8 Binary files /dev/null and b/promo/narrated_xtts2/faded_03.wav differ diff --git a/promo/narrated_xtts2/faded_04.wav b/promo/narrated_xtts2/faded_04.wav new file mode 100644 index 0000000..455f2e7 Binary files /dev/null and b/promo/narrated_xtts2/faded_04.wav differ diff --git a/promo/narrated_xtts2/faded_05.wav b/promo/narrated_xtts2/faded_05.wav new file mode 100644 index 0000000..a3bf871 Binary files /dev/null and b/promo/narrated_xtts2/faded_05.wav differ diff --git a/promo/narrated_xtts2/faded_06.wav b/promo/narrated_xtts2/faded_06.wav new file mode 100644 index 0000000..d0e5a98 Binary files /dev/null and b/promo/narrated_xtts2/faded_06.wav differ diff --git a/promo/narrated_xtts2/faded_07.wav b/promo/narrated_xtts2/faded_07.wav new file mode 100644 index 0000000..db9af4c Binary files /dev/null and b/promo/narrated_xtts2/faded_07.wav differ diff --git a/promo/narrated_xtts2/faded_08.wav b/promo/narrated_xtts2/faded_08.wav new file mode 100644 index 0000000..c5b54f2 Binary files /dev/null and b/promo/narrated_xtts2/faded_08.wav differ diff --git a/promo/narrated_xtts2/faded_09.wav b/promo/narrated_xtts2/faded_09.wav new file mode 100644 index 0000000..52a974f Binary files /dev/null and b/promo/narrated_xtts2/faded_09.wav differ diff --git a/promo/narrated_xtts2/faded_10.wav b/promo/narrated_xtts2/faded_10.wav new file mode 100644 index 0000000..01e77c6 Binary files /dev/null and b/promo/narrated_xtts2/faded_10.wav differ diff --git a/promo/narrated_xtts2/faded_11.wav b/promo/narrated_xtts2/faded_11.wav new file mode 100644 index 0000000..f6fcd59 Binary files /dev/null and b/promo/narrated_xtts2/faded_11.wav differ diff --git a/promo/narrated_xtts2/final_list.txt b/promo/narrated_xtts2/final_list.txt new file mode 100644 index 0000000..acc540f --- /dev/null +++ b/promo/narrated_xtts2/final_list.txt @@ -0,0 +1,23 @@ +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_00.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_01.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_02.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_03.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_04.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_05.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_06.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_07.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_08.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_09.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_10.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/breath.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/faded_11.wav' diff --git a/promo/narrated_xtts2/narration.mp3 b/promo/narrated_xtts2/narration.mp3 new file mode 100644 index 0000000..f547204 Binary files /dev/null and b/promo/narrated_xtts2/narration.mp3 differ diff --git a/promo/narrated_xtts2/narration.wav b/promo/narrated_xtts2/narration.wav new file mode 100644 index 0000000..4353e00 Binary files /dev/null and b/promo/narrated_xtts2/narration.wav differ diff --git a/promo/narrated_xtts2/raw_concat.wav b/promo/narrated_xtts2/raw_concat.wav new file mode 100644 index 0000000..fe87b80 Binary files /dev/null and b/promo/narrated_xtts2/raw_concat.wav differ diff --git a/promo/narrated_xtts2/raw_list.txt b/promo/narrated_xtts2/raw_list.txt new file mode 100644 index 0000000..1a6d83b --- /dev/null +++ b/promo/narrated_xtts2/raw_list.txt @@ -0,0 +1,12 @@ +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_00.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_01.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_02.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_03.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_04.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_05.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_06.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_07.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_08.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_09.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_10.wav' +file '/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2/seg_11.wav' diff --git a/promo/narrated_xtts2/seg_00.wav b/promo/narrated_xtts2/seg_00.wav new file mode 100644 index 0000000..fb375da Binary files /dev/null and b/promo/narrated_xtts2/seg_00.wav differ diff --git a/promo/narrated_xtts2/seg_01.wav b/promo/narrated_xtts2/seg_01.wav new file mode 100644 index 0000000..8db7451 Binary files /dev/null and b/promo/narrated_xtts2/seg_01.wav differ diff --git a/promo/narrated_xtts2/seg_02.wav b/promo/narrated_xtts2/seg_02.wav new file mode 100644 index 0000000..9fa5887 Binary files /dev/null and b/promo/narrated_xtts2/seg_02.wav differ diff --git a/promo/narrated_xtts2/seg_03.wav b/promo/narrated_xtts2/seg_03.wav new file mode 100644 index 0000000..1692430 Binary files /dev/null and b/promo/narrated_xtts2/seg_03.wav differ diff --git a/promo/narrated_xtts2/seg_04.wav b/promo/narrated_xtts2/seg_04.wav new file mode 100644 index 0000000..19c8a0a Binary files /dev/null and b/promo/narrated_xtts2/seg_04.wav differ diff --git a/promo/narrated_xtts2/seg_05.wav b/promo/narrated_xtts2/seg_05.wav new file mode 100644 index 0000000..569c0b8 Binary files /dev/null and b/promo/narrated_xtts2/seg_05.wav differ diff --git a/promo/narrated_xtts2/seg_06.wav b/promo/narrated_xtts2/seg_06.wav new file mode 100644 index 0000000..0effb7c Binary files /dev/null and b/promo/narrated_xtts2/seg_06.wav differ diff --git a/promo/narrated_xtts2/seg_07.wav b/promo/narrated_xtts2/seg_07.wav new file mode 100644 index 0000000..44f8928 Binary files /dev/null and b/promo/narrated_xtts2/seg_07.wav differ diff --git a/promo/narrated_xtts2/seg_08.wav b/promo/narrated_xtts2/seg_08.wav new file mode 100644 index 0000000..fba31c8 Binary files /dev/null and b/promo/narrated_xtts2/seg_08.wav differ diff --git a/promo/narrated_xtts2/seg_09.wav b/promo/narrated_xtts2/seg_09.wav new file mode 100644 index 0000000..ba1a6be Binary files /dev/null and b/promo/narrated_xtts2/seg_09.wav differ diff --git a/promo/narrated_xtts2/seg_10.wav b/promo/narrated_xtts2/seg_10.wav new file mode 100644 index 0000000..529aae2 Binary files /dev/null and b/promo/narrated_xtts2/seg_10.wav differ diff --git a/promo/narrated_xtts2/seg_11.wav b/promo/narrated_xtts2/seg_11.wav new file mode 100644 index 0000000..48f2e26 Binary files /dev/null and b/promo/narrated_xtts2/seg_11.wav differ diff --git a/promo/promo_scene.py b/promo/promo_scene.py new file mode 100644 index 0000000..b9c1414 --- /dev/null +++ b/promo/promo_scene.py @@ -0,0 +1,167 @@ +"""EoStudio — production promo video with synced narration.""" +from manim import * +import json +import os + +# Load durations from generate_audio.py +dur_path = os.path.join(os.path.dirname(__file__), "durations.json") +if os.path.exists(dur_path): + with open(dur_path) as f: + DUR = json.load(f) +else: + DUR = {"intro": 4, "f1": 6, "f2": 6, "f3": 6, "arch": 7, "cta": 5} + +ACCENT = "#6366f1" +BG = "#0f172a" +DARK = "#1e293b" + + +class ProductPromo(Scene): + def construct(self): + self.camera.background_color = BG + + # ═══ INTRO ═══ + title = Text("EoStudio", font_size=96, color=WHITE, weight=BOLD) + underline = Line(LEFT * 3, RIGHT * 3, color=ACCENT, stroke_width=4) + underline.next_to(title, DOWN, buff=0.3) + tagline = Text("Design Suite with LLM Integration", font_size=28, color=GRAY_B) + tagline.next_to(underline, DOWN, buff=0.4) + # Tech badges + techs = "Python, React, TypeScript".split(", ") + badges = VGroup() + for t in techs: + badge = VGroup( + RoundedRectangle(corner_radius=0.1, width=len(t)*0.18+0.6, height=0.4, + stroke_color=ACCENT, fill_color=DARK, fill_opacity=1), + Text(t, font_size=14, color=WHITE), + ) + badge[1].move_to(badge[0]) + badges.add(badge) + badges.arrange(RIGHT, buff=0.3).next_to(tagline, DOWN, buff=0.5) + + self.play(Write(title), run_time=0.8) + self.play(Create(underline), FadeIn(tagline, shift=UP*0.2), run_time=0.6) + self.play(LaggedStart(*[FadeIn(b, scale=0.8) for b in badges], lag_ratio=0.1), run_time=0.6) + self.wait(DUR["intro"] - 2.0) + self.play(FadeOut(VGroup(title, underline, tagline, badges)), run_time=0.4) + + # ═══ FEATURES ═══ + features = [ + ("01", "AI Code Generation", "Natural language prompts generate production-ready TypeScript and Python code", DUR["f1"]), + ("02", "Spec-Driven Development", "Auto-generates requirements, design specs, tech specs, and task breakdowns", DUR["f2"]), + ("03", "Production UI Kit", "39 accessible React components with responsive variants and Framer Motion animations", DUR["f3"]), + ] + for num, feat_name, feat_desc, dur in features: + # Large number watermark + num_text = Text(num, font_size=200, color=ACCENT, weight=BOLD, + font="Monospace").set_opacity(0.08) + num_text.to_edge(LEFT, buff=0.5) + + # Feature title + feat_title = Text(feat_name, font_size=48, color=WHITE, weight=BOLD) + feat_title.to_edge(UP, buff=1.5).shift(RIGHT * 0.5) + + # Accent bar + bar = Rectangle(width=6, height=0.05, color=ACCENT, fill_opacity=1) + bar.next_to(feat_title, DOWN, buff=0.2, aligned_edge=LEFT) + + # Description text (wrapped) + desc_text = Paragraph( + feat_desc, font_size=22, color=GRAY_B, + line_spacing=1.2, alignment="left", + ).scale(0.9) + desc_text.next_to(bar, DOWN, buff=0.4, aligned_edge=LEFT) + if desc_text.width > 10: + desc_text.scale(10 / desc_text.width) + + # Visual element: tech diagram box + diagram = VGroup( + RoundedRectangle(corner_radius=0.15, width=4, height=2.5, + stroke_color=ACCENT, stroke_width=1, + fill_color=DARK, fill_opacity=0.5), + ) + # Add icon-like dots inside + for row in range(3): + for col in range(4): + dot = Dot(radius=0.04, color=ACCENT).set_opacity(0.3 + row*0.2) + dot.move_to(diagram[0].get_center() + RIGHT*(col-1.5)*0.6 + DOWN*(row-1)*0.5) + diagram.add(dot) + diagram.to_edge(RIGHT, buff=1).shift(DOWN * 0.3) + + grp = VGroup(num_text, feat_title, bar, desc_text, diagram) + self.play( + FadeIn(num_text), + Write(feat_title), GrowFromEdge(bar, LEFT), + run_time=0.7, + ) + self.play(FadeIn(desc_text, shift=UP*0.2), FadeIn(diagram, scale=0.9), run_time=0.6) + self.wait(dur - 1.7) + self.play(FadeOut(grp), run_time=0.4) + + # ═══ ARCHITECTURE ═══ + arch_label = Text("Architecture", font_size=20, color=GRAY_B) + arch_label.to_edge(UP, buff=0.6) + + components = ["LLM Client", "Spec Engine", "Code Gen", "UI Kit", "Deploy"] + boxes = VGroup() + for i, comp in enumerate(components): + box = VGroup( + RoundedRectangle( + corner_radius=0.12, width=2.2, height=1.0, + stroke_color=ACCENT, fill_color=DARK, fill_opacity=1, stroke_width=2, + ), + Text(comp, font_size=16, color=WHITE), + ) + box[1].move_to(box[0]) + boxes.add(box) + boxes.arrange(RIGHT, buff=0.4) + + arrows = VGroup() + for i in range(len(boxes) - 1): + arr = Arrow( + boxes[i].get_right(), boxes[i+1].get_left(), + color=ACCENT, buff=0.08, stroke_width=2, + max_tip_length_to_length_ratio=0.15, + ) + arrows.add(arr) + + # Data flow dots + flow_dots = VGroup() + for arr in arrows: + for t in [0.3, 0.5, 0.7]: + dot = Dot(radius=0.03, color=ACCENT).set_opacity(0.6) + dot.move_to(arr.point_from_proportion(t)) + flow_dots.add(dot) + + self.play(FadeIn(arch_label), run_time=0.3) + self.play( + LaggedStart(*[FadeIn(b, shift=UP*0.3) for b in boxes], lag_ratio=0.12), + run_time=0.8, + ) + self.play( + LaggedStart(*[GrowArrow(a) for a in arrows], lag_ratio=0.1), + run_time=0.5, + ) + self.play(LaggedStart(*[FadeIn(d, scale=0) for d in flow_dots], lag_ratio=0.05), run_time=0.4) + self.wait(DUR["arch"] - 2.4) + self.play(FadeOut(VGroup(arch_label, boxes, arrows, flow_dots)), run_time=0.4) + + # ═══ CTA ═══ + cta_name = Text("EoStudio", font_size=72, color=WHITE, weight=BOLD) + cta_line = Line(LEFT*2, RIGHT*2, color=ACCENT, stroke_width=3) + cta_line.next_to(cta_name, DOWN, buff=0.3) + cta_url = Text( + "github.com/embeddedos-org/EoStudio", + font_size=22, color=ManimColor(ACCENT), + ) + cta_url.next_to(cta_line, DOWN, buff=0.3) + cta_badge = Text("Open Source · MIT License · Production Ready", + font_size=16, color=GRAY_B) + cta_badge.next_to(cta_url, DOWN, buff=0.3) + star = Text("★ Star us on GitHub", font_size=18, color=YELLOW).set_opacity(0.8) + star.next_to(cta_badge, DOWN, buff=0.4) + + self.play(Write(cta_name), Create(cta_line), run_time=0.7) + self.play(FadeIn(cta_url, shift=UP*0.2), run_time=0.4) + self.play(FadeIn(cta_badge), FadeIn(star), run_time=0.4) + self.wait(DUR["cta"] - 1.9) diff --git a/promo/voice_clone_final.py b/promo/voice_clone_final.py new file mode 100644 index 0000000..e114093 --- /dev/null +++ b/promo/voice_clone_final.py @@ -0,0 +1,78 @@ +"""Voice clone — runs with LD_LIBRARY_PATH fix.""" +import os +os.environ["LD_LIBRARY_PATH"] = "/home/spatchava/miniconda3/envs/tts/lib:" + os.environ.get("LD_LIBRARY_PATH", "") + +import subprocess + +OUTPUT_DIR = "/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned" +os.makedirs(OUTPUT_DIR, exist_ok=True) +REFERENCE = "/home/spatchava/embeddedos-org/EoStudio/promo/your_voice.mp3" +FFMPEG = "/home/spatchava/.local/bin/ffmpeg" + +SEGMENTS = [ + "Hey everyone. I'm really excited to show you EoStudio. It's an open source design suite we built to solve a real problem.", + "EoStudio has thirteen design editors. 3D modeling, CAD, image editing, game design, UI UX prototyping, and more. All in one app.", + "We built a complete animation engine from scratch. Spring physics, twenty five presets, and a visual timeline editor.", + "The AI features are powerful. Describe your UI in plain English and get animated components instantly.", + "Prototyping is interactive. Click interactions, gestures, state machines. Export as HTML with one click.", + "Thirty three plus code generators. React with Framer Motion, Flutter, Swift, GSAP, and many more.", + "EoStudio version one. Community Edition. Free, open source, MIT licensed. Check it out on GitHub. Thank you.", +] + +from TTS.api import TTS +print("Loading YourTTS voice cloning model...") +tts = TTS("tts_models/multilingual/multi-dataset/your_tts", progress_bar=True) +print(f"Model loaded. Cloning from: {REFERENCE}") + +seg_paths = [] +for i, text in enumerate(SEGMENTS): + out = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.wav") + print(f" [{i}] {text[:55]}...") + tts.tts_to_file(text=text, file_path=out, speaker_wav=REFERENCE, language="en") + seg_paths.append(out) + print(f" -> {os.path.getsize(out)} bytes") + +# Silence +sil = os.path.join(OUTPUT_DIR, "silence.wav") +subprocess.run([FFMPEG, "-y", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", + "-t", "1.0", "-c:a", "pcm_s16le", sil], capture_output=True) + +# Concat +lp = os.path.join(OUTPUT_DIR, "list.txt") +with open(lp, "w") as f: + for i, sp in enumerate(seg_paths): + f.write(f"file '{sp}'\n") + if i < len(seg_paths) - 1: + f.write(f"file '{sil}'\n") + +nar_wav = os.path.join(OUTPUT_DIR, "narration.wav") +subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", lp, nar_wav], capture_output=True) + +nar_mp3 = os.path.join(OUTPUT_DIR, "narration.mp3") +subprocess.run([FFMPEG, "-y", "-i", nar_wav, "-c:a", "libmp3lame", "-q:a", "2", nar_mp3], capture_output=True) + +# Duration +r = subprocess.run([FFMPEG, "-i", nar_mp3, "-f", "null", "-"], capture_output=True, text=True) +dur = 120 +for line in r.stderr.split("\n"): + if "Duration:" in line: + t = line.split("Duration:")[1].split(",")[0].strip().split(":") + dur = float(t[0])*3600 + float(t[1])*60 + float(t[2]) +print(f"Total narration: {dur:.1f}s") + +# Combine with video +video = "/home/spatchava/embeddedos-org/EoStudio/promo/media/videos/eostudio_promo/1080p60/EoStudioPromo.mp4" +output = os.path.join(OUTPUT_DIR, "EoStudio_VoiceCloned_1080p.mp4") +subprocess.run([FFMPEG, "-y", "-stream_loop", "-1", "-i", video, "-i", nar_mp3, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-b:a", "192k", "-t", str(dur), + "-pix_fmt", "yuv420p", output], capture_output=True) + +if os.path.exists(output): + sz = os.path.getsize(output) / 1024 / 1024 + print(f"Final: {output} ({sz:.1f} MB)") + subprocess.run(["cp", output, "/mnt/c/Users/spatchava/Desktop/EoStudio_VoiceCloned_1080p.mp4"], capture_output=True) + subprocess.run(["cp", nar_mp3, "/mnt/c/Users/spatchava/Desktop/EoStudio_VoiceCloned.mp3"], capture_output=True) + print("Copied to Desktop!") +else: + print("Video failed, but audio at:", nar_mp3) diff --git a/promo/voice_clone_v2.py b/promo/voice_clone_v2.py new file mode 100644 index 0000000..75fb758 --- /dev/null +++ b/promo/voice_clone_v2.py @@ -0,0 +1,76 @@ +"""Voice clone narration using Coqui TTS 0.22 + Python 3.10 — clones from your Recording.mp3.""" +import os, subprocess + +OUTPUT_DIR = "/home/spatchava/embeddedos-org/EoStudio/promo/narrated_cloned" +os.makedirs(OUTPUT_DIR, exist_ok=True) +REFERENCE = "/home/spatchava/embeddedos-org/EoStudio/promo/your_voice.mp3" +FFMPEG = "/home/spatchava/.local/bin/ffmpeg" + +SEGMENTS = [ + "Hey everyone. I'm really excited to show you EoStudio. It's an open-source design suite we built to solve a real problem.", + "EoStudio has thirteen design editors. 3D modeling, CAD, image editing, game design, UI UX prototyping, and more. All in one app.", + "We built a complete animation engine from scratch. Spring physics, twenty five presets, and a visual timeline editor.", + "The AI features are powerful. Describe your UI in plain English and get animated components instantly.", + "Prototyping is interactive. Click interactions, gestures, state machines. Export as HTML with one click.", + "Thirty three plus code generators. React with Framer Motion, Flutter, Swift, GSAP, and many more.", + "EoStudio version one. Community Edition. Free, open source, MIT licensed. Check it out on GitHub. Thank you.", +] + +def main(): + from TTS.api import TTS + print("Loading YourTTS model for voice cloning...") + tts = TTS("tts_models/multilingual/multi-dataset/your_tts", progress_bar=True) + print(f"Model loaded. Cloning voice from: {REFERENCE}") + + seg_paths = [] + for i, text in enumerate(SEGMENTS): + out = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.wav") + print(f" [{i}] {text[:55]}...") + tts.tts_to_file(text=text, file_path=out, speaker_wav=REFERENCE, language="en") + seg_paths.append(out) + + # Create silence + sil = os.path.join(OUTPUT_DIR, "silence.wav") + subprocess.run([FFMPEG, "-y", "-f", "lavfi", "-i", "anullsrc=r=16000:cl=mono", + "-t", "1.0", "-c:a", "pcm_s16le", sil], capture_output=True) + + # Concatenate + lp = os.path.join(OUTPUT_DIR, "list.txt") + with open(lp, "w") as f: + for i, sp in enumerate(seg_paths): + f.write(f"file '{sp}'\n") + if i < len(seg_paths) - 1: + f.write(f"file '{sil}'\n") + + nar_wav = os.path.join(OUTPUT_DIR, "narration.wav") + subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", lp, nar_wav], capture_output=True) + + nar_mp3 = os.path.join(OUTPUT_DIR, "narration.mp3") + subprocess.run([FFMPEG, "-y", "-i", nar_wav, "-c:a", "libmp3lame", "-q:a", "2", nar_mp3], capture_output=True) + + # Get duration + r = subprocess.run([FFMPEG, "-i", nar_mp3, "-f", "null", "-"], capture_output=True, text=True) + dur = 120 + for line in r.stderr.split("\n"): + if "Duration:" in line: + t = line.split("Duration:")[1].split(",")[0].strip().split(":") + dur = float(t[0])*3600 + float(t[1])*60 + float(t[2]) + print(f"Narration: {dur:.1f}s") + + # Combine with video + video = "/home/spatchava/embeddedos-org/EoStudio/promo/media/videos/eostudio_promo/1080p60/EoStudioPromo.mp4" + output = os.path.join(OUTPUT_DIR, "EoStudio_VoiceCloned_1080p.mp4") + subprocess.run([FFMPEG, "-y", "-stream_loop", "-1", "-i", video, "-i", nar_mp3, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-b:a", "192k", "-t", str(dur), + "-pix_fmt", "yuv420p", output], capture_output=True) + + if os.path.exists(output): + sz = os.path.getsize(output) / 1024 / 1024 + print(f"Final: {output} ({sz:.1f} MB)") + subprocess.run(["cp", output, "/mnt/c/Users/spatchava/Desktop/EoStudio_VoiceCloned_1080p.mp4"], capture_output=True) + subprocess.run(["cp", nar_mp3, "/mnt/c/Users/spatchava/Desktop/EoStudio_VoiceCloned.mp3"], capture_output=True) + print("Copied to Desktop!") + +if __name__ == "__main__": + main() diff --git a/promo/voice_clone_xtts.py b/promo/voice_clone_xtts.py new file mode 100644 index 0000000..d31126d --- /dev/null +++ b/promo/voice_clone_xtts.py @@ -0,0 +1,104 @@ +"""Voice clone using XTTS v2 — higher quality, more confident output.""" +import os +os.environ["LD_LIBRARY_PATH"] = "/home/spatchava/miniconda3/envs/tts/lib:" + os.environ.get("LD_LIBRARY_PATH", "") +os.environ["COQUI_TOS_AGREED"] = "1" + +import subprocess + +OUTPUT_DIR = "/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts" +os.makedirs(OUTPUT_DIR, exist_ok=True) +REFERENCE = "/home/spatchava/embeddedos-org/EoStudio/promo/your_voice.mp3" +FFMPEG = "/home/spatchava/.local/bin/ffmpeg" + +# Convert reference to WAV first (XTTS prefers WAV) +REF_WAV = os.path.join(OUTPUT_DIR, "reference.wav") +subprocess.run([FFMPEG, "-y", "-i", REFERENCE, "-ar", "22050", "-ac", "1", REF_WAV], capture_output=True) +print(f"Reference WAV: {os.path.getsize(REF_WAV)} bytes") + +SEGMENTS = [ + "Hey everyone.", + "I'm really excited to show you EoStudio.", + "It's an open source design suite.", + "We built it to solve a real problem.", + "Why do we need ten different tools when one can do it all?", + "EoStudio has thirteen design editors.", + "3D modeling. CAD design. Image editing.", + "Game design. UI UX prototyping. And more.", + "All in one app.", + "We built a complete animation engine from scratch.", + "Spring physics. Twenty five animation presets.", + "A visual timeline editor.", + "The AI features are seriously powerful.", + "Describe your UI in plain English.", + "EoStudio generates animated components instantly.", + "Upload a screenshot and it extracts every component.", + "Prototyping is fully interactive.", + "Click interactions. Hover effects. Swipe gestures.", + "Export as HTML prototype with one click.", + "Code generation is where EoStudio really shines.", + "Thirty three plus frameworks.", + "React with Framer Motion. Flutter. Swift. Kotlin.", + "Design once. Deploy everywhere.", + "EoStudio version one point oh.", + "Community Edition.", + "Free. Open source. MIT licensed.", + "Check it out on GitHub. Thank you.", +] + +from TTS.api import TTS + +print("Loading XTTS v2 model (best quality voice cloning)...") +tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", progress_bar=True) +print("Model loaded. Generating with your cloned voice...") + +seg_paths = [] +for i, text in enumerate(SEGMENTS): + out = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.wav") + print(f" [{i}] {text[:60]}...") + tts.tts_to_file(text=text, file_path=out, speaker_wav=REF_WAV, language="en") + sz = os.path.getsize(out) + print(f" -> {sz} bytes") + seg_paths.append(out) + +# Silence between segments +sil = os.path.join(OUTPUT_DIR, "silence.wav") +subprocess.run([FFMPEG, "-y", "-f", "lavfi", "-i", "anullsrc=r=22050:cl=mono", + "-t", "0.4", "-c:a", "pcm_s16le", sil], capture_output=True) + +# Concatenate +lp = os.path.join(OUTPUT_DIR, "list.txt") +with open(lp, "w") as f: + for i, sp in enumerate(seg_paths): + f.write(f"file '{sp}'\n") + if i < len(seg_paths) - 1: + f.write(f"file '{sil}'\n") + +nar_wav = os.path.join(OUTPUT_DIR, "narration.wav") +subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", lp, nar_wav], capture_output=True) + +nar_mp3 = os.path.join(OUTPUT_DIR, "narration.mp3") +subprocess.run([FFMPEG, "-y", "-i", nar_wav, "-c:a", "libmp3lame", "-q:a", "2", nar_mp3], capture_output=True) + +# Duration +r = subprocess.run([FFMPEG, "-i", nar_mp3, "-f", "null", "-"], capture_output=True, text=True) +dur = 120 +for line in r.stderr.split("\n"): + if "Duration:" in line: + t = line.split("Duration:")[1].split(",")[0].strip().split(":") + dur = float(t[0])*3600 + float(t[1])*60 + float(t[2]) +print(f"Total narration: {dur:.1f}s") + +# Combine with video +video = "/home/spatchava/embeddedos-org/EoStudio/promo/media/videos/eostudio_promo/1080p60/EoStudioPromo.mp4" +output = os.path.join(OUTPUT_DIR, "EoStudio_XTTS_1080p.mp4") +subprocess.run([FFMPEG, "-y", "-stream_loop", "-1", "-i", video, "-i", nar_mp3, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-b:a", "192k", "-t", str(dur), + "-pix_fmt", "yuv420p", output], capture_output=True) + +if os.path.exists(output): + sz = os.path.getsize(output) / 1024 / 1024 + print(f"Final: {output} ({sz:.1f} MB)") + subprocess.run(["cp", output, "/mnt/c/Users/spatchava/Desktop/EoStudio_XTTS_VoiceClone_1080p.mp4"], capture_output=True) + subprocess.run(["cp", nar_mp3, "/mnt/c/Users/spatchava/Desktop/EoStudio_XTTS_VoiceClone.mp3"], capture_output=True) + print("Copied to Desktop!") diff --git a/promo/voice_clone_xtts_v2.py b/promo/voice_clone_xtts_v2.py new file mode 100644 index 0000000..331162a --- /dev/null +++ b/promo/voice_clone_xtts_v2.py @@ -0,0 +1,128 @@ +"""XTTS v2 voice clone — medium segments + crossfade for continuous natural flow.""" +import os +os.environ["LD_LIBRARY_PATH"] = "/home/spatchava/miniconda3/envs/tts/lib:" + os.environ.get("LD_LIBRARY_PATH", "") +os.environ["COQUI_TOS_AGREED"] = "1" + +import subprocess + +OUTPUT_DIR = "/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts2" +os.makedirs(OUTPUT_DIR, exist_ok=True) +REFERENCE = "/home/spatchava/embeddedos-org/EoStudio/promo/narrated_xtts/reference.wav" +FFMPEG = "/home/spatchava/.local/bin/ffmpeg" + +# Medium-length segments — short enough for no word drops, long enough for natural flow +SEGMENTS = [ + "Hey everyone. I'm really excited to show you EoStudio.", + "It's an open source design suite we built to solve a real problem.", + "EoStudio has thirteen design editors built right in.", + "3D modeling, CAD design, image editing, game design, UI UX prototyping, and more.", + "We built a complete animation engine from scratch. Spring physics and twenty five presets.", + "The AI features are seriously powerful. Describe your UI in plain English.", + "EoStudio generates animated components instantly. Upload a screenshot and it extracts every component.", + "Prototyping is fully interactive. Click interactions, hover effects, swipe gestures.", + "Code generation is where EoStudio really shines. Thirty three plus frameworks.", + "React with Framer Motion, Flutter, Swift, Kotlin, and many more.", + "EoStudio version one point oh. Community Edition. Free, open source, MIT licensed.", + "Check it out on GitHub. Thank you.", +] + +from TTS.api import TTS + +print("Loading XTTS v2...") +tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", progress_bar=True) +print("Generating segments...") + +seg_paths = [] +for i, text in enumerate(SEGMENTS): + out = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.wav") + print(f" [{i}] {text[:60]}...") + tts.tts_to_file(text=text, file_path=out, speaker_wav=REFERENCE, language="en") + print(f" -> {os.path.getsize(out)} bytes") + seg_paths.append(out) + +# Use crossfade instead of silence for continuous flow +print("Crossfading segments for continuous audio...") + +# First, concat all WAVs raw (no gaps) +raw_list = os.path.join(OUTPUT_DIR, "raw_list.txt") +with open(raw_list, "w") as f: + for sp in seg_paths: + f.write(f"file '{sp}'\n") + +raw_concat = os.path.join(OUTPUT_DIR, "raw_concat.wav") +subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", raw_list, raw_concat], capture_output=True) + +# Apply crossfade using acrossfade filter between segments +# Build a complex filter that crossfades each pair +if len(seg_paths) > 1: + # Use a simpler approach: concat with small overlap via adelay + amix + # Or just use raw concat with 100ms fade between segments + + # Add tiny fade-in/fade-out to each segment, then concat + faded_paths = [] + for i, sp in enumerate(seg_paths): + faded = os.path.join(OUTPUT_DIR, f"faded_{i:02d}.wav") + # 50ms fade-in, 100ms fade-out for smooth transitions + subprocess.run([FFMPEG, "-y", "-i", sp, + "-af", "afade=t=in:st=0:d=0.05,afade=t=out:st=999:d=0.1", + faded], capture_output=True) + # The fade-out start time 999 won't match, so use a smarter approach + # Get duration first + r = subprocess.run([FFMPEG, "-i", sp, "-f", "null", "-"], capture_output=True, text=True) + dur = 2.0 + for line in r.stderr.split("\n"): + if "Duration:" in line: + t = line.split("Duration:")[1].split(",")[0].strip().split(":") + dur = float(t[0])*3600 + float(t[1])*60 + float(t[2]) + + fade_out_start = max(0, dur - 0.1) + subprocess.run([FFMPEG, "-y", "-i", sp, + "-af", f"afade=t=in:st=0:d=0.05,afade=t=out:st={fade_out_start}:d=0.1", + faded], capture_output=True) + faded_paths.append(faded) + + # Concat faded segments with tiny 50ms silence (just enough for breath) + breath = os.path.join(OUTPUT_DIR, "breath.wav") + subprocess.run([FFMPEG, "-y", "-f", "lavfi", "-i", "anullsrc=r=22050:cl=mono", + "-t", "0.05", "-c:a", "pcm_s16le", breath], capture_output=True) + + final_list = os.path.join(OUTPUT_DIR, "final_list.txt") + with open(final_list, "w") as f: + for i, fp in enumerate(faded_paths): + f.write(f"file '{fp}'\n") + if i < len(faded_paths) - 1: + f.write(f"file '{breath}'\n") + + narration_wav = os.path.join(OUTPUT_DIR, "narration.wav") + subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", final_list, narration_wav], + capture_output=True) +else: + narration_wav = seg_paths[0] + +narration_mp3 = os.path.join(OUTPUT_DIR, "narration.mp3") +subprocess.run([FFMPEG, "-y", "-i", narration_wav, "-c:a", "libmp3lame", "-q:a", "2", narration_mp3], + capture_output=True) + +# Get duration +r = subprocess.run([FFMPEG, "-i", narration_mp3, "-f", "null", "-"], capture_output=True, text=True) +dur = 120 +for line in r.stderr.split("\n"): + if "Duration:" in line: + t = line.split("Duration:")[1].split(",")[0].strip().split(":") + dur = float(t[0])*3600 + float(t[1])*60 + float(t[2]) +print(f"Total: {dur:.1f}s") + +# Combine with video +video = "/home/spatchava/embeddedos-org/EoStudio/promo/media/videos/eostudio_promo/1080p60/EoStudioPromo.mp4" +output = os.path.join(OUTPUT_DIR, "EoStudio_XTTS_Final_1080p.mp4") +subprocess.run([FFMPEG, "-y", "-stream_loop", "-1", "-i", video, "-i", narration_mp3, + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-b:a", "192k", "-t", str(dur), + "-pix_fmt", "yuv420p", output], capture_output=True) + +if os.path.exists(output): + sz = os.path.getsize(output) / 1024 / 1024 + print(f"Final: {output} ({sz:.1f} MB)") + subprocess.run(["cp", output, "/mnt/c/Users/spatchava/Desktop/EoStudio_XTTS_Final_1080p.mp4"], capture_output=True) + subprocess.run(["cp", narration_mp3, "/mnt/c/Users/spatchava/Desktop/EoStudio_XTTS_Final.mp3"], capture_output=True) + print("Copied to Desktop!") diff --git a/pyproject.toml b/pyproject.toml index c2d8e30..0bd04a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "EoStudio" -version = "1.0.0" -description = "EoStudio - Cross-Platform Design Suite with LLM Integration, Animation Engine, and AI UI Generation" +version = "2.0.0" +description = "EoStudio - Universal Development Platform with AI-Powered Code Editing, Design Suite, DevTools, and Real-Time Collaboration" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" @@ -20,9 +20,31 @@ dev = [ "flake8>=6.0", "mypy>=1.0", ] +ai = [ + "httpx>=0.25", + "tiktoken>=0.5", +] +database = [ + "psycopg2-binary>=2.9", + "pymysql>=1.1", + "pymongo>=4.5", + "redis>=5.0", +] +cloud = [ + "httpx>=0.25", + "keyring>=24.0", +] all = [ "Pillow>=10.0", "httpx>=0.25", + "tiktoken>=0.5", + "keyring>=24.0", + "psycopg2-binary>=2.9", + "pymysql>=1.1", + "pymongo>=4.5", + "redis>=5.0", + "pyyaml>=6.0", + "websockets>=12.0", ] [project.scripts] @@ -51,4 +73,4 @@ warn_unused_configs = true max-line-length = 120 ignore = ["E501", "W503", "E402", "E741", "F401", "F811", "F841", "F541", "F404"] -disable_error_code = ["no-redef", "attr-defined", "misc", "call-overload", "str-unpack", "var-annotated", "func-returns-value"] +disable_error_code = ["no-redef", "attr-defined", "misc", "call-overload", "str-unpack", "var-annotated", "func-returns-value"] \ No newline at end of file