8000 feat/0.9.0: propose command + web API with automatic metadata extraction by dominicusin · Pull Request #75 · dominicusin/package-maximizer · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.0] - 2026-09-05

### Added
- **CLI `propose` command**: automatic metadata extraction + maximization
- `package-maximizer propose nginx apache2 --manager apt --explain`
- Auto-fetches depends/conflicts via adapters
- `--explain` shows why packages were excluded
- **Web API `/api/v1/propose`**: POST endpoint with automatic metadata extraction
- Accepts `packages`, `manager`, `solver`, `explain` fields
- Returns `metadata_fetched`, `metadata` summary, and optional `excluded` reasons
- **Adapter `fetch()` method**: subprocess call + parse for APT, pip, pacman
- `APTMetadataAdapter.fetch()` → `apt-cache show`
- `PipMetadataAdapter.fetch()` → `pip show`
- `PacmanMetadataAdapter.fetch()` → `pacman -Si`
- **`get_adapter(manager)` factory**: get adapter by manager name
- **Tests**: 8 tests for propose CLI, 5 tests for propose web API

### Changed
- Adapters now support both `parse()` (from raw string) and `fetch()` (from repository)

## [0.7.1] - 2026-09-05

### Added
Expand Down
63 changes: 63 additions & 0 deletions package_maximizer/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import re
import subprocess
from dataclasses import dataclass, field

from ..core.package import Package
Expand Down Expand Up @@ -35,9 +36,41 @@ def to_package(self) -> Package:
)


def get_adapter(manager: str):
"""Получить адаптер метаданных по имени менеджера."""
adapters = {
"apt": APTMetadataAdapter,
"pip": PipMetadataAdapter,
"pacman": PacmanMetadataAdapter,
}
cls = adapters.get(manager.lower())
if cls is None:
raise ValueError(
f"No metadata adapter for '{manager}'. "
f"Available: {', '.join(adapters.keys())}"
)
return cls()


class APTMetadataAdapter:
"""Парсер метаданных APT (apt-cache show / dpkg -s)."""

def fetch(self, package_name: str) -> PackageMetadata | None:
"""Получить метаданные через apt-cache show."""
try:
result = subprocess.run(
["apt-cache", "show", package_name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
packages = self.parse_multi(result.stdout)
return packages[0] if packages else None
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None

def parse(self, raw: str) -> PackageMetadata | None:
"""Разобрать вывод apt-cache show или dpkg -s."""
if not raw.strip():
Expand Down Expand Up @@ -140,6 +173,21 @@ def parse_multi(self, raw: str) -> list[PackageMetadata]:
class PipMetadataAdapter:
"""Парсер метаданных pip (pip show / METADATA)."""

def fetch(self, package_name: str) -> PackageMetadata | None:
"""Получить метаданные через pip show."""
try:
result = subprocess.run(
["pip", "show", package_name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return self.parse(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None

def parse(self, raw: str) -> PackageMetadata | None:
"""Разобрать вывод pip show."""
if not raw.strip():
Expand Down Expand Up @@ -215,6 +263,21 @@ def _parse_pip_deps(self, raw: str) -> list[str]:
class PacmanMetadataAdapter:
"""Парсер метаданных pacman (pacman -Si / -Qi)."""

def fetch(self, package_name: str) -> PackageMetadata | None:
"""Получить метаданные через pacman -Si."""
try:
result = subprocess.run(
["pacman", "-Si", package_name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
return self.parse(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None

def parse(self, raw: str) -> PackageMetadata | None:
"""Разобрать вывод pacman -Si или -Qi."""
if not raw.strip():
Expand Down
152 changes: 152 additions & 0 deletions package_maximizer/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,158 @@ def export_command(packages, manager, solver, conflicts, format, output_file):
click.echo(content)


@cli.command(name="propose")
@click.argument("packages", nargs=-1)
@click.option(
"--manager",
"-m",
type=str,
default=None,
help="Тип пакетного менеджера (apt, pip, pacman)",
)
@click.option(
"--solver",
"-s",
type=str,
default=None,
help="Тип солвера (greedy, z3, pulp, ortools, maxsat, minisat, enhanced_greedy)",
)
@click.option(
"--output",
"-o",
type=click.Choice(["text", "json"]),
default="text",
help="Формат вывода",
)
@click.option(
"--explain",
"-e",
is_flag=True,
default=False,
help="Показать причины отбора/отклонения пакетов",
)
def propose(packages, manager, solver, output, explain):
"""
Предложить оптимальный набор пакетов с автоматическим извлечением метаданных.

Автоматически извлекает depends/conflicts из репозитория и выбирает
максимальное совместное множество.

Примеры:

package-maximizer propose nginx apache2 php-fpm --manager apt --explain

package-maximizer propose requests urllib3 --manager pip --solver z3
"""
obj = click.get_current_context().obj or {}
cfg = obj.get("config")
if manager is None:
manager = cfg.default_manager if cfg else "apt"
if solver is None:
solver = cfg.default_solver if cfg else "greedy"

try:
try:
manager_enum = PackageManagerType(manager)
except ValueError:
click.echo(f"Ошибка: Неизвестный пакетный менеджер '{manager}'", err=True)
sys.exit(1)

from ..adapters import get_adapter

try:
adapter = get_adapter(manager)
except ValueError as e:
click.echo(f"Ошибка: {e}", err=True)
sys.exit(1)

click.echo(f"Извлечение метаданных для {len(packages)} пакетов ({manager})...")

package_objs = []
not_found = []
for pkg_name in packages:
metadata = adapter.fetch(pkg_name)
if metadata and metadata.name:
package_objs.append(metadata.to_package())
click.echo(
f" ✓ {pkg_name}: {len(metadata.depends)} зависимостей, "
f"{len(metadata.conflicts)} конфликтов"
)
else:
not_found.append(pkg_name)
package_objs.append(Package(name=pkg_name, status="candidate"))
click.echo(f" ⚠ {pkg_name}: метаданные не найдены")

if not_found:
click.echo(
f"\nПредупреждение: метаданные не найдены для: {', '.join(not_found)}"
)

if not package_objs:
click.echo("Ошибка: нет пакетов для обработки", err=True)
sys.exit(1)

try:
maximizer = PackageMaximizer(manager=manager_enum, solver=solver)
except ValueError:
click.echo(f"Ошибка: Неизвестный солвер '{solver}'", err=True)
sys.exit(1)

result = maximizer.solve(package_objs)

if output == "json":
output_data = {
"manager": manager,
"solver": solver,
"input": [p.name for p in package_objs],
"output": result,
"count": len(result),
"metadata_fetched": len(packages) - len(not_found),
}
click.echo(json.dumps(output_data, indent=2))
else:
click.echo(f"\nМенеджер: {manager}")
click.echo(f"Солвер: {solver}")
click.echo(f"Входные пакеты: {len(package_objs)}")
click.echo(f"Выбранные пакеты: {len(result)}")
click.echo(f"Результат: {', '.join(result)}")

if explain:
selected_set = set(result)
all_names = {p.name for p in package_objs}
excluded = all_names - selected_set

if excluded:
click.echo(f"\nПричины отклонения:")
from ..core.model_encoder import encode_packages

constraints = encode_packages(package_objs)

for name in sorted(excluded):
reasons = []
for a, b in constraints.conflicts:
if a == name and b in selected_set:
reasons.append(f"конфликт с {b}")
elif b == name and a in selected_set:
reasons.append(f"конфликт с {a}")

deps = constraints.dependencies.get(name, [])
for dep in deps:
if dep not in selected_set:
reasons.append(f"не выбрана зависимость {dep}")

if reasons:
click.echo(f" - {name}: {'; '.join(reasons)}")
else:
click.echo(f" - {name}: не оптимально для данного солвера")
else:
click.echo(f"\nВсе пакеты выбраны!")
except Exception as e:
logger.error(f"Ошибка: {e}", exc_info=True)
click.echo(f"Ошибка: {e}", err=True)
sys.exit(1)


@cli.command(name="tui")
def tui_command() -> None:
"""
Expand Down
Loading
Loading
0