feat: wire Forgejo Actions CI/CD workflows (#22) #26
35 changed files with 318 additions and 61 deletions
41
.cliff.toml
Normal file
41
.cliff.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
[changelog]
|
||||||
|
header = """
|
||||||
|
# Changelog\n
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] — {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
commit_preprocessors = [
|
||||||
|
{ pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/${1}))" },
|
||||||
|
]
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactoring" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Testing" },
|
||||||
|
{ message = "^revert", group = "Reverts" },
|
||||||
|
{ message = "^chore", skip = true },
|
||||||
|
]
|
||||||
|
filter_commits = true
|
||||||
|
tag_pattern = "v[0-9]*"
|
||||||
|
topo_order = false
|
||||||
|
sort_commits = "oldest"
|
||||||
62
.forgejo/workflows/ci.yml
Normal file
62
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api:
|
||||||
|
name: API — lint + test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install circuitforge-core
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone https://x-token:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git /tmp/circuitforge-core
|
||||||
|
pip install -e /tmp/circuitforge-core
|
||||||
|
|
||||||
|
- name: Install snipe + dev deps
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Lint (ruff)
|
||||||
|
run: ruff check app/ api/ tests/
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest tests/ -v
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web — typecheck + test + build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
working-directory: web
|
||||||
|
run: npx vue-tsc --noEmit
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
working-directory: web
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: web
|
||||||
|
run: npm run build
|
||||||
37
.forgejo/workflows/mirror.yml
Normal file
37
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror-github:
|
||||||
|
name: Mirror to GitHub
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to GitHub
|
||||||
|
env:
|
||||||
|
GH_MIRROR_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote add github https://x-access-token:${GH_MIRROR_TOKEN}@github.com/CircuitForgeLLC/snipe.git
|
||||||
|
git push github --mirror
|
||||||
|
|
||||||
|
mirror-codeberg:
|
||||||
|
name: Mirror to Codeberg
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to Codeberg
|
||||||
|
env:
|
||||||
|
CODEBERG_MIRROR_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote add codeberg https://CircuitForge:${CODEBERG_MIRROR_TOKEN}@codeberg.org/CircuitForge/snipe.git
|
||||||
|
git push codeberg --mirror
|
||||||
67
.forgejo/workflows/release.yml
Normal file
67
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build images + create release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
uses: orhun/git-cliff-action@v3
|
||||||
|
id: cliff
|
||||||
|
with:
|
||||||
|
config: .cliff.toml
|
||||||
|
args: --current --strip header
|
||||||
|
env:
|
||||||
|
OUTPUT: CHANGES.md
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# BSL product: push to Forgejo registry only.
|
||||||
|
# GHCR push is deferred pending license-gate-at-startup implementation (Circuit-Forge/snipe#3).
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.opensourcesolarpunk.com
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push API image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/api/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
git.opensourcesolarpunk.com/circuit-forge/snipe-api:${{ gitea.ref_name }}
|
||||||
|
git.opensourcesolarpunk.com/circuit-forge/snipe-api:latest
|
||||||
|
|
||||||
|
- name: Build and push web image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/web/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
git.opensourcesolarpunk.com/circuit-forge/snipe-web:${{ gitea.ref_name }}
|
||||||
|
git.opensourcesolarpunk.com/circuit-forge/snipe-web:latest
|
||||||
|
|
||||||
|
- name: Create Forgejo release
|
||||||
|
env:
|
||||||
|
FORGEJO_RELEASE_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
body=$(cat CHANGES.md | python3 -c "import sys, json; print(json.dumps(sys.stdin.read()))")
|
||||||
|
curl -sf -X POST \
|
||||||
|
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/snipe/releases" \
|
||||||
|
-H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${TAG}\", \"body\": ${body}}"
|
||||||
|
|
@ -16,8 +16,6 @@ FastAPI usage:
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -77,7 +75,6 @@ def compute_features(tier: str) -> SessionFeatures:
|
||||||
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
|
"""Compute feature flags from tier. Evaluated server-side; sent to frontend."""
|
||||||
local = tier == "local"
|
local = tier == "local"
|
||||||
paid_plus = local or tier in ("paid", "premium", "ultra")
|
paid_plus = local or tier in ("paid", "premium", "ultra")
|
||||||
premium_plus = local or tier in ("premium", "ultra")
|
|
||||||
|
|
||||||
return SessionFeatures(
|
return SessionFeatures(
|
||||||
saved_searches=True, # all tiers get saved searches
|
saved_searches=True, # all tiers get saved searches
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Header, HTTPException, Request
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||||
from cryptography.hazmat.primitives.hashes import SHA1
|
from cryptography.hazmat.primitives.hashes import SHA1
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Request
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
|
|
|
||||||
33
api/main.py
33
api/main.py
|
|
@ -1,8 +1,11 @@
|
||||||
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
|
"""Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json as _json
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -12,28 +15,25 @@ from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile, File
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from circuitforge_core.config import load_env
|
|
||||||
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
from circuitforge_core.api import make_feedback_router as _make_feedback_router
|
||||||
|
from circuitforge_core.config import load_env
|
||||||
|
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from api.cloud_session import CloudUser, compute_features, get_session
|
||||||
|
from api.ebay_webhook import router as ebay_webhook_router
|
||||||
|
from app.db.models import SavedSearch as SavedSearchModel
|
||||||
|
from app.db.models import ScammerEntry
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
|
|
||||||
from app.platforms import SearchFilters
|
from app.platforms import SearchFilters
|
||||||
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
|
||||||
from app.platforms.ebay.adapter import EbayAdapter
|
from app.platforms.ebay.adapter import EbayAdapter
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
from app.platforms.ebay.query_builder import expand_queries, parse_groups
|
||||||
|
from app.platforms.ebay.scraper import ScrapedEbayAdapter
|
||||||
from app.trust import TrustScorer
|
from app.trust import TrustScorer
|
||||||
from api.cloud_session import CloudUser, compute_features, get_session
|
|
||||||
from api.ebay_webhook import router as ebay_webhook_router
|
|
||||||
|
|
||||||
load_env(Path(".env"))
|
load_env(Path(".env"))
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -50,8 +50,8 @@ async def _lifespan(app: FastAPI):
|
||||||
# Start vision/LLM background task scheduler.
|
# Start vision/LLM background task scheduler.
|
||||||
# background_tasks queue lives in shared_db (cloud) or local_db (local)
|
# background_tasks queue lives in shared_db (cloud) or local_db (local)
|
||||||
# so the scheduler has a single stable DB path across all cloud users.
|
# so the scheduler has a single stable DB path across all cloud users.
|
||||||
|
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
|
||||||
from app.tasks.scheduler import get_scheduler, reset_scheduler
|
from app.tasks.scheduler import get_scheduler, reset_scheduler
|
||||||
from api.cloud_session import CLOUD_MODE, _LOCAL_SNIPE_DB, _shared_db_path
|
|
||||||
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
||||||
get_scheduler(sched_db)
|
get_scheduler(sched_db)
|
||||||
log.info("Snipe task scheduler started (db=%s)", sched_db)
|
log.info("Snipe task scheduler started (db=%s)", sched_db)
|
||||||
|
|
@ -245,9 +245,10 @@ def _enqueue_vision_tasks(
|
||||||
trust_scores table in cloud mode.
|
trust_scores table in cloud mode.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
|
from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path
|
||||||
from app.tasks.runner import insert_task
|
from app.tasks.runner import insert_task
|
||||||
from app.tasks.scheduler import get_scheduler
|
from app.tasks.scheduler import get_scheduler
|
||||||
from api.cloud_session import CLOUD_MODE, _shared_db_path, _LOCAL_SNIPE_DB
|
|
||||||
|
|
||||||
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
sched_db = _shared_db_path() if CLOUD_MODE else _LOCAL_SNIPE_DB
|
||||||
sched = get_scheduler(sched_db)
|
sched = get_scheduler(sched_db)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Dataclasses for all Snipe domain objects."""
|
"""Dataclasses for all Snipe domain objects."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Thin SQLite read/write layer for all Snipe models."""
|
"""Thin SQLite read/write layer for all Snipe models."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -7,7 +8,7 @@ from typing import Optional
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection, run_migrations
|
from circuitforge_core.db import get_connection, run_migrations
|
||||||
|
|
||||||
from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry
|
from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""PlatformAdapter abstract base and shared types."""
|
"""PlatformAdapter abstract base and shared types."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Listing, Seller
|
from app.db.models import Listing, Seller
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"""eBay Browse API adapter."""
|
"""eBay Browse API adapter."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -18,7 +20,7 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
|
||||||
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
|
||||||
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
|
||||||
|
|
||||||
from app.db.models import Listing, Seller, MarketComp
|
from app.db.models import Listing, MarketComp, Seller
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.platforms import PlatformAdapter, SearchFilters
|
from app.platforms import PlatformAdapter, SearchFilters
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""eBay OAuth2 client credentials token manager."""
|
"""eBay OAuth2 client credentials token manager."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
EBAY_OAUTH_URLS = {
|
EBAY_OAUTH_URLS = {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""Convert raw eBay API responses into Snipe domain objects."""
|
"""Convert raw eBay API responses into Snipe domain objects."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Listing, Seller
|
from app.db.models import Listing, Seller
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -302,7 +302,8 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
|
|
||||||
time.sleep(self._delay)
|
time.sleep(self._delay)
|
||||||
|
|
||||||
import subprocess, os
|
import os
|
||||||
|
import subprocess
|
||||||
display_num = next(_display_counter)
|
display_num = next(_display_counter)
|
||||||
display = f":{display_num}"
|
display = f":{display_num}"
|
||||||
xvfb = subprocess.Popen(
|
xvfb = subprocess.Popen(
|
||||||
|
|
@ -313,8 +314,10 @@ class ScrapedEbayAdapter(PlatformAdapter):
|
||||||
env["DISPLAY"] = display
|
env["DISPLAY"] = display
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker
|
from playwright.sync_api import (
|
||||||
from playwright_stealth import Stealth # noqa: PLC0415
|
sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker
|
||||||
|
)
|
||||||
|
from playwright_stealth import Stealth # noqa: PLC0415
|
||||||
|
|
||||||
with sync_playwright() as pw:
|
with sync_playwright() as pw:
|
||||||
browser = pw.chromium.launch(
|
browser = pw.chromium.launch(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection
|
from circuitforge_core.db import get_connection
|
||||||
from circuitforge_core.llm import LLMRouter
|
from circuitforge_core.llm import LLMRouter
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler, # re-export for tests
|
||||||
|
)
|
||||||
|
from circuitforge_core.tasks.scheduler import (
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler, # re-export for tests
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ Intentionally ungated (free for all):
|
||||||
- saved_searches — retention feature; friction cost outweighs gate value
|
- saved_searches — retention feature; friction cost outweighs gate value
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from circuitforge_core.tiers import can_use as _core_can_use, TIERS # noqa: F401
|
|
||||||
|
from circuitforge_core.tiers import can_use as _core_can_use # noqa: F401
|
||||||
|
|
||||||
# Feature key → minimum tier required.
|
# Feature key → minimum tier required.
|
||||||
FEATURES: dict[str, str] = {
|
FEATURES: dict[str, str] = {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
from .metadata import MetadataScorer
|
|
||||||
from .photo import PhotoScorer
|
|
||||||
from .aggregator import Aggregator
|
|
||||||
from app.db.models import Seller, Listing, TrustScore
|
|
||||||
from app.db.store import Store
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
from app.db.models import Listing, TrustScore
|
||||||
|
from app.db.models import Seller as Seller
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
from .aggregator import Aggregator
|
||||||
|
from .metadata import MetadataScorer
|
||||||
|
from .photo import PhotoScorer
|
||||||
|
|
||||||
|
|
||||||
class TrustScorer:
|
class TrustScorer:
|
||||||
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
"""Orchestrates metadata + photo scoring for a batch of listings."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"""Composite score and red flag extraction."""
|
"""Composite score and red flag extraction."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Seller, TrustScore
|
from app.db.models import Seller, TrustScore
|
||||||
|
|
||||||
HARD_FILTER_AGE_DAYS = 7
|
HARD_FILTER_AGE_DAYS = 7
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Five metadata trust signals, each scored 0–20."""
|
"""Five metadata trust signals, each scored 0–20."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db.models import Seller
|
from app.db.models import Seller
|
||||||
|
|
||||||
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}
|
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
|
"""Perceptual hash deduplication within a result set (free tier, v0.1)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Optional
|
|
||||||
import io
|
import io
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
"""Main search + results page."""
|
"""Main search + results page."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from circuitforge_core.config import load_env
|
from circuitforge_core.config import load_env
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.platforms import PlatformAdapter, SearchFilters
|
from app.platforms import PlatformAdapter, SearchFilters
|
||||||
from app.trust import TrustScorer
|
from app.trust import TrustScorer
|
||||||
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
|
|
||||||
from app.ui.components.listing_row import render_listing_row
|
|
||||||
from app.ui.components.easter_eggs import (
|
from app.ui.components.easter_eggs import (
|
||||||
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
|
|
||||||
auction_hours_remaining,
|
auction_hours_remaining,
|
||||||
|
check_snipe_mode,
|
||||||
|
inject_steal_css,
|
||||||
|
render_snipe_mode_banner,
|
||||||
)
|
)
|
||||||
|
from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar
|
||||||
|
from app.ui.components.listing_row import render_listing_row
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Konami → Snipe Mode
|
# 1. Konami → Snipe Mode
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
"""Render a single listing row with trust score, badges, and error states."""
|
"""Render a single listing row with trust score, badges, and error states."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore, Seller
|
from app.db.models import Listing, Seller, TrustScore
|
||||||
from app.ui.components.easter_eggs import (
|
from app.ui.components.easter_eggs import (
|
||||||
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining,
|
auction_hours_remaining,
|
||||||
|
is_steal,
|
||||||
|
render_auction_notice,
|
||||||
|
render_steal_banner,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""First-run wizard: collect eBay credentials and write .env."""
|
"""First-run wizard: collect eBay credentials and write .env."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from circuitforge_core.wizard import BaseWizard
|
from circuitforge_core.wizard import BaseWizard
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,23 @@ dependencies = [
|
||||||
"PyJWT>=2.8",
|
"PyJWT>=2.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["app*", "api*"]
|
include = ["app*", "api*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# E (pycodestyle) deferred — tighten incrementally once existing violations are resolved
|
||||||
|
select = ["F", "I"]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.db.models import Listing, MarketComp, Seller
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.db.models import Listing, Seller, TrustScore, MarketComp
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import time
|
import time
|
||||||
import requests
|
from unittest.mock import MagicMock, patch
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
from app.platforms.ebay.auth import EbayTokenManager
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
|
Uses a minimal HTML fixture mirroring eBay's current s-card markup.
|
||||||
No HTTP requests are made — all tests operate on the pure parsing functions.
|
No HTTP requests are made — all tests operate on the pure parsing functions.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.platforms.ebay.scraper import (
|
from app.platforms.ebay.scraper import (
|
||||||
scrape_listings,
|
_extract_seller_from_card,
|
||||||
scrape_sellers,
|
|
||||||
_parse_price,
|
_parse_price,
|
||||||
_parse_time_left,
|
_parse_time_left,
|
||||||
_extract_seller_from_card,
|
scrape_listings,
|
||||||
|
scrape_sellers,
|
||||||
)
|
)
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Minimal eBay search results HTML fixture (li.s-card schema)
|
# Minimal eBay search results HTML fixture (li.s-card schema)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from circuitforge_core.api.feedback import make_feedback_router
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from circuitforge_core.api.feedback import make_feedback_router
|
|
||||||
|
|
||||||
|
|
||||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from app.tiers import can_use, FEATURES, LOCAL_VISION_UNLOCKABLE
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_scoring_is_free():
|
def test_metadata_scoring_is_free():
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.db.models import Listing, TrustScore
|
from app.db.models import Listing, TrustScore
|
||||||
from app.ui.components.easter_eggs import is_steal, auction_hours_remaining
|
from app.ui.components.easter_eggs import auction_hours_remaining, is_steal
|
||||||
|
|
||||||
|
|
||||||
def _listing(**kwargs) -> Listing:
|
def _listing(**kwargs) -> Listing:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue