Initial upload: dependency freshness checker CLI tool
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-04 14:55:41 +00:00
parent 65d4fcfaf9
commit 356031e631

211
src/depcheck/cli.py Normal file
View File

@@ -0,0 +1,211 @@
"""Main CLI module for depcheck."""
import sys
from pathlib import Path
from typing import Optional
import click
from depcheck import __version__
from depcheck.analyzers import CVEAnalyzer, load_cve_database
from depcheck.config import Config
from depcheck.models import ScanResult, Severity
from depcheck.parsers import ParserRegistry, create_parser_registry
from depcheck.reporters import JSONReporter, TerminalReporter
@click.group()
@click.version_option(version=__version__, prog_name="depcheck")
@click.option("--config", type=click.Path(exists=True), help="Path to config file")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential output")
@click.pass_context
def main(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool) -> None:
"""Dependency Freshness Checker - Monitor outdated dependencies across package managers."""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["quiet"] = quiet
@main.command()
@click.argument("path", type=click.Path(exists=True), default=".")
@click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
@click.option("--ci", is_flag=True, help="CI/CD mode (non-interactive, proper exit codes)")
@click.option(
"--fail-level",
type=click.Choice([s.value for s in Severity]),
default=Severity.MEDIUM.value,
help="Severity level that causes non-zero exit code",
)
@click.option(
"--package-manager",
type=click.Choice(["npm", "pip", "go", "cargo", "auto"]),
default="auto",
help="Package manager to use",
)
@click.option(
"--exclude-dev",
is_flag=True,
help="Exclude dev dependencies",
)
@click.pass_context
def scan(
ctx: click.Context,
path: str,
json_output: bool,
ci: bool,
fail_level: str,
package_manager: str,
exclude_dev: bool,
) -> None:
"""Scan dependency files for outdated packages and vulnerabilities."""
verbose = ctx.obj.get("verbose", False)
quiet = ctx.obj.get("quiet", False)
config = Config(
fail_level=Severity(fail_level),
output_format="json" if json_output else "terminal",
verbose=verbose,
quiet=quiet,
include_dev=not exclude_dev,
)
scan_path = Path(path)
registry = create_parser_registry()
cve_analyzer = CVEAnalyzer(load_cve_database())
if json_output or ci:
reporter: JSONReporter | TerminalReporter = JSONReporter(config)
else:
reporter = TerminalReporter(config)
result = ScanResult()
try:
if scan_path.is_file():
result = _scan_file(scan_path, registry, cve_analyzer, config)
else:
result = _scan_directory(scan_path, registry, cve_analyzer, config)
except Exception as e:
if not quiet:
reporter.print_error(f"Scan failed: {str(e)}")
sys.exit(2)
if json_output:
print(reporter.report(result))
else:
if result.scan_errors and verbose:
for error in result.scan_errors:
reporter.print_warning(error)
reporter.report(result)
exit_code = reporter.get_exit_code(result) if ci else 0
sys.exit(exit_code)
def _scan_file(
file_path: Path,
registry: ParserRegistry,
cve_analyzer: CVEAnalyzer,
config: Config,
) -> ScanResult:
"""Scan a single dependency file."""
result = ScanResult(source_file=str(file_path))
parser = registry.get_parser(file_path)
if parser is None:
result.scan_errors.append(f"No parser found for {file_path}")
return result
dependencies = parser.parse(file_path)
for dep in dependencies:
if not config.include_dev and dep.category in ["devDependencies", "devDependencies"]:
continue
vulnerabilities = cve_analyzer.analyze(dep)
for vuln in vulnerabilities:
result.vulnerabilities.append((dep, vuln))
result.dependencies = dependencies
result.package_manager = parser.package_manager
result.source_file = str(file_path)
return result
def _scan_directory(
directory: Path,
registry: ParserRegistry,
cve_analyzer: CVEAnalyzer,
config: Config,
) -> ScanResult:
"""Scan a directory for all supported dependency files."""
result = ScanResult()
if package_manager := _get_package_manager_from_args(directory, registry):
for pattern in _get_package_file_patterns(package_manager):
for file_path in directory.rglob(pattern):
if _should_skip_file(file_path, config):
continue
file_result = _scan_file(file_path, registry, cve_analyzer, config)
result.dependencies.extend(file_result.dependencies)
result.vulnerabilities.extend(file_result.vulnerabilities)
result.scan_errors.extend(file_result.scan_errors)
else:
for file_path in _find_all_dependency_files(directory):
if _should_skip_file(file_path, config):
continue
file_result = _scan_file(file_path, registry, cve_analyzer, config)
result.dependencies.extend(file_result.dependencies)
result.vulnerabilities.extend(file_result.vulnerabilities)
result.scan_errors.extend(file_result.scan_errors)
if result.dependencies:
result.package_manager = result.dependencies[0].package_manager
return result
def _get_package_manager_from_args(directory: Path, registry: ParserRegistry) -> Optional[str]:
"""Determine package manager from directory contents."""
for parser in registry.get_all_parsers():
for pattern in parser.get_file_patterns():
if (directory / pattern).exists():
return parser.package_manager.value
return None
def _get_package_file_patterns(package_manager: str) -> list[str]:
"""Get file patterns for a package manager."""
patterns = {
"npm": ["package.json"],
"pip": ["requirements.txt", "pyproject.toml"],
"go": ["go.mod"],
"cargo": ["Cargo.toml"],
}
return patterns.get(package_manager, [])
def _find_all_dependency_files(directory: Path) -> list[Path]:
"""Find all dependency files in a directory."""
patterns = ["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml"]
files: list[Path] = []
for pattern in patterns:
files.extend(directory.rglob(pattern))
return sorted(set(files))
def _should_skip_file(file_path: Path, config: Config) -> bool:
"""Check if a file should be skipped."""
for pattern in config.ignore_patterns:
if pattern in str(file_path):
return True
return False
@main.command()
@click.pass_context
def check_update(ctx: click.Context) -> None:
"""Check if a new version of depcheck is available."""
click.echo("Update check not implemented in offline mode.")