8000 fix/0.10.0: get_adapter returns None for invalid managers by dominicusin · Pull Request #77 · dominicusin/package-maximizer · GitHub
[go: up one dir, main page]

Skip to content
Closed
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ 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.10.0] - 2026-09-05

### Added
- **NpmMetadataAdapter**: парсер метаданных npm (package-lock.json v2/v3)
- `parse()`: парсит `npm view` JSON и `package-lock.json`
- `parse_lockfile()`: разбирает lockfile v2/v3
- `fetch()`: зовёт `npm view <pkg> json`
- **BrewMetadataAdapter**: парсер метаданных brew
- `parse()`: парсит JSON от `brew info --json=v2`
- `fetch()`: зовёт `brew info --json=v2 <pkg>`
- Поддержка dependencies, conflicts_with, versions
- **get_adapter()** теперь поддерживает npm и brew

### Tests
- 14 тестов для NpmMetadataAdapter и BrewMetadataAdapter
- Всего 779 тестов

## [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
4 changes: 2 additions & 2 deletions package_maximizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
try:
__version__ = _dist_version("package-maximizer")
except PackageNotFoundError:
__version__ = "0.7.1"
__version__ = "0.10.0"
except ImportError: # pragma: no cover — Python < 3.8
__version__ = "0.7.1"
__version__ = "0.10.0"
__author__ = "Package Maximizer Team"
__email__ = "team@package-maximizer.dev"
__license__ = "MIT"
Expand Down
230 changes: 229 additions & 1 deletion package_maximizer/adapters/__init__.py
79EC
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,43 @@ def to_package(self) -> Package:
)


def get_adapter(manager: str):
"""Получить адаптер метаданных по имени менеджера."""
adapters = {
"apt": APTMetadataAdapter,
"pip": PipMetadataAdapter,
"pacman": PacmanMetadataAdapter,
"npm": NpmMetadataAdapter,
"brew": BrewMetadataAdapter,
}
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 @@ -96,7 +131,7 @@ def _set_field(self, metadata: PackageMetadata, field: str, value: str) -> None:
elif field_lower == "installed-size":
try:
metadata.size = int(value.split()[0])
except (ValueError, IndexError):
except ValueError, IndexError:
pass
elif field_lower == "homepage":
metadata.homepage = value.strip()
Expand Down Expand Up @@ -140,6 +175,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 +265,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 Expand Up @@ -278,3 +343,166 @@ def _parse_pacman_deps(self, raw: str) -> list[str]:
deps.append(dep)

return deps


class NpmMetadataAdapter:
"""Парсер метаданных npm (package-lock.json v2/v3)."""

def fetch(self, package_name: str) -> PackageMetadata | None:
"""Получить метаданные через npm view."""
try:
result = subprocess.run(
["npm", "view", package_name, "json"],
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:
"""Разобрать вывод npm view или package-lock.json."""
if not raw.strip():
return None

import json

try:
data = json.loads(raw)
except json.JSONDecodeError:
return None

# Handle both single package and array responses
if isinstance(data, list):
if len(data) == 0:
return None
data = data[-1] # Latest version

if not isinstance(data, dict):
return None

metadata = PackageMetadata(
name=data.get("name", ""),
version=data.get("version", ""),
description=data.get("description", ""),
homepage=data.get("homepage", ""),
)

# Dependencies
deps = data.get("dependencies", {})
if isinstance(deps, dict):
metadata.depends.extend(deps.keys())

# Conflicts (npm uses "conflicts" rarely, but check)
conflicts = data.get("conflicts", {})
if isinstance(conflicts, dict):
metadata.conflicts.extend(conflicts.keys())

return metadata if metadata.name else None

def parse_lockfile(self, raw: str) -> list[PackageMetadata]:
"""Разобрать package-lock.json v2/v3."""
if not raw.strip():
return []

import json

try:
data = json.loads(raw)
except json.JSONDecodeError:
return []

packages = []
packages_data = data.get("packages", {})

for path, info in packages_data.items():
if not isinstance(info, dict):
continue
# Skip the root entry
if path == "":
continue

name = info.get("name", "")
if not name:
# Extract from path (node_modules/@scope/pkg or node_modules/pkg)
parts = path.replace("node_modules/", "").split("/")
if len(parts) > 1 and parts[0].startswith("@"):
name = "/".join(parts[:2])
else:
name = parts[0]

metadata = PackageMetadata(
name=name,
version=info.get("version", ""),
)

deps = info.get("dependencies", {})
if isinstance(deps, dict):
metadata.depends.extend(deps.keys())

packages.append(metadata)

return packages


class BrewMetadataAdapter:
"""Парсер метаданных brew (brew info --json=v2)."""

def fetch(self, package_name: str) -> PackageMetadata | None:
"""Получить метаданные через brew info --json=v2."""
try:
result = subprocess.run(
["brew", "info", "--json=v2", package_name],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
import json

data = json.loads(result.stdout)
if data and isinstance(data, list) and len(data) > 0:
return self._parse_brew_json(data[0])
except subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError:
pass
return None

def _parse_brew_json(self, data: dict) -> PackageMetadata:
"""Разобрать JSON от brew info."""
metadata = PackageMetadata(
name=data.get("name", ""),
version=data.get("versions", {}).get("stable", ""),
description=data.get("desc", ""),
homepage=data.get("homepage", ""),
)

# Dependencies
deps = data.get("dependencies", [])
if isinstance(deps, list):
metadata.depends.extend(deps)

# Conflicts
conflicts = data.get("conflicts_with", [])
if isinstance(conflicts, list):
metadata.conflicts.extend(conflicts)

return metadata

def parse(self, raw: str) -> PackageMetadata | None:
"""Разобрать JSON вывод brew info --json=v2."""
if not raw.strip():
return None
try:
import json

data = json.loads(raw)
if isinstance(data, list) and len(data) > 0:
return self._parse_brew_json(data[0])
elif isinstance(data, dict):
return self._parse_brew_json(data)
except json.JSONDecodeError:
pass
return None
Loading
0