Merge pull request #47 from barrett-ruth/feat/testing

Misc QOL Improvements
This commit is contained in:
Barrett Ruth 2025-09-19 04:18:09 +02:00 committed by GitHub
commit 2d3fc0625f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 689 additions and 194 deletions

View file

@ -51,7 +51,7 @@ jobs:
- name: Install ruff - name: Install ruff
run: uv tool install ruff run: uv tool install ruff
- name: Check Python formatting with ruff - name: Check Python formatting with ruff
run: ruff format --check scrapers/ run: ruff format --check scrapers/ tests/scrapers/
python-lint: python-lint:
name: Python Linting name: Python Linting
@ -63,4 +63,28 @@ jobs:
- name: Install ruff - name: Install ruff
run: uv tool install ruff run: uv tool install ruff
- name: Lint Python files with ruff - name: Lint Python files with ruff
run: ruff check scrapers/ run: ruff check scrapers/ tests/scrapers/
python-typecheck:
name: Python Type Checking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with mypy
run: uv sync --dev
- name: Type check Python files with mypy
run: uv run mypy scrapers/ tests/scrapers/
python-test:
name: Python Testing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with pytest
run: uv sync --dev
- name: Run Python tests
run: uv run pytest tests/scrapers/ -v

View file

@ -9,3 +9,18 @@ dependencies = [
"cloudscraper>=1.2.71", "cloudscraper>=1.2.71",
"requests>=2.32.5", "requests>=2.32.5",
] ]
[dependency-groups]
dev = [
"mypy>=1.18.2",
"types-beautifulsoup4>=4.12.0.20250516",
"types-requests>=2.32.4.20250913",
"pytest>=8.0.0",
"pytest-mock>=3.12.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.mypy]
ignore_missing_imports = true

0
scrapers/__init__.py Normal file
View file

View file

@ -4,7 +4,7 @@ import json
import sys import sys
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup, Tag
def parse_problem_url(contest_id: str, problem_letter: str) -> str: def parse_problem_url(contest_id: str, problem_letter: str) -> str:
@ -23,7 +23,6 @@ def extract_problem_from_row(row, contest_id: str) -> dict[str, str] | None:
task_name = task_link.get_text(strip=True) task_name = task_link.get_text(strip=True)
task_href = task_link.get("href", "") task_href = task_link.get("href", "")
if not task_href: if not task_href:
return None return None
@ -50,13 +49,11 @@ def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]:
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
task_table = soup.find("table", class_="table") task_table = soup.find("table", class_="table")
if not task_table or not isinstance(task_table, Tag):
if not task_table:
return [] return []
rows = task_table.find_all("tr")[1:] rows = task_table.find_all("tr")[1:]
problems = [] problems: list[dict[str, str]] = []
for row in rows: for row in rows:
problem = extract_problem_from_row(row, contest_id) problem = extract_problem_from_row(row, contest_id)
if problem: if problem:
@ -92,7 +89,6 @@ def extract_test_case_from_headers(sample_headers, i: int) -> tuple[str, str] |
input_text = input_pre.get_text().strip().replace("\r", "") input_text = input_pre.get_text().strip().replace("\r", "")
output_text = output_pre.get_text().strip().replace("\r", "") output_text = output_pre.get_text().strip().replace("\r", "")
if not input_text or not output_text: if not input_text or not output_text:
return None return None
@ -109,14 +105,12 @@ def scrape(url: str) -> list[tuple[str, str]]:
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
sample_headers = soup.find_all( sample_headers = soup.find_all(
"h3", string=lambda x: x and "sample" in x.lower() if x else False "h3", string=lambda x: x and "sample" in x.lower() if x else False
) )
tests = [] tests: list[tuple[str, str]] = []
i = 0 i = 0
while i < len(sample_headers): while i < len(sample_headers):
test_case = extract_test_case_from_headers(sample_headers, i) test_case = extract_test_case_from_headers(sample_headers, i)
if test_case: if test_case:
@ -134,88 +128,111 @@ def scrape(url: str) -> list[tuple[str, str]]:
def main() -> None: def main() -> None:
if len(sys.argv) < 2: if len(sys.argv) < 2:
result: dict[str, str | bool] = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> <problem_letter>", "error": "Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> <problem_letter>",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
mode: str = sys.argv[1] mode: str = sys.argv[1]
if mode == "metadata": if mode == "metadata":
if len(sys.argv) != 3: if len(sys.argv) != 3:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: atcoder.py metadata <contest_id>", "error": "Usage: atcoder.py metadata <contest_id>",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
contest_id: str = sys.argv[2] contest_id: str = sys.argv[2]
problems: list[dict[str, str]] = scrape_contest_problems(contest_id) problems: list[dict[str, str]] = scrape_contest_problems(contest_id)
if not problems: if not problems:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"No problems found for contest {contest_id}", "error": f"No problems found for contest {contest_id}",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
result = { print(
json.dumps(
{
"success": True, "success": True,
"contest_id": contest_id, "contest_id": contest_id,
"problems": problems, "problems": problems,
} }
print(json.dumps(result)) )
)
elif mode == "tests": elif mode == "tests":
if len(sys.argv) != 4: if len(sys.argv) != 4:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: atcoder.py tests <contest_id> <problem_letter>", "error": "Usage: atcoder.py tests <contest_id> <problem_letter>",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
contest_id: str = sys.argv[2] test_contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3] problem_letter: str = sys.argv[3]
problem_id: str = contest_id + problem_letter.lower() problem_id: str = f"{test_contest_id}_{problem_letter.lower()}"
url: str = parse_problem_url(contest_id, problem_letter) url: str = parse_problem_url(test_contest_id, problem_letter)
print(f"Scraping: {url}", file=sys.stderr) print(f"Scraping: {url}", file=sys.stderr)
tests: list[tuple[str, str]] = scrape(url) tests: list[tuple[str, str]] = scrape(url)
if not tests: if not tests:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"No tests found for {contest_id} {problem_letter}", "error": f"No tests found for {test_contest_id} {problem_letter}",
"problem_id": problem_id, "problem_id": problem_id,
"url": url, "url": url,
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
test_list: list[dict[str, str]] = [] test_list: list[dict[str, str]] = [
for input_data, output_data in tests: {"input": i, "expected": o} for i, o in tests
test_list.append({"input": input_data, "expected": output_data}) ]
result = { print(
json.dumps(
{
"success": True, "success": True,
"problem_id": problem_id, "problem_id": problem_id,
"url": url, "url": url,
"tests": test_list, "tests": test_list,
} }
print(json.dumps(result)) )
)
else: else:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'", "error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)

View file

@ -2,12 +2,15 @@
import json import json
import sys import sys
from dataclasses import asdict
import cloudscraper import cloudscraper
from bs4 import BeautifulSoup from bs4 import BeautifulSoup, Tag
from .models import MetadataResult, Problem, TestCase, TestsResult
def scrape(url: str) -> list[tuple[str, str]]: def scrape(url: str) -> list[TestCase]:
try: try:
scraper = cloudscraper.create_scraper() scraper = cloudscraper.create_scraper()
response = scraper.get(url, timeout=10) response = scraper.get(url, timeout=10)
@ -17,12 +20,12 @@ def scrape(url: str) -> list[tuple[str, str]]:
input_sections = soup.find_all("div", class_="input") input_sections = soup.find_all("div", class_="input")
output_sections = soup.find_all("div", class_="output") output_sections = soup.find_all("div", class_="output")
individual_inputs = {} individual_inputs: dict[str, list[str]] = {}
individual_outputs = {} individual_outputs: dict[str, list[str]] = {}
for inp_section in input_sections: for inp_section in input_sections:
inp_pre = inp_section.find("pre") inp_pre = inp_section.find("pre")
if not inp_pre: if not inp_pre or not isinstance(inp_pre, Tag):
continue continue
test_line_divs = inp_pre.find_all( test_line_divs = inp_pre.find_all(
@ -51,7 +54,7 @@ def scrape(url: str) -> list[tuple[str, str]]:
for out_section in output_sections: for out_section in output_sections:
out_pre = out_section.find("pre") out_pre = out_section.find("pre")
if not out_pre: if not out_pre or not isinstance(out_pre, Tag):
continue continue
test_line_divs = out_pre.find_all( test_line_divs = out_pre.find_all(
@ -88,19 +91,19 @@ def scrape(url: str) -> list[tuple[str, str]]:
input_text = "\n".join(individual_inputs[test_num]) input_text = "\n".join(individual_inputs[test_num])
output_text = "\n".join(individual_outputs[test_num]) output_text = "\n".join(individual_outputs[test_num])
prefixed_input = "1\n" + input_text prefixed_input = "1\n" + input_text
tests.append((prefixed_input, output_text)) tests.append(TestCase(input=prefixed_input, expected=output_text))
return tests return tests
all_inputs = [] all_inputs = []
all_outputs = [] all_outputs = []
for inp_section in input_sections: for inp_section in input_sections:
inp_pre = inp_section.find("pre") inp_pre = inp_section.find("pre")
if not inp_pre: if not inp_pre or not isinstance(inp_pre, Tag):
continue continue
divs = inp_pre.find_all("div") divs = inp_pre.find_all("div")
if divs: if divs:
lines = [div.get_text().strip() for div in divs] lines = [div.get_text().strip() for div in divs if isinstance(div, Tag)]
text = "\n".join(lines) text = "\n".join(lines)
else: else:
text = inp_pre.get_text().replace("\r", "").strip() text = inp_pre.get_text().replace("\r", "").strip()
@ -108,12 +111,12 @@ def scrape(url: str) -> list[tuple[str, str]]:
for out_section in output_sections: for out_section in output_sections:
out_pre = out_section.find("pre") out_pre = out_section.find("pre")
if not out_pre: if not out_pre or not isinstance(out_pre, Tag):
continue continue
divs = out_pre.find_all("div") divs = out_pre.find_all("div")
if divs: if divs:
lines = [div.get_text().strip() for div in divs] lines = [div.get_text().strip() for div in divs if isinstance(div, Tag)]
text = "\n".join(lines) text = "\n".join(lines)
else: else:
text = out_pre.get_text().replace("\r", "").strip() text = out_pre.get_text().replace("\r", "").strip()
@ -124,7 +127,7 @@ def scrape(url: str) -> list[tuple[str, str]]:
combined_input = "\n".join(all_inputs) combined_input = "\n".join(all_inputs)
combined_output = "\n".join(all_outputs) combined_output = "\n".join(all_outputs)
return [(combined_input, combined_output)] return [TestCase(input=combined_input, expected=combined_output)]
except Exception as e: except Exception as e:
print(f"CloudScraper failed: {e}", file=sys.stderr) print(f"CloudScraper failed: {e}", file=sys.stderr)
@ -137,7 +140,7 @@ def parse_problem_url(contest_id: str, problem_letter: str) -> str:
) )
def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]: def scrape_contest_problems(contest_id: str) -> list[Problem]:
try: try:
contest_url: str = f"https://codeforces.com/contest/{contest_id}" contest_url: str = f"https://codeforces.com/contest/{contest_id}"
scraper = cloudscraper.create_scraper() scraper = cloudscraper.create_scraper()
@ -145,28 +148,30 @@ def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]:
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
problems: list[dict[str, str]] = [] problems: list[Problem] = []
problem_links = soup.find_all( problem_links = soup.find_all(
"a", href=lambda x: x and f"/contest/{contest_id}/problem/" in x "a", href=lambda x: x and f"/contest/{contest_id}/problem/" in x
) )
for link in problem_links: for link in problem_links:
href: str = link.get("href", "") if not isinstance(link, Tag):
continue
href: str = str(link.get("href", ""))
if f"/contest/{contest_id}/problem/" in href: if f"/contest/{contest_id}/problem/" in href:
problem_letter: str = href.split("/")[-1].lower() problem_letter: str = href.split("/")[-1].lower()
problem_name: str = link.get_text(strip=True) problem_name: str = link.get_text(strip=True)
if problem_letter and problem_name: if problem_letter and problem_name:
problems.append({"id": problem_letter, "name": problem_name}) problems.append(Problem(id=problem_letter, name=problem_name))
problems.sort(key=lambda x: x["id"]) problems.sort(key=lambda x: x.id)
seen: set[str] = set() seen: set[str] = set()
unique_problems: list[dict[str, str]] = [] unique_problems: list[Problem] = []
for p in problems: for p in problems:
if p["id"] not in seen: if p.id not in seen:
seen.add(p["id"]) seen.add(p.id)
unique_problems.append(p) unique_problems.append(p)
return unique_problems return unique_problems
@ -176,93 +181,79 @@ def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]:
return [] return []
def scrape_sample_tests(url: str) -> list[tuple[str, str]]: def scrape_sample_tests(url: str) -> list[TestCase]:
print(f"Scraping: {url}", file=sys.stderr) print(f"Scraping: {url}", file=sys.stderr)
return scrape(url) return scrape(url)
def main() -> None: def main() -> None:
if len(sys.argv) < 2: if len(sys.argv) < 2:
result: dict[str, str | bool] = { result = MetadataResult(
"success": False, success=False,
"error": "Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> <problem_letter>", error="Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> <problem_letter>",
} )
print(json.dumps(result)) print(json.dumps(asdict(result)))
sys.exit(1) sys.exit(1)
mode: str = sys.argv[1] mode: str = sys.argv[1]
if mode == "metadata": if mode == "metadata":
if len(sys.argv) != 3: if len(sys.argv) != 3:
result: dict[str, str | bool] = { result = MetadataResult(
"success": False, success=False, error="Usage: codeforces.py metadata <contest_id>"
"error": "Usage: codeforces.py metadata <contest_id>", )
} print(json.dumps(asdict(result)))
print(json.dumps(result))
sys.exit(1) sys.exit(1)
contest_id: str = sys.argv[2] contest_id: str = sys.argv[2]
problems: list[dict[str, str]] = scrape_contest_problems(contest_id) problems: list[Problem] = scrape_contest_problems(contest_id)
if not problems: if not problems:
result: dict[str, str | bool] = { result = MetadataResult(
"success": False, success=False, error=f"No problems found for contest {contest_id}"
"error": f"No problems found for contest {contest_id}", )
} print(json.dumps(asdict(result)))
print(json.dumps(result))
sys.exit(1) sys.exit(1)
result: dict[str, str | bool | list] = { result = MetadataResult(success=True, contest_id=contest_id, problems=problems)
"success": True, print(json.dumps(asdict(result)))
"contest_id": contest_id,
"problems": problems,
}
print(json.dumps(result))
elif mode == "tests": elif mode == "tests":
if len(sys.argv) != 4: if len(sys.argv) != 4:
result: dict[str, str | bool] = { tests_result = TestsResult(
"success": False, success=False,
"error": "Usage: codeforces.py tests <contest_id> <problem_letter>", error="Usage: codeforces.py tests <contest_id> <problem_letter>",
} )
print(json.dumps(result)) print(json.dumps(asdict(tests_result)))
sys.exit(1) sys.exit(1)
contest_id: str = sys.argv[2] tests_contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3] problem_letter: str = sys.argv[3]
problem_id: str = contest_id + problem_letter.lower() problem_id: str = tests_contest_id + problem_letter.lower()
url: str = parse_problem_url(contest_id, problem_letter) url: str = parse_problem_url(tests_contest_id, problem_letter)
tests: list[tuple[str, str]] = scrape_sample_tests(url) tests: list[TestCase] = scrape_sample_tests(url)
if not tests: if not tests:
result: dict[str, str | bool] = { tests_result = TestsResult(
"success": False, success=False,
"error": f"No tests found for {contest_id} {problem_letter}", error=f"No tests found for {tests_contest_id} {problem_letter}",
"problem_id": problem_id, problem_id=problem_id,
"url": url, url=url,
} )
print(json.dumps(result)) print(json.dumps(asdict(tests_result)))
sys.exit(1) sys.exit(1)
test_list: list[dict[str, str]] = [] tests_result = TestsResult(
for input_data, output_data in tests: success=True, problem_id=problem_id, url=url, tests=tests
test_list.append({"input": input_data, "expected": output_data}) )
print(json.dumps(asdict(tests_result)))
result: dict[str, str | bool | list] = {
"success": True,
"problem_id": problem_id,
"url": url,
"tests": test_list,
}
print(json.dumps(result))
else: else:
result: dict[str, str | bool] = { result = MetadataResult(
"success": False, success=False, error=f"Unknown mode: {mode}. Use 'metadata' or 'tests'"
"error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'", )
} print(json.dumps(asdict(result)))
print(json.dumps(result))
sys.exit(1) sys.exit(1)

View file

@ -16,7 +16,9 @@ def parse_problem_url(problem_input: str) -> str | None:
def process_problem_element( def process_problem_element(
element, current_category: str, all_categories: dict element,
current_category: str | None,
all_categories: dict[str, list[dict[str, str]]],
) -> str | None: ) -> str | None:
if element.name == "h1": if element.name == "h1":
category_name = element.get_text().strip() category_name = element.get_text().strip()
@ -52,7 +54,7 @@ def scrape_all_problems() -> dict[str, list[dict[str, str]]]:
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
all_categories = {} all_categories: dict[str, list[dict[str, str]]] = {}
problem_links = soup.find_all( problem_links = soup.find_all(
"a", href=lambda x: x and "/problemset/task/" in x "a", href=lambda x: x and "/problemset/task/" in x
@ -127,59 +129,79 @@ def scrape(url: str) -> list[tuple[str, str]]:
def main() -> None: def main() -> None:
if len(sys.argv) < 2: if len(sys.argv) < 2:
result: dict[str, str | bool] = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: cses.py metadata OR cses.py tests <problem_id_or_url>", "error": "Usage: cses.py metadata OR cses.py tests <problem_id_or_url>",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
mode: str = sys.argv[1] mode: str = sys.argv[1]
if mode == "metadata": if mode == "metadata":
if len(sys.argv) != 2: if len(sys.argv) != 2:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: cses.py metadata", "error": "Usage: cses.py metadata",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
all_categories: dict[str, list[dict[str, str]]] = scrape_all_problems() all_categories: dict[str, list[dict[str, str]]] = scrape_all_problems()
if not all_categories: if not all_categories:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Failed to scrape CSES problem categories", "error": "Failed to scrape CSES problem categories",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
result = { print(
json.dumps(
{
"success": True, "success": True,
"categories": all_categories, "categories": all_categories,
} }
print(json.dumps(result)) )
)
elif mode == "tests": elif mode == "tests":
if len(sys.argv) != 3: if len(sys.argv) != 3:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": "Usage: cses.py tests <problem_id_or_url>", "error": "Usage: cses.py tests <problem_id_or_url>",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
problem_input: str = sys.argv[2] problem_input: str = sys.argv[2]
url: str | None = parse_problem_url(problem_input) url: str | None = parse_problem_url(problem_input)
if not url: if not url:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"Invalid problem input: {problem_input}. Use either problem ID (e.g., 1068) or full URL", "error": f"Invalid problem input: {problem_input}. Use either problem ID (e.g., 1068) or full URL",
"problem_id": problem_input if problem_input.isdigit() else None, "problem_id": problem_input
if problem_input.isdigit()
else None,
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
tests: list[tuple[str, str]] = scrape(url) tests: list[tuple[str, str]] = scrape(url)
@ -189,33 +211,42 @@ def main() -> None:
) )
if not tests: if not tests:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"No tests found for {problem_input}", "error": f"No tests found for {problem_input}",
"problem_id": problem_id, "problem_id": problem_id,
"url": url, "url": url,
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)
test_list: list[dict[str, str]] = [] test_list: list[dict[str, str]] = [
for input_data, output_data in tests: {"input": i, "expected": o} for i, o in tests
test_list.append({"input": input_data, "expected": output_data}) ]
result = { print(
json.dumps(
{
"success": True, "success": True,
"problem_id": problem_id, "problem_id": problem_id,
"url": url, "url": url,
"tests": test_list, "tests": test_list,
} }
print(json.dumps(result)) )
)
else: else:
result = { print(
json.dumps(
{
"success": False, "success": False,
"error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'", "error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'",
} }
print(json.dumps(result)) )
)
sys.exit(1) sys.exit(1)

41
scrapers/models.py Normal file
View file

@ -0,0 +1,41 @@
from dataclasses import dataclass
@dataclass
class TestCase:
input: str
expected: str
@dataclass
class Problem:
id: str
name: str
@dataclass
class ScrapingResult:
success: bool
error: str | None = None
@dataclass
class MetadataResult(ScrapingResult):
contest_id: str | None = None
problems: list[Problem] | None = None
categories: dict[str, list[Problem]] | None = None
def __post_init__(self):
if self.problems is None:
self.problems = []
@dataclass
class TestsResult(ScrapingResult):
problem_id: str = ""
url: str = ""
tests: list[TestCase] | None = None
def __post_init__(self):
if self.tests is None:
self.tests = []

View file

@ -0,0 +1,41 @@
import pytest
@pytest.fixture
def mock_codeforces_html():
return """
<div class="input">
<pre>
<div class="test-example-line-1">3</div>
<div class="test-example-line-1">1 2 3</div>
</pre>
</div>
<div class="output">
<pre>
<div class="test-example-line-1">6</div>
</pre>
</div>
"""
@pytest.fixture
def mock_atcoder_html():
return """
<h3>Sample Input 1</h3>
<pre>3
1 2 3</pre>
<h3>Sample Output 1</h3>
<pre>6</pre>
"""
@pytest.fixture
def mock_cses_html():
return """
<h1>Example</h1>
<p>Input:</p>
<pre>3
1 2 3</pre>
<p>Output:</p>
<pre>6</pre>
"""

View file

@ -0,0 +1,50 @@
from unittest.mock import Mock
from scrapers.atcoder import scrape, scrape_contest_problems
def test_scrape_success(mocker, mock_atcoder_html):
mock_response = Mock()
mock_response.text = mock_atcoder_html
mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response)
result = scrape("https://atcoder.jp/contests/abc350/tasks/abc350_a")
assert len(result) == 1
assert result[0][0] == "3\n1 2 3"
assert result[0][1] == "6"
def test_scrape_contest_problems(mocker):
mock_response = Mock()
mock_response.text = """
<table class="table">
<tr><th>Task</th><th>Name</th></tr>
<tr>
<td></td>
<td><a href="/contests/abc350/tasks/abc350_a">A - Water Tank</a></td>
</tr>
<tr>
<td></td>
<td><a href="/contests/abc350/tasks/abc350_b">B - Dentist Aoki</a></td>
</tr>
</table>
"""
mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response)
result = scrape_contest_problems("abc350")
assert len(result) == 2
assert result[0] == {"id": "a", "name": "A - Water Tank"}
assert result[1] == {"id": "b", "name": "B - Dentist Aoki"}
def test_scrape_network_error(mocker):
mocker.patch(
"scrapers.atcoder.requests.get", side_effect=Exception("Network error")
)
result = scrape("https://atcoder.jp/contests/abc350/tasks/abc350_a")
assert result == []

View file

@ -0,0 +1,53 @@
from unittest.mock import Mock
from scrapers.codeforces import scrape, scrape_contest_problems
from scrapers.models import Problem
def test_scrape_success(mocker, mock_codeforces_html):
mock_scraper = Mock()
mock_response = Mock()
mock_response.text = mock_codeforces_html
mock_scraper.get.return_value = mock_response
mocker.patch(
"scrapers.codeforces.cloudscraper.create_scraper", return_value=mock_scraper
)
result = scrape("https://codeforces.com/contest/1900/problem/A")
assert len(result) == 1
assert result[0].input == "1\n3\n1 2 3"
assert result[0].expected == "6"
def test_scrape_contest_problems(mocker):
mock_scraper = Mock()
mock_response = Mock()
mock_response.text = """
<a href="/contest/1900/problem/A">A. Problem A</a>
<a href="/contest/1900/problem/B">B. Problem B</a>
"""
mock_scraper.get.return_value = mock_response
mocker.patch(
"scrapers.codeforces.cloudscraper.create_scraper", return_value=mock_scraper
)
result = scrape_contest_problems("1900")
assert len(result) == 2
assert result[0] == Problem(id="a", name="A. Problem A")
assert result[1] == Problem(id="b", name="B. Problem B")
def test_scrape_network_error(mocker):
mock_scraper = Mock()
mock_scraper.get.side_effect = Exception("Network error")
mocker.patch(
"scrapers.codeforces.cloudscraper.create_scraper", return_value=mock_scraper
)
result = scrape("https://codeforces.com/contest/1900/problem/A")
assert result == []

View file

@ -0,0 +1,46 @@
from unittest.mock import Mock
from scrapers.cses import scrape, scrape_all_problems
def test_scrape_success(mocker, mock_cses_html):
mock_response = Mock()
mock_response.text = mock_cses_html
mocker.patch("scrapers.cses.requests.get", return_value=mock_response)
result = scrape("https://cses.fi/problemset/task/1068")
assert len(result) == 1
assert result[0][0] == "3\n1 2 3"
assert result[0][1] == "6"
def test_scrape_all_problems(mocker):
mock_response = Mock()
mock_response.text = """
<h1>Introductory Problems</h1>
<a href="/problemset/task/1068">Weird Algorithm</a>
<a href="/problemset/task/1083">Missing Number</a>
<h1>Sorting and Searching</h1>
<a href="/problemset/task/1084">Apartments</a>
"""
mocker.patch("scrapers.cses.requests.get", return_value=mock_response)
result = scrape_all_problems()
assert "Introductory Problems" in result
assert "Sorting and Searching" in result
assert len(result["Introductory Problems"]) == 2
assert result["Introductory Problems"][0] == {
"id": "1068",
"name": "Weird Algorithm",
}
def test_scrape_network_error(mocker):
mocker.patch("scrapers.cses.requests.get", side_effect=Exception("Network error"))
result = scrape("https://cses.fi/problemset/task/1068")
assert result == []

180
uv.lock generated
View file

@ -91,6 +91,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/97/fc88803a451029688dffd7eb446dc1b529657577aec13aceff1cc9628c5d/cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0", size = 99652, upload-time = "2023-04-25T23:20:15.974Z" }, { url = "https://files.pythonhosted.org/packages/81/97/fc88803a451029688dffd7eb446dc1b529657577aec13aceff1cc9628c5d/cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0", size = 99652, upload-time = "2023-04-25T23:20:15.974Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@ -100,6 +109,98 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "mypy"
version = "1.18.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" },
{ url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" },
{ url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" },
{ url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" },
{ url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
{ url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" },
{ url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.2.3" version = "3.2.3"
@ -109,6 +210,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" },
] ]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@ -146,6 +275,15 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
] ]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-mock" },
{ name = "types-beautifulsoup4" },
{ name = "types-requests" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.13.5" }, { name = "beautifulsoup4", specifier = ">=4.13.5" },
@ -153,6 +291,15 @@ requires-dist = [
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
] ]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.18.2" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-mock", specifier = ">=3.12.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" },
{ name = "types-requests", specifier = ">=2.32.4.20250913" },
]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.8" version = "2.8"
@ -162,6 +309,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
] ]
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20250516"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-html5lib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/d1/32b410f6d65eda94d3dfb0b3d0ca151f12cb1dc4cef731dcf7cbfd8716ff/types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e", size = 16628, upload-time = "2025-05-16T03:09:09.93Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/79/d84de200a80085b32f12c5820d4fd0addcbe7ba6dce8c1c9d8605e833c8e/types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee", size = 16879, upload-time = "2025-05-16T03:09:09.051Z" },
]
[[package]]
name = "types-html5lib"
version = "1.1.11.20250917"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/4b/a970718e8bd9324ee8fb8eaf02ff069f6d03c20d4523bb4232892ecc3d06/types_html5lib-1.1.11.20250917.tar.gz", hash = "sha256:7b52743377f33f9b4fd7385afbd2d457b8864ee51f90ff2a795ad9e8c053373a", size = 16868, upload-time = "2025-09-17T02:47:41.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/8a/da91a9c64dcb5e69beb567519857411996d8ecae9f6f128bcef8260e7a8d/types_html5lib-1.1.11.20250917-py3-none-any.whl", hash = "sha256:b294fd06d60da205daeb2f615485ca4d475088d2eff1009cf427f4a80fcd5346", size = 22908, upload-time = "2025-09-17T02:47:40.39Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20250913"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.14.1"

View file

@ -16,3 +16,9 @@ any = true
[it] [it]
any = true any = true
[before_each]
any = true
[after_each]
any = true