diff --git a/Makefile b/Makefile index f9678f8..ba19f48 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ test-force: ## Run tests in a Docker container while ignoring any stored state test-debug: ## Spawn an interactive shell in the test container to debug @docker compose build - @docker compose run --rm test /bin/bash + @docker compose run --rm with-sast /bin/bash docs-serve: ## Serve the documentation locally @mkdocs serve --livereload \ No newline at end of file diff --git a/codesectools/cli.py b/codesectools/cli.py index d03e013..4f421b0 100755 --- a/codesectools/cli.py +++ b/codesectools/cli.py @@ -160,8 +160,8 @@ def get_downloadable() -> dict[str, DownloadableRequirement | Dataset]: @cli.command(hidden=download_hidden) -def download(name: download_arg_type = download_arg_value) -> None: - """Download any missing resources that are available for download.""" +def download(name: download_arg_type = download_arg_value, test: bool = False) -> None: + """Download and install any missing resources that are available for download.""" if name is None: print("All downloadable resources have been retrieved.") else: @@ -174,7 +174,7 @@ def download(name: download_arg_type = download_arg_value) -> None: if isinstance(downloadable, DownloadableRequirement): downloadable.download() else: - downloadable.download_dataset() + downloadable.download_dataset(test=test) cli.add_typer(build_all_sast_cli()) diff --git a/codesectools/datasets/BenchmarkJava/dataset.py b/codesectools/datasets/BenchmarkJava/dataset.py index 9745603..d45bb88 100644 --- a/codesectools/datasets/BenchmarkJava/dataset.py +++ b/codesectools/datasets/BenchmarkJava/dataset.py @@ -6,6 +6,7 @@ """ import csv +import random from pathlib import Path from typing import Self @@ -99,12 +100,28 @@ def __eq__(self, other: str | Self) -> bool: else: return False - def download_files(self: Self) -> None: - """Download the dataset files from the official Git repository.""" + def download_files(self: Self, test: bool = False) -> None: + """Download the dataset files from the official Git repository. + + Clones the BenchmarkJava repository and, if in test mode, prunes it to a smaller size. + + Args: + test: If True, reduce the number of test files for faster testing. + + """ git.Repo.clone_from( "https://github.com/OWASP-Benchmark/BenchmarkJava.git", self.directory ) + if test: + testcodes = list( + ( + self.directory / "src/main/java/org/owasp/benchmark/testcode" + ).iterdir() + ) + for to_delete_testcode in random.sample(testcodes, k=len(testcodes) - 50): + to_delete_testcode.unlink() + def load_dataset(self) -> list[TestCode]: """Load the BenchmarkJava dataset from its source files. diff --git a/codesectools/datasets/CVEfixes/dataset.py b/codesectools/datasets/CVEfixes/dataset.py index 7e39bf6..e0ba788 100644 --- a/codesectools/datasets/CVEfixes/dataset.py +++ b/codesectools/datasets/CVEfixes/dataset.py @@ -42,7 +42,7 @@ def __init__(self, lang: str | None = None) -> None: self.max_repo_size = 100e6 super().__init__(lang) - def download_files(self: Self) -> None: + def download_files(self: Self, test: bool = False) -> None: """Copy the dataset files from the package data directory to the user cache.""" self.directory.mkdir(exist_ok=True, parents=True) license_file = DATA_DIR / self.name / "LICENSE" diff --git a/codesectools/datasets/core/dataset.py b/codesectools/datasets/core/dataset.py index 9d07f5f..7acc461 100644 --- a/codesectools/datasets/core/dataset.py +++ b/codesectools/datasets/core/dataset.py @@ -94,16 +94,33 @@ def prompt_license_agreement(self) -> None: raise typer.Exit(code=1) @abstractmethod - def download_files(self) -> None: - """Download the raw dataset files.""" + def download_files(self, test: bool = False) -> None: + """Download the raw dataset files. + + This method must be implemented by subclasses to define how the + raw files for the dataset are obtained. + + Args: + test: If True, download a smaller subset of the dataset for testing. + + """ pass - def download_dataset(self) -> None: - """Handle the full dataset download process, including license prompt and caching.""" + def download_dataset(self, test: bool = False) -> None: + """Handle the full dataset download process, including license prompt and caching. + + This method orchestrates the download by first prompting for license + agreement, then calling the `download_files` method, and finally creating + a `.complete` file to mark the dataset as cached. + + Args: + test: If True, download a smaller subset of the dataset for testing. + + """ self.prompt_license_agreement() with Progress() as progress: progress.add_task(f"Downloading [b]{self.name}[/b]...", total=None) - self.download_files() + self.download_files(test=test) (self.directory / ".complete").write_bytes(b"\x42") print(f"[b]{self.name}[/b] has been downloaded at {self.directory}.") diff --git a/codesectools/sasts/all/cli.py b/codesectools/sasts/all/cli.py index 5ac4381..62a9eb7 100644 --- a/codesectools/sasts/all/cli.py +++ b/codesectools/sasts/all/cli.py @@ -20,7 +20,8 @@ from codesectools.sasts import SASTS_ALL from codesectools.sasts.all.graphics import ProjectGraphics from codesectools.sasts.all.sast import AllSAST -from codesectools.sasts.core.sast import PrebuiltSAST +from codesectools.sasts.core.sast import PrebuiltBuildlessSAST, PrebuiltSAST +from codesectools.utils import group_successive def build_cli() -> typer.Typer: @@ -72,11 +73,11 @@ def analyze( ), ], # Additional options - artifact_dir: Annotated[ + artifacts: Annotated[ Path | None, typer.Option( help="Pre-built artifacts directory (for PrebuiltSAST only)", - metavar="ARTIFACT_DIR", + metavar="ARTIFACTS", ), ] = None, # Common NOT REQUIRED option @@ -90,27 +91,28 @@ def analyze( ) -> None: """Run analysis on the current project with all available SAST tools.""" for sast in all_sast.sasts_by_lang.get(lang, []): - if isinstance(sast, PrebuiltSAST) and artifact_dir is None: - print(f"{sast.name} required pre-built artifacts for analysis") + if isinstance(sast, PrebuiltBuildlessSAST) and artifacts is None: print( - "Please provide the directory with artifacts (with --artifact-dir) to include this tool" + f"[i]{sast.name} can use pre-built artifacts ({sast.artefact_name} {sast.artefact_type}) for more accurate analysis" ) + print("[i]Use the flag --artifacts to provide the artifacts") + elif isinstance(sast, PrebuiltSAST) and artifacts is None: + print( + f"[b]Skipping {sast.name} because it requires pre-built artifacts ({sast.artefact_name} {sast.artefact_type})" + ) + print("[b]Use the flag --artifacts to provide the artifacts") continue output_dir = sast.output_dir / Path.cwd().name if output_dir.is_dir(): if overwrite: shutil.rmtree(output_dir) - sast.run_analysis( - lang, Path.cwd(), output_dir, artifact_dir=artifact_dir - ) + sast.run_analysis(lang, Path.cwd(), output_dir, artifacts=artifacts) else: print(f"Found existing analysis result at {output_dir}") print("Use --overwrite to overwrite it") else: - sast.run_analysis( - lang, Path.cwd(), output_dir, artifact_dir=artifact_dir - ) + sast.run_analysis(lang, Path.cwd(), output_dir, artifacts=artifacts) @cli.command(help="Benchmark a dataset using all SAST tools.") def benchmark( @@ -323,20 +325,43 @@ def report( defect_table.add_column("SAST", justify="center") defect_table.add_column("CWE", justify="center") defect_table.add_column("Message") - for defect in sorted(set(defect_data["raw"]), key=lambda d: d.location[0]): - if location := defect.location: - start, end = location - shortcut = Text(f"{start}", style=Style(link=f"#L{start}")) + rows = [] + for defect in defect_data["raw"]: + groups = group_successive(defect.lines) + if groups: + for group in groups: + start, end = group[0], group[-1] + shortcut = Text(f"{start}", style=Style(link=f"#L{start}")) + cwe_link = ( + Text( + f"CWE-{defect.cwe.id}", + style=Style( + link=f"https://cwe.mitre.org/data/definitions/{defect.cwe.id}.html" + ), + ) + if defect.cwe.id != -1 + else "None" + ) + rows.append( + (start, shortcut, defect.sast, cwe_link, defect.message) + ) else: - shortcut = "None" - cwe_link = Text( - f"CWE-{defect.cwe.id}", - style=Style( - link=f"https://cwe.mitre.org/data/definitions/{defect.cwe.id}.html" - ), - ) - defect_table.add_row(shortcut, defect.sast, cwe_link, defect.message) + cwe_link = ( + Text( + f"CWE-{defect.cwe.id}", + style=Style( + link=f"https://cwe.mitre.org/data/definitions/{defect.cwe.id}.html" + ), + ) + if defect.cwe.id != -1 + else "None" + ) + rows.append( + (float("inf"), "None", defect.sast, cwe_link, defect.message) + ) + for row in sorted(rows, key=lambda r: r[0]): + defect_table.add_row(*row[1:]) defect_page.print(defect_table) # Syntax @@ -352,7 +377,11 @@ def report( for location in defect_data["locations"]: sast, cwe, message, (start, end) = location for i in range(start, end + 1): - text = f"{sast}: {message} (CWE-{cwe.id})" + text = ( + f"{sast}: {message} (CWE-{cwe.id})" + if cwe.id != -1 + else f"{sast}: {message}" + ) if highlights.get(i): highlights[i].add(text) else: diff --git a/codesectools/sasts/all/parser.py b/codesectools/sasts/all/parser.py index 5cfa13e..a56b16e 100644 --- a/codesectools/sasts/all/parser.py +++ b/codesectools/sasts/all/parser.py @@ -4,6 +4,7 @@ from codesectools.sasts import SASTS_ALL from codesectools.sasts.core.parser import AnalysisResult +from codesectools.utils import group_successive if TYPE_CHECKING: from codesectools.sasts.core.sast import SAST @@ -149,12 +150,10 @@ def stats_by_scores(self) -> dict: defect_locations = {} for defect in defects: - if any(defect.location): - start, end = defect.location - for line in range(start, end + 1): - if not defect_locations.get(line): - defect_locations[line] = [] - defect_locations[line].append(defect) + for line in defect.lines: + if not defect_locations.get(line): + defect_locations[line] = [] + defect_locations[line].append(defect) defects_same_location = 0 defects_same_location_same_cwe = 0 @@ -202,12 +201,11 @@ def prepare_report_data(self) -> dict: locations = [] for defect in defects: - if any(defect.location): - start, end = defect.location - if start and end: - locations.append( - (defect.sast, defect.cwe, defect.message, (start, end)) - ) + for group in group_successive(defect.lines): + start, end = group[0], group[-1] + locations.append( + (defect.sast, defect.cwe, defect.message, (start, end)) + ) report["defects"][defect_file] = { "score": scores[defect_file]["score"], diff --git a/codesectools/sasts/core/cli.py b/codesectools/sasts/core/cli.py index c5e9184..63ee62f 100644 --- a/codesectools/sasts/core/cli.py +++ b/codesectools/sasts/core/cli.py @@ -23,7 +23,7 @@ GitRepoDatasetGraphics, ProjectGraphics, ) -from codesectools.sasts.core.sast import SAST, PrebuiltSAST +from codesectools.sasts.core.sast import SAST, PrebuiltBuildlessSAST, PrebuiltSAST class CLIFactory: @@ -139,17 +139,23 @@ def add_analyze(self: Self, help: str = "") -> None: """ # PrebuiltSAST additional options - if isinstance(self.sast, PrebuiltSAST): - artifact_dir_default = typer.Option( - help="Pre-built artifacts directory", - metavar="ARTIFACT_DIR", + if isinstance(self.sast, PrebuiltBuildlessSAST): + artifacts_default = typer.Option( + default=None, + help=f"Pre-built artifacts ({self.sast.artefact_name} {self.sast.artefact_type}) for more accurate analysis", + metavar="ARTIFACTS", + ) + elif isinstance(self.sast, PrebuiltSAST): + artifacts_default = typer.Option( + help=f"Pre-built artifacts ({self.sast.artefact_name} {self.sast.artefact_type})", + metavar="ARTIFACTS", ) else: - artifact_dir_default = typer.Option( + artifacts_default = typer.Option( default=None, hidden=True, - help="Pre-built artifacts directory (for PrebuiltSAST only)", - metavar="ARTIFACT_DIR", + help="Pre-built artifacts (for PrebuiltSAST only)", + metavar="ARTIFACTS", ) @self.cli.command(help=help) @@ -163,7 +169,7 @@ def analyze( ), ], # Additional REQUIRED options - artifact_dir: Optional[Path] = artifact_dir_default, + artifacts: Optional[Path] = artifacts_default, # Common NOT REQUIRED option overwrite: Annotated[ bool, @@ -177,23 +183,29 @@ def analyze( Args: lang: The source code language to analyze. - artifact_dir: The directory containing pre-built artifacts, required for PrebuiltSAST tools. + artifacts: The path to pre-built artifacts, required for PrebuiltSAST tools. overwrite: If True, overwrite any existing analysis results for the project. """ + if isinstance(self.sast, PrebuiltBuildlessSAST) and artifacts is None: + print( + f"[i]{self.sast.name} can use pre-built artifacts ({self.sast.artefact_name} {self.sast.artefact_type}) for more accurate analysis" + ) + print("[i]Use the flag --artifacts to provide the artifacts") + output_dir = self.sast.output_dir / Path.cwd().name if output_dir.is_dir(): if overwrite: shutil.rmtree(output_dir) self.sast.run_analysis( - lang, Path.cwd(), output_dir, artifact_dir=artifact_dir + lang, Path.cwd(), output_dir, artifacts=artifacts ) else: print(f"Found existing analysis result at {output_dir}") print("Use --overwrite to overwrite it") else: self.sast.run_analysis( - lang, Path.cwd(), output_dir, artifact_dir=artifact_dir + lang, Path.cwd(), output_dir, artifacts=artifacts ) def add_benchmark(self, help: str = "") -> None: diff --git a/codesectools/sasts/core/parser.py b/codesectools/sasts/core/parser.py index 49635f9..1cef54b 100644 --- a/codesectools/sasts/core/parser.py +++ b/codesectools/sasts/core/parser.py @@ -39,7 +39,7 @@ def __init__( category: str, cwe: CWE, message: str, - location: tuple[int] | None, + lines: list[int] | None, data: tuple[Any], ) -> None: """Initialize a Defect instance. @@ -50,7 +50,7 @@ def __init__( category: The category of the checker. cwe: The CWE associated with the defect. message: The description of the defect. - location: A tuple with start and end line numbers of the defect, or None. + lines: A list of line numbers where the defect is located. data: Raw data from the SAST tool for this defect. """ @@ -61,15 +61,9 @@ def __init__( self.category = category self.cwe = cwe self.message = message - self.location = location + self.lines = lines self.data = data - start, end = self.location - if start and not end: - self.location = (start, start) - elif not start and end: - self.location = (end, end) - def __repr__(self) -> str: """Return a developer-friendly string representation of the Defect. diff --git a/codesectools/sasts/core/sast/__init__.py b/codesectools/sasts/core/sast/__init__.py index cf9d6c1..faba25b 100644 --- a/codesectools/sasts/core/sast/__init__.py +++ b/codesectools/sasts/core/sast/__init__.py @@ -13,7 +13,7 @@ import time from abc import ABC from pathlib import Path -from typing import Any +from typing import Any, Literal import git from rich import print @@ -112,7 +112,14 @@ def render_command(self, command: list[str], map: dict[str, str]) -> list[str]: for i, arg in enumerate(_command): if pattern in arg: _command[i] = arg.replace(pattern, value) - return _command + + # Remove not rendered part of the command: + __command = [] + for part in _command: + if not ("{" in part and "}" in part): + __command.append(part) + + return __command def run_analysis( self, lang: str, project_dir: Path, output_dir: Path, **kwargs: Any @@ -362,7 +369,16 @@ class BuildlessSAST(SAST): class PrebuiltSAST(SAST): - """Represent a SAST tool that requires a pre-built project.""" + """Represent a SAST tool that requires pre-built artifacts for analysis. + + Attributes: + artefact_name (str): The name of the expected artifact (e.g., 'Java Bytecode'). + artefact_type (Literal["file", "directory"]): The type of artifact expected. + + """ + + artefact_name: str + artefact_type: Literal["file", "directory"] def analyze_files( self, @@ -421,8 +437,36 @@ def analyze_files( # Run analysis self.run_analysis( - dataset.lang, dataset.directory, result_path, artifact_dir=temp_path + dataset.lang, dataset.directory, result_path, artifacts=temp_path ) # Clear temporary directory temp_dir.cleanup() + + +class PrebuiltBuildlessSAST(PrebuiltSAST, BuildlessSAST): + """Represent a SAST tool that can analyze both source code and pre-built artifacts.""" + + def run_analysis( + self, lang: str, project_dir: Path, output_dir: Path, **kwargs: Any + ) -> None: + """Run analysis, deciding whether to use pre-built or buildless mode. + + If `artifacts` are provided in `kwargs`, it runs the analysis in pre-built mode. + Otherwise, it falls back to the buildless mode, analyzing source code directly. + + Args: + lang: The programming language of the project. + project_dir: The path to the project's source code. + output_dir: The path to save the analysis results. + **kwargs: Additional tool-specific arguments, including optional 'artifacts'. + + """ + if kwargs.get("artifacts"): + return PrebuiltSAST.run_analysis( + self, lang, project_dir, output_dir, **kwargs + ) + else: + return BuildlessSAST.run_analysis( + self, lang, project_dir, output_dir, **kwargs + ) diff --git a/codesectools/sasts/tools/Bearer/parser.py b/codesectools/sasts/tools/Bearer/parser.py index d91d973..7d2e946 100644 --- a/codesectools/sasts/tools/Bearer/parser.py +++ b/codesectools/sasts/tools/Bearer/parser.py @@ -33,7 +33,7 @@ def __init__(self, defect_data: dict, severity: str) -> None: category=severity, cwe=CWEs.from_id(int(defect_data["cwe_ids"][0])), message=defect_data["description"].split("\n")[2], - location=(defect_data["line_number"], defect_data["line_number"]), + lines=[defect_data["line_number"]], data=defect_data, ) diff --git a/codesectools/sasts/tools/Coverity/parser.py b/codesectools/sasts/tools/Coverity/parser.py index 46d8bba..ff8268f 100644 --- a/codesectools/sasts/tools/Coverity/parser.py +++ b/codesectools/sasts/tools/Coverity/parser.py @@ -83,7 +83,7 @@ def __init__(self, defect_data: dict) -> None: category=None, cwe=CWEs.from_id(TYPE_TO_CWE.get(defect_data["type"], -1)), message="", # TODO - location=(defect_data["line"], defect_data["line"]), + lines=[defect_data["line"]], data=defect_data, ) diff --git a/codesectools/sasts/tools/Cppcheck/__init__.py b/codesectools/sasts/tools/Cppcheck/__init__.py new file mode 100644 index 0000000..90debc1 --- /dev/null +++ b/codesectools/sasts/tools/Cppcheck/__init__.py @@ -0,0 +1 @@ +"""Initializes the Cppcheck SAST integration module.""" diff --git a/codesectools/sasts/tools/Cppcheck/cli.py b/codesectools/sasts/tools/Cppcheck/cli.py new file mode 100644 index 0000000..3bf4e4a --- /dev/null +++ b/codesectools/sasts/tools/Cppcheck/cli.py @@ -0,0 +1,10 @@ +"""Defines the command-line interface for the Cppcheck integration. + +This script sets up the `typer` command group for Cppcheck and uses the +`CLIFactory` to generate the standard set of subcommands (analyze, benchmark, etc.). +""" + +from codesectools.sasts.core.cli import CLIFactory +from codesectools.sasts.tools.Cppcheck.sast import CppcheckSAST + +CppcheckCLIFactory = CLIFactory(CppcheckSAST(), custom_messages={"main": "Cppcheck"}) diff --git a/codesectools/sasts/tools/Cppcheck/parser.py b/codesectools/sasts/tools/Cppcheck/parser.py new file mode 100644 index 0000000..7892a10 --- /dev/null +++ b/codesectools/sasts/tools/Cppcheck/parser.py @@ -0,0 +1,121 @@ +"""Provides classes for parsing Cppcheck analysis results. + +This module defines `CppcheckIssue` and `CppcheckAnalysisResult` to process +the XML output from a Cppcheck scan, converting it into the standardized +format used by CodeSecTools. +""" + +import json +from pathlib import Path +from typing import Self + +from lxml import etree +from lxml.etree import ElementTree + +from codesectools.sasts.core.parser import AnalysisResult, Defect +from codesectools.shared.cwe import CWE, CWEs +from codesectools.utils import MissingFile + + +class CppcheckError(Defect): + """Represent a single error reported by Cppcheck.""" + + sast = "Cppcheck" + + def __init__( + self, + filepath: Path, + checker: str, + category: str, + cwe: CWE, + message: str, + lines: list[int] | None, + data: dict, + ) -> None: + """Initialize a CppcheckError instance. + + Args: + filepath: The file path of the defect. + checker: The name of the rule/checker. + category: The category of the checker. + cwe: The CWE associated with the defect. + message: The description of the defect. + lines: A list of line numbers where the defect is located. + data: Raw data from the SAST tool for this defect. + + """ + super().__init__(filepath, checker, category, cwe, message, lines, data) + + +class CppcheckAnalysisResult(AnalysisResult): + """Represent the complete result of a Cppcheck analysis.""" + + def __init__(self, output_dir: Path, xml_tree: ElementTree, cmdout: dict) -> None: + """Initialize a CppcheckAnalysisResult instance. + + Args: + output_dir: The directory where the results are stored. + xml_tree: Parsed data from the main Cppcheck XML output. + cmdout: A dictionary with metadata from the command execution. + + """ + super().__init__( + name=output_dir.name, + source_path=Path(cmdout["project_dir"]), + lang=cmdout["lang"], + files=[], + defects=[], + time=cmdout["duration"], + loc=cmdout["loc"], + data=(xml_tree, cmdout), + ) + + errors = xml_tree.xpath("/results/errors/error") + for error in errors: + category = error.get("severity") + if category in ["error", "warning", "style"]: + self.defects.append( + CppcheckError( + filepath=Path(error.xpath("location")[0].get("file")), + checker=error.get("id"), + category=category, + cwe=CWEs.from_id(error.get("cwe", -1)), + message=error.get("msg"), + lines=[ + int(location.get("line")) + for location in error.xpath("location") + ], + data=error.attrib, + ) + ) + + self.files = list(set(d.filepath_str for d in self.defects)) + + @classmethod + def load_from_output_dir(cls, output_dir: Path) -> Self: + """Load and parse Cppcheck analysis results from a directory. + + Read `cppcheck_output.xml` and `cstools_output.json` to construct a complete + analysis result object. + + Args: + output_dir: The directory containing the Cppcheck output files. + + Returns: + An instance of `CppcheckAnalysisResult`. + + Raises: + MissingFile: If a required result file is not found. + + """ + # Cmdout + cmdout = json.load((output_dir / "cstools_output.json").open()) + + # Analysis outputs + analysis_output_path = output_dir / "cppcheck_output.xml" + if analysis_output_path.is_file(): + analysis_output = etree.parse(analysis_output_path) + else: + raise MissingFile(["cppcheck_output.xml"]) + + return cls(output_dir, analysis_output, cmdout) diff --git a/codesectools/sasts/tools/Cppcheck/sast.py b/codesectools/sasts/tools/Cppcheck/sast.py new file mode 100644 index 0000000..031966c --- /dev/null +++ b/codesectools/sasts/tools/Cppcheck/sast.py @@ -0,0 +1,69 @@ +"""Defines the SAST integration for Cppcheck. + +This module provides the `CppcheckSAST` class, which configures and orchestrates +the execution of Cppcheck scans using the core SAST framework. +""" + +from pathlib import Path + +from codesectools.sasts.core.sast import PrebuiltBuildlessSAST +from codesectools.sasts.core.sast.properties import SASTProperties +from codesectools.sasts.core.sast.requirements import ( + Binary, + SASTRequirements, +) +from codesectools.sasts.tools.Cppcheck.parser import CppcheckAnalysisResult + + +class CppcheckSAST(PrebuiltBuildlessSAST): + """SAST integration for Cppcheck. + + Attributes: + name (str): The name of the SAST tool. + supported_languages (list[str]): A list of supported programming languages. + supported_dataset_names (list[str]): A list of names of compatible datasets. + properties (SASTProperties): The properties of the SAST tool. + requirements (SASTRequirements): The requirements for the SAST tool. + commands (list[list[str]]): A list of command-line templates to be executed. + valid_codes (list[int]): A list of exit codes indicating that the command did not fail. + output_files (list[tuple[Path, bool]]): A list of expected output files and + whether they are required. + parser (type[CppcheckAnalysisResult]): The parser class for the tool's results. + color_mapping (dict): A mapping of result categories to colors for plotting. + + """ + + name = "Cppcheck" + supported_languages = ["c"] + supported_dataset_names = [] + properties = SASTProperties(free=True, offline=True) + requirements = SASTRequirements( + full_reqs=[ + Binary("cppcheck", url="https://cppcheck.sourceforge.io/"), + ], + partial_reqs=[], + ) + commands = [ + [ + "cppcheck", + ".", + "--enable=all", + "--xml", + "--output-file=cppcheck_output.xml", + "--project={artifacts}", + ] + ] + valid_codes = [0] + output_files = [ + (Path("cppcheck_output.xml"), True), + ] + parser = CppcheckAnalysisResult + color_mapping = { + "error": "red", + "warning": "orange", + "style": "yellow", + } + + # PrebuiltSAST + artefact_name = "Compilation database" + artefact_type = "file" diff --git a/codesectools/sasts/tools/SemgrepCE/parser.py b/codesectools/sasts/tools/SemgrepCE/parser.py index 94edb3b..fdb4d43 100644 --- a/codesectools/sasts/tools/SemgrepCE/parser.py +++ b/codesectools/sasts/tools/SemgrepCE/parser.py @@ -20,10 +20,6 @@ class SemgrepCEFinding(Defect): Parses defect data from the Semgrep JSON output to extract file, checker, category, CWE, and line information. - - Attributes: - lines (str): The line or lines of code where the finding occurred. - """ sast = "SemgrepCE" @@ -42,13 +38,12 @@ def __init__(self, defect_data: dict) -> None: category=defect_data["extra"]["metadata"].get("impact", "NONE"), cwe=CWEs.from_string(defect_data["extra"]["metadata"].get("cwe", [""])[0]), message=defect_data["extra"]["message"], - location=(defect_data["start"]["line"], defect_data["end"]["line"]), + lines=list( + range(defect_data["start"]["line"], defect_data["end"]["line"] + 1) + ), data=defect_data, ) - # Extra - self.lines = self.data["extra"]["lines"] - class SemgrepCEAnalysisResult(AnalysisResult): """Represents the complete result of a Semgrep Community Edition analysis. diff --git a/codesectools/sasts/tools/SnykCode/parser.py b/codesectools/sasts/tools/SnykCode/parser.py index 93675d9..237a8a2 100644 --- a/codesectools/sasts/tools/SnykCode/parser.py +++ b/codesectools/sasts/tools/SnykCode/parser.py @@ -29,7 +29,7 @@ def __init__( category: str, cwe: CWE, message: str, - location: tuple[int, int] | None, + lines: list[int] | None, data: dict, ) -> None: """Initialize a SnykCodeIssue instance. @@ -40,11 +40,11 @@ def __init__( category: The category of the checker. cwe: The CWE associated with the defect. message: The description of the defect. - location: A tuple with start and end line numbers of the defect, or None. + lines: A list of line numbers where the defect is located, or None. data: Raw data from the SAST tool for this defect. """ - super().__init__(filepath, checker, category, cwe, message, location, data) + super().__init__(filepath, checker, category, cwe, message, lines, data) class SnykCodeAnalysisResult(AnalysisResult): @@ -86,6 +86,25 @@ def __init__(self, output_dir: Path, result_data: dict, cmdout: dict) -> None: if self.lang not in self.normalize_lang_names[lang]: continue + start = ( + result["locations"][0]["physicalLocation"] + .get("region", {}) + .get("startLine", None) + ) + end = ( + result["locations"][0]["physicalLocation"] + .get("region", {}) + .get("endLine", None) + ) + if start and end: + lines = list(range(start, end + 1)) + elif start: + lines = [start] + elif end: + lines = [end] + else: + lines = None + defect = SnykCodeIssue( filepath=Path( result["locations"][0]["physicalLocation"]["artifactLocation"][ @@ -102,14 +121,7 @@ def __init__(self, output_dir: Path, result_data: dict, cmdout: dict) -> None: ] ), message=result["message"]["text"], - location=( - result["locations"][0]["physicalLocation"]["region"].get( - "startLine", None - ), - result["locations"][0]["physicalLocation"]["region"].get( - "endLine", None - ), - ), + lines=lines, data=result, ) self.defects.append(defect) diff --git a/codesectools/sasts/tools/SpotBugs/parser.py b/codesectools/sasts/tools/SpotBugs/parser.py index 6a27916..e8283b5 100644 --- a/codesectools/sasts/tools/SpotBugs/parser.py +++ b/codesectools/sasts/tools/SpotBugs/parser.py @@ -26,7 +26,7 @@ def __init__( category: str, cwe: CWE, message: str, - location: tuple[int, int] | None, + lines: list[int] | None, data: dict, ) -> None: """Initialize a SpotBugsIssue instance. @@ -37,11 +37,11 @@ def __init__( category: The category of the checker. cwe: The CWE associated with the defect. message: The description of the defect. - location: A tuple with start and end line numbers of the defect, or None. + lines: A list of line numbers where the defect is located, or None. data: Raw data from the SAST tool for this defect. """ - super().__init__(filepath, checker, category, cwe, message, location, data) + super().__init__(filepath, checker, category, cwe, message, lines, data) class SpotBugsAnalysisResult(AnalysisResult): @@ -92,6 +92,25 @@ def __init__(self, output_dir: Path, result_data: dict, cmdout: dict) -> None: partial_parents[partial_filepath.parent] / partial_filepath.name ) + start = ( + result["locations"][0]["physicalLocation"] + .get("region", {}) + .get("startLine", None) + ) + end = ( + result["locations"][0]["physicalLocation"] + .get("region", {}) + .get("endLine", None) + ) + if start and end: + lines = list(range(start, end + 1)) + elif start: + lines = [start] + elif end: + lines = [end] + else: + lines = None + defect = SpotBugsIssue( filepath=filepath, checker=checker, @@ -106,14 +125,7 @@ def __init__(self, output_dir: Path, result_data: dict, cmdout: dict) -> None: ) ), message=result["message"]["text"], - location=( - result["locations"][0]["physicalLocation"] - .get("region", {}) - .get("startLine", None), - result["locations"][0]["physicalLocation"] - .get("region", {}) - .get("endLine", None), - ), + lines=lines, data=result, ) if defect.category in ["SECURITY", "CORRECTNESS", "MT_CORRECTNESS"]: diff --git a/codesectools/sasts/tools/SpotBugs/sast.py b/codesectools/sasts/tools/SpotBugs/sast.py index 7034836..90e1f26 100644 --- a/codesectools/sasts/tools/SpotBugs/sast.py +++ b/codesectools/sasts/tools/SpotBugs/sast.py @@ -62,7 +62,7 @@ class SpotBugsSAST(PrebuiltSAST): "-nested:true", "-progress", "-sarif=spotbugs_output.json", - "{artifact_dir}", + "{artifacts}", ] ] valid_codes = [0] @@ -76,3 +76,7 @@ class SpotBugsSAST(PrebuiltSAST): "CORRECTNESS": "orange", "MT_CORRECTNESS": "yellow", } + + # PrebuiltSAST + artefact_name = "Java Bytecode" + artefact_type = "directory" diff --git a/codesectools/utils.py b/codesectools/utils.py index f9ad065..9fac31d 100644 --- a/codesectools/utils.py +++ b/codesectools/utils.py @@ -119,3 +119,35 @@ def __init__(self, command: list[str], command_output: str) -> None: def __str__(self) -> str: """Return a user-friendly string representation of the exception.""" return f"Non zero return code while running command:\n{self.command}\n{self.command_output}" + + +def group_successive(numbers_list: list[int]) -> list[list[int]]: + """Group a list of integers into sublists of consecutive numbers. + + For example, `[1, 2, 4, 5, 6, 8]` becomes `[[1, 2], [4, 5, 6], [8]]`. + + Args: + numbers_list: A list of integers. + + Returns: + A list of lists, where each sublist contains consecutive integers. + + """ + if not numbers_list: + return [] + + sorted_list = sorted(list(set(numbers_list))) + + groups = [] + current_group = [sorted_list[0]] + + for i in range(1, len(sorted_list)): + if sorted_list[i] == current_group[-1] + 1: + current_group.append(sorted_list[i]) + else: + groups.append(current_group) + current_group = [sorted_list[i]] + + groups.append(current_group) + + return groups diff --git a/pyproject.toml b/pyproject.toml index 6b17306..33a00f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "CodeSecTools" -version = "0.11.0" +version = "0.12.0" description = "A framework for code security that provides abstractions for static analysis tools and datasets to support their integration, testing, and evaluation." readme = "README.md" license = "AGPL-3.0-only" @@ -12,6 +12,7 @@ authors = [ dependencies = [ "gitpython>=3.1.44", "humanize>=4.12.3", + "lxml>=6.0.2", "matplotlib>=3.10.3", "numpy>=2.3.1", "pyyaml>=6.0.2", @@ -31,6 +32,7 @@ dev = [ "pre-commit>=4.3.0", "ruff>=0.12.8", "ty>=0.0.1a17", + "types-lxml>=2025.8.25", ] docs = [ "mkdocs>=1.6.1", diff --git a/requirements.txt b/requirements.txt index e79c7db..640f4cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -248,6 +248,81 @@ kiwisolver==1.4.9 \ --hash=sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54 \ --hash=sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df # via matplotlib +lxml==6.0.2 \ + --hash=sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8 \ + --hash=sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0 \ + --hash=sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf \ + --hash=sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452 \ + --hash=sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d \ + --hash=sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6 \ + --hash=sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca \ + --hash=sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed \ + --hash=sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd \ + --hash=sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659 \ + --hash=sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314 \ + --hash=sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5 \ + --hash=sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849 \ + --hash=sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6 \ + --hash=sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba \ + --hash=sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d \ + --hash=sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601 \ + --hash=sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37 \ + --hash=sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f \ + --hash=sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d \ + --hash=sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6 \ + --hash=sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f \ + --hash=sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534 \ + --hash=sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f \ + --hash=sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8 \ + --hash=sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f \ + --hash=sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1 \ + --hash=sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322 \ + --hash=sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f \ + --hash=sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f \ + --hash=sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f \ + --hash=sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916 \ + --hash=sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec \ + --hash=sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9 \ + --hash=sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679 \ + --hash=sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0 \ + --hash=sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192 \ + --hash=sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917 \ + --hash=sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c \ + --hash=sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d \ + --hash=sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338 \ + --hash=sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d \ + --hash=sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77 \ + --hash=sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba \ + --hash=sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456 \ + --hash=sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a \ + --hash=sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f \ + --hash=sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d \ + --hash=sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe \ + --hash=sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8 \ + --hash=sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0 \ + --hash=sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7 \ + --hash=sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9 \ + --hash=sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048 \ + --hash=sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c \ + --hash=sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b \ + --hash=sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0 \ + --hash=sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564 \ + --hash=sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62 \ + --hash=sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272 \ + --hash=sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9 \ + --hash=sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a \ + --hash=sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312 \ + --hash=sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37 \ + --hash=sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484 \ + --hash=sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e \ + --hash=sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924 \ + --hash=sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2 \ + --hash=sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df \ + --hash=sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd \ + --hash=sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed \ + --hash=sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2 \ + --hash=sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092 + # via codesectools markdown-it-py==4.0.0 \ --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 diff --git a/tests/Dockerfile b/tests/Dockerfile index cced92f..c6305ea 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -21,6 +21,7 @@ RUN apt update -qq && \ curl git \ cloc \ openjdk-17-jdk-headless maven \ + build-essential bear \ -y -qq --no-install-recommends && \ rm -rf /var/lib/apt/lists/* @@ -50,6 +51,10 @@ RUN curl -sfL https://raw.githubusercontent.com/Bearer/bearer/main/contrib/insta RUN curl -sL https://github.com/spotbugs/spotbugs/releases/download/4.9.7/spotbugs-4.9.7.tgz | tar -xzvf - && \ mv spotbugs-* /tmp/spotbugs ENV PATH="/tmp/spotbugs/bin:$PATH" +# Cppcheck +RUN apt update -qq && \ + DEBIAN_FRONTEND=noninteractive apt install cppcheck -y -qq --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* # === Run tests === COPY --from=builder --chown=app:app /app /app diff --git a/tests/test_all_sasts.py b/tests/test_all_sasts.py index 4d69fbe..70945c7 100644 --- a/tests/test_all_sasts.py +++ b/tests/test_all_sasts.py @@ -58,7 +58,7 @@ def test_analyze(monkeypatch: pytest.MonkeyPatch) -> None: assert retcode == 0 result = runner.invoke( - build_cli(), ["analyze", "java", "--artifact-dir", "target/classes"] + build_cli(), ["analyze", "java", "--artifacts", "target/classes"] ) assert result.exit_code == 0 diff --git a/tests/test_sasts.py b/tests/test_sasts.py index 7608e64..e61753d 100644 --- a/tests/test_sasts.py +++ b/tests/test_sasts.py @@ -27,7 +27,12 @@ @pytest.fixture(autouse=True, scope="module") def update_sast_module_state() -> GeneratorType: - """Update the state of SAST modules between tests.""" + """Update the state of SAST modules before running tests in this module. + + This fixture re-initializes each SAST tool's status and missing requirements + to ensure a clean state for the test functions. + + """ for sast_data in SASTS_ALL.values(): sast_instance = sast_data["sast"]() sast_data["cli_factory"].sast.__init__() @@ -40,7 +45,12 @@ def update_sast_module_state() -> GeneratorType: runner = CliRunner(env={"COLUMNS": "200"}) TEST_CODES_DIR = Path("tests/testcodes").resolve() -TEST_CODES = {"java": {"build_command": "javac {filename}"}} +TEST_CODES = { + "java": {"build_command": "javac {filename}"}, + "c": {"build_command": "bear -- gcc {filename}"}, +} + +ARTIFACTS_ARG = {"java": ".", "c": "compile_commands.json"} @pytest.mark.order(0) @@ -116,7 +126,7 @@ def test_sasts_analyze(monkeypatch: pytest.MonkeyPatch) -> None | AssertionError Path(temp_dir, file.name).write_bytes(file.read_bytes()) result = runner.invoke( - sast_cli, ["analyze", lang, "--artifact-dir", "."] + sast_cli, ["analyze", lang, "--artifacts", ARTIFACTS_ARG[lang]] ) assert result.exit_code == 0 assert "--overwrite" not in result.output @@ -129,9 +139,7 @@ def test_sasts_analyze(monkeypatch: pytest.MonkeyPatch) -> None | AssertionError SAST_RESULTS[sast_name].append(Path(temp_dir).name) - result = runner.invoke( - sast_cli, ["analyze", lang, "--artifact-dir", "."] - ) + result = runner.invoke(sast_cli, ["analyze", lang, "--artifacts", "."]) assert result.exit_code == 0 assert "--overwrite" in result.output diff --git a/uv.lock b/uv.lock index fd62b0d..3d84865 100644 --- a/uv.lock +++ b/uv.lock @@ -221,11 +221,12 @@ wheels = [ [[package]] name = "codesectools" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "gitpython" }, { name = "humanize" }, + { name = "lxml" }, { name = "matplotlib" }, { name = "numpy" }, { name = "pyyaml" }, @@ -240,6 +241,7 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, { name = "ty" }, + { name = "types-lxml" }, ] docs = [ { name = "mkdocs" }, @@ -265,6 +267,7 @@ test = [ requires-dist = [ { name = "gitpython", specifier = ">=3.1.44" }, { name = "humanize", specifier = ">=4.12.3" }, + { name = "lxml", specifier = ">=6.0.2" }, { name = "matplotlib", specifier = ">=3.10.3" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.1" }, { name = "mkdocs-click", marker = "extra == 'docs'", specifier = ">=0.9.0" }, @@ -289,6 +292,7 @@ requires-dist = [ { name = "tqdm", specifier = ">=4.67.1" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a17" }, { name = "typer", specifier = ">=0.16.1" }, + { name = "types-lxml", marker = "extra == 'dev'", specifier = ">=2025.8.25" }, { name = "xmltodict", specifier = ">=0.14.2" }, ] provides-extras = ["dev", "docs", "test"] @@ -368,6 +372,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] +[[package]] +name = "cssselect" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870, upload-time = "2025-03-10T09:30:29.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" }, +] + [[package]] name = "cssselect2" version = "0.8.0" @@ -648,6 +661,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -1532,6 +1625,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, ] +[[package]] +name = "types-html5lib" +version = "1.1.11.20251014" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/b8/0ce98d9b20a4e8bdac4f4914054acadf5b3a36a7a832e11e0d1938e4c3ce/types_html5lib-1.1.11.20251014.tar.gz", hash = "sha256:cc628d626e0111a2426a64f5f061ecfd113958b69ff6b3dc0eaaed2347ba9455", size = 16895, upload-time = "2025-10-14T02:54:50.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/cb/df12640506b8dbd2f2bd0643c5ef4a72fa6285ec4cd7f4b20457842e7fd5/types_html5lib-1.1.11.20251014-py3-none-any.whl", hash = "sha256:4ff2cf18dfc547009ab6fa4190fc3de464ba815c9090c3dd4a5b65f664bfa76c", size = 22916, upload-time = "2025-10-14T02:54:48.686Z" }, +] + +[[package]] +name = "types-lxml" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "cssselect" }, + { name = "types-html5lib" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3e/a545ece610c1bd9699addd887edfe9477a8f647c4336ba75cfb0561d197c/types_lxml-2025.8.25.tar.gz", hash = "sha256:79b9f5b1f236f937f14fe3add9dc687bd8d4111ca5df58eb9f1bde1a3b032fd5", size = 156126, upload-time = "2025-08-26T06:28:56.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/29/c45f567b4142288b8184f073af8f659abd134c21de055f971c65f2d755bd/types_lxml-2025.8.25-py3-none-any.whl", hash = "sha256:d61340e5329e102d3f8d64124e90d50c12c0bfeaa9088d65558279ef4e7138ac", size = 95318, upload-time = "2025-08-26T06:28:54.066Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"