diff --git a/.cliff.toml b/.cliff.toml new file mode 100644 index 0000000..cca3608 --- /dev/null +++ b/.cliff.toml @@ -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" diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..56aa7e9 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..5ff0a07 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -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: + GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} + run: | + git remote add github https://x-access-token:${GITHUB_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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..5136144 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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}}" diff --git a/api/cloud_session.py b/api/cloud_session.py index 467702f..d3cc8a9 100644 --- a/api/cloud_session.py +++ b/api/cloud_session.py @@ -16,8 +16,6 @@ FastAPI usage: """ from __future__ import annotations -import hashlib -import hmac import logging import os import re @@ -77,7 +75,6 @@ def compute_features(tier: str) -> SessionFeatures: """Compute feature flags from tier. Evaluated server-side; sent to frontend.""" local = tier == "local" paid_plus = local or tier in ("paid", "premium", "ultra") - premium_plus = local or tier in ("premium", "ultra") return SessionFeatures( saved_searches=True, # all tiers get saved searches diff --git a/api/ebay_webhook.py b/api/ebay_webhook.py index 0719455..7f59254 100644 --- a/api/ebay_webhook.py +++ b/api/ebay_webhook.py @@ -26,11 +26,11 @@ from pathlib import Path from typing import Optional import requests -from fastapi import APIRouter, Header, HTTPException, Request from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ec import ECDSA from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.serialization import load_pem_public_key +from fastapi import APIRouter, Header, HTTPException, Request from app.db.store import Store diff --git a/api/main.py b/api/main.py index e5fd187..1764cfa 100644 --- a/api/main.py +++ b/api/main.py @@ -1,8 +1,11 @@ """Snipe FastAPI — search endpoint wired to ScrapedEbayAdapter + TrustScorer.""" from __future__ import annotations +import asyncio +import csv import dataclasses import hashlib +import io import json as _json import logging import os @@ -12,28 +15,25 @@ from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager 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.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.models import SavedSearch as SavedSearchModel, ScammerEntry from app.platforms import SearchFilters -from app.platforms.ebay.scraper import ScrapedEbayAdapter from app.platforms.ebay.adapter import EbayAdapter from app.platforms.ebay.auth import EbayTokenManager from app.platforms.ebay.query_builder import expand_queries, parse_groups +from app.platforms.ebay.scraper import ScrapedEbayAdapter 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")) log = logging.getLogger(__name__) @@ -50,8 +50,8 @@ async def _lifespan(app: FastAPI): # Start vision/LLM background task scheduler. # 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. + from api.cloud_session import _LOCAL_SNIPE_DB, CLOUD_MODE, _shared_db_path 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 get_scheduler(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. """ 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.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 = get_scheduler(sched_db) diff --git a/app/db/models.py b/app/db/models.py index 3f0acde..08a3eaa 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,5 +1,6 @@ """Dataclasses for all Snipe domain objects.""" from __future__ import annotations + from dataclasses import dataclass, field from typing import Optional diff --git a/app/db/store.py b/app/db/store.py index b246fa8..981fb72 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1,5 +1,6 @@ """Thin SQLite read/write layer for all Snipe models.""" from __future__ import annotations + import json from datetime import datetime, timezone from pathlib import Path @@ -7,7 +8,7 @@ from typing import Optional 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" diff --git a/app/platforms/__init__.py b/app/platforms/__init__.py index 93bd054..1ed2dd0 100644 --- a/app/platforms/__init__.py +++ b/app/platforms/__init__.py @@ -1,8 +1,10 @@ """PlatformAdapter abstract base and shared types.""" from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional + from app.db.models import Listing, Seller diff --git a/app/platforms/ebay/adapter.py b/app/platforms/ebay/adapter.py index 75ccafa..4b729b9 100644 --- a/app/platforms/ebay/adapter.py +++ b/app/platforms/ebay/adapter.py @@ -1,10 +1,12 @@ """eBay Browse API adapter.""" from __future__ import annotations + import hashlib import logging from dataclasses import replace from datetime import datetime, timedelta, timezone from typing import Optional + import requests 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 _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.platforms import PlatformAdapter, SearchFilters from app.platforms.ebay.auth import EbayTokenManager diff --git a/app/platforms/ebay/auth.py b/app/platforms/ebay/auth.py index f04c4cd..c20a06d 100644 --- a/app/platforms/ebay/auth.py +++ b/app/platforms/ebay/auth.py @@ -1,8 +1,10 @@ """eBay OAuth2 client credentials token manager.""" from __future__ import annotations + import base64 import time from typing import Optional + import requests EBAY_OAUTH_URLS = { diff --git a/app/platforms/ebay/normaliser.py b/app/platforms/ebay/normaliser.py index 99f4921..063a104 100644 --- a/app/platforms/ebay/normaliser.py +++ b/app/platforms/ebay/normaliser.py @@ -1,8 +1,10 @@ """Convert raw eBay API responses into Snipe domain objects.""" from __future__ import annotations + import json from datetime import datetime, timezone from typing import Optional + from app.db.models import Listing, Seller diff --git a/app/platforms/ebay/scraper.py b/app/platforms/ebay/scraper.py index 2635f08..1f42f7d 100644 --- a/app/platforms/ebay/scraper.py +++ b/app/platforms/ebay/scraper.py @@ -16,7 +16,7 @@ import json import logging import re import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from typing import Optional @@ -302,7 +302,8 @@ class ScrapedEbayAdapter(PlatformAdapter): time.sleep(self._delay) - import subprocess, os + import os + import subprocess display_num = next(_display_counter) display = f":{display_num}" xvfb = subprocess.Popen( @@ -313,8 +314,10 @@ class ScrapedEbayAdapter(PlatformAdapter): env["DISPLAY"] = display try: - from playwright.sync_api import sync_playwright # noqa: PLC0415 — lazy: only needed in Docker - from playwright_stealth import Stealth # noqa: PLC0415 + from playwright.sync_api import ( + sync_playwright, # noqa: PLC0415 — lazy: only needed in Docker + ) + from playwright_stealth import Stealth # noqa: PLC0415 with sync_playwright() as pw: browser = pw.chromium.launch( diff --git a/app/tasks/runner.py b/app/tasks/runner.py index beea57a..2b41b4c 100644 --- a/app/tasks/runner.py +++ b/app/tasks/runner.py @@ -19,7 +19,6 @@ import logging from pathlib import Path import requests - from circuitforge_core.db import get_connection from circuitforge_core.llm import LLMRouter diff --git a/app/tasks/scheduler.py b/app/tasks/scheduler.py index a45e0ae..74fabd4 100644 --- a/app/tasks/scheduler.py +++ b/app/tasks/scheduler.py @@ -5,9 +5,10 @@ from __future__ import annotations from pathlib import Path from circuitforge_core.tasks.scheduler import ( - TaskScheduler, + TaskScheduler, # re-export for tests +) +from circuitforge_core.tasks.scheduler import ( get_scheduler as _base_get_scheduler, - reset_scheduler, # re-export for tests ) from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task diff --git a/app/tiers.py b/app/tiers.py index b355466..d41eafd 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -14,7 +14,8 @@ Intentionally ungated (free for all): - saved_searches — retention feature; friction cost outweighs gate value """ 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. FEATURES: dict[str, str] = { diff --git a/app/trust/__init__.py b/app/trust/__init__.py index 8caf0a1..deaa328 100644 --- a/app/trust/__init__.py +++ b/app/trust/__init__.py @@ -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 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: """Orchestrates metadata + photo scoring for a batch of listings.""" diff --git a/app/trust/aggregator.py b/app/trust/aggregator.py index 974c45f..5153b14 100644 --- a/app/trust/aggregator.py +++ b/app/trust/aggregator.py @@ -1,8 +1,10 @@ """Composite score and red flag extraction.""" from __future__ import annotations + import json -from datetime import datetime, timezone +from datetime import datetime from typing import Optional + from app.db.models import Seller, TrustScore HARD_FILTER_AGE_DAYS = 7 diff --git a/app/trust/metadata.py b/app/trust/metadata.py index 9ce88a6..f672c6d 100644 --- a/app/trust/metadata.py +++ b/app/trust/metadata.py @@ -1,7 +1,9 @@ """Five metadata trust signals, each scored 0–20.""" from __future__ import annotations + import json from typing import Optional + from app.db.models import Seller ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"} diff --git a/app/trust/photo.py b/app/trust/photo.py index 78301b9..9fb541f 100644 --- a/app/trust/photo.py +++ b/app/trust/photo.py @@ -1,7 +1,9 @@ """Perceptual hash deduplication within a result set (free tier, v0.1).""" from __future__ import annotations -from typing import Optional + import io +from typing import Optional + import requests try: diff --git a/app/ui/Search.py b/app/ui/Search.py index 0b65650..5130d81 100644 --- a/app/ui/Search.py +++ b/app/ui/Search.py @@ -1,19 +1,24 @@ """Main search + results page.""" from __future__ import annotations + import logging import os from pathlib import Path + import streamlit as st from circuitforge_core.config import load_env + from app.db.store import Store from app.platforms import PlatformAdapter, SearchFilters 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 ( - inject_steal_css, check_snipe_mode, render_snipe_mode_banner, 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__) diff --git a/app/ui/components/easter_eggs.py b/app/ui/components/easter_eggs.py index fddf033..4df09a7 100644 --- a/app/ui/components/easter_eggs.py +++ b/app/ui/components/easter_eggs.py @@ -22,7 +22,6 @@ import streamlit as st from app.db.models import Listing, TrustScore - # --------------------------------------------------------------------------- # 1. Konami → Snipe Mode # --------------------------------------------------------------------------- diff --git a/app/ui/components/filters.py b/app/ui/components/filters.py index 6756939..09fffbb 100644 --- a/app/ui/components/filters.py +++ b/app/ui/components/filters.py @@ -1,9 +1,12 @@ """Build dynamic filter options from a result set and render the Streamlit sidebar.""" from __future__ import annotations + import json from dataclasses import dataclass, field from typing import Optional + import streamlit as st + from app.db.models import Listing, TrustScore diff --git a/app/ui/components/listing_row.py b/app/ui/components/listing_row.py index 17056be..256fb98 100644 --- a/app/ui/components/listing_row.py +++ b/app/ui/components/listing_row.py @@ -1,13 +1,17 @@ """Render a single listing row with trust score, badges, and error states.""" from __future__ import annotations + import json from typing import Optional 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 ( - is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining, + auction_hours_remaining, + is_steal, + render_auction_notice, + render_steal_banner, ) diff --git a/app/wizard/setup.py b/app/wizard/setup.py index 2ac8187..0bd3552 100644 --- a/app/wizard/setup.py +++ b/app/wizard/setup.py @@ -1,6 +1,8 @@ """First-run wizard: collect eBay credentials and write .env.""" from __future__ import annotations + from pathlib import Path + import streamlit as st from circuitforge_core.wizard import BaseWizard diff --git a/pyproject.toml b/pyproject.toml index 57ae0f6..5154d80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,23 @@ dependencies = [ "PyJWT>=2.8", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.4", +] + [tool.setuptools.packages.find] where = ["."] include = ["app*", "api*"] [tool.pytest.ini_options] 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"] diff --git a/tests/db/test_store.py b/tests/db/test_store.py index 26e60ac..dd37014 100644 --- a/tests/db/test_store.py +++ b/tests/db/test_store.py @@ -1,8 +1,9 @@ -import pytest 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.models import Listing, Seller, TrustScore, MarketComp @pytest.fixture diff --git a/tests/platforms/test_ebay_auth.py b/tests/platforms/test_ebay_auth.py index e16b517..3944c7c 100644 --- a/tests/platforms/test_ebay_auth.py +++ b/tests/platforms/test_ebay_auth.py @@ -1,7 +1,9 @@ import time -import requests -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import pytest +import requests + from app.platforms.ebay.auth import EbayTokenManager diff --git a/tests/platforms/test_ebay_normaliser.py b/tests/platforms/test_ebay_normaliser.py index ebb75fa..e8b0eaa 100644 --- a/tests/platforms/test_ebay_normaliser.py +++ b/tests/platforms/test_ebay_normaliser.py @@ -1,4 +1,5 @@ import pytest + from app.platforms.ebay.normaliser import normalise_listing, normalise_seller diff --git a/tests/platforms/test_ebay_scraper.py b/tests/platforms/test_ebay_scraper.py index a4c8519..a9cae13 100644 --- a/tests/platforms/test_ebay_scraper.py +++ b/tests/platforms/test_ebay_scraper.py @@ -3,16 +3,18 @@ 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. """ -import pytest from datetime import timedelta + +import pytest +from bs4 import BeautifulSoup + from app.platforms.ebay.scraper import ( - scrape_listings, - scrape_sellers, + _extract_seller_from_card, _parse_price, _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) diff --git a/tests/test_feedback.py b/tests/test_feedback.py index e5e7e89..f75ff8c 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -4,12 +4,10 @@ from __future__ import annotations from collections.abc import Callable from unittest.mock import MagicMock, patch +from circuitforge_core.api.feedback import make_feedback_router from fastapi import FastAPI from fastapi.testclient import TestClient -from circuitforge_core.api.feedback import make_feedback_router - - # ── Test app factory ────────────────────────────────────────────────────────── def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient: diff --git a/tests/test_tasks/test_runner.py b/tests/test_tasks/test_runner.py index 0a44b7c..c9c13db 100644 --- a/tests/test_tasks/test_runner.py +++ b/tests/test_tasks/test_runner.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import sqlite3 from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest diff --git a/tests/test_tiers.py b/tests/test_tiers.py index 11dc250..18c0162 100644 --- a/tests/test_tiers.py +++ b/tests/test_tiers.py @@ -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(): diff --git a/tests/ui/test_easter_eggs.py b/tests/ui/test_easter_eggs.py index 9551552..1774002 100644 --- a/tests/ui/test_easter_eggs.py +++ b/tests/ui/test_easter_eggs.py @@ -4,10 +4,8 @@ from __future__ import annotations import json from datetime import datetime, timedelta, timezone -import pytest - 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: