8000 feat(parsers): add Nix, Guix and Spack parsers (Phase 2.1) by devin-ai-integration[bot] · Pull Request #105 · 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
6 changes: 3 additions & 3 deletions PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@

| Менеджер | Платформа | Приоритет |
|----------|-----------|-----------|
| Nix | Linux/macOS | Высокий |
| Guix | Linux | Высокий |
| Spack | Linux (HPC) | Высокий |
| Nix | Linux/macOS | ✅ Готово |
| Guix | Linux | ✅ Готово |
| Spack | Linux (HPC) | ✅ Готово |
| xbps | Void Linux | Средний |
| pkgsrc | NetBSD | Средний |
| apk-tools | Alpine (уже есть) | — |
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
- Использовать различные алгоритмы решения (жадный, SAT, ILP, SMT, CP-SAT)
- Учитывать версионные ограничения и зависимости
- Экспортировать результаты в JSON, CSV и GraphML
- Парсить вывод 22+ систем управления пакетами
- Парсить вывод 25+ систем управления пакетами

## ✨ Возможности

### 🔧 Поддерживаемые пакетные менеджеры (22)
### 🔧 Поддерживаемые пакетные менеджеры (25)

**Linux:**
- ✅ **APT** (Debian/Ubuntu) — `apt list --installed`
Expand Down Expand Up @@ -51,7 +51,6 @@

**C++:**
- ✅ **Vcpkg** — `vcpkg list`
- ✅ **Conan** — `conan search`
- ✅ **NuGet** (.NET) — `dotnet list package`

**Контейнеры / универсальные:**
Expand All @@ -60,6 +59,11 @@
- ✅ **Conda** (Python) — `conda list`
- ✅ **Portage** (Gentoo) — `emerge -p`

**Функциональные / HPC:**
- ✅ **Nix** (NixOS/macOS) — `nix-env -q` / `nix profile list [--json]`
- ✅ **Guix** (GNU Guix) — `guix package --list-installed`
- ✅ **Spack** (HPC) — `spack find` / `spack find --json`

### 🧠 Поддерживаемые солверы
- ✅ **GreedySolver** — Базовый жадный алгоритм
- ✅ **EnhancedGreedySolver** — Улучшенный жадный алгоритм с поддержкой версий
Expand Down Expand Up @@ -142,7 +146,7 @@ package-maximizer list-solvers
# Просмотр доступных парсеров
package-maximizer list-parsers

# Просмотр поддерживаемых пакетных менеджеров (22)
# Просмотр поддерживаемых пакетных менеджеров (25)
package-maximizer list-managers

# Проверка версии
Expand Down
2 changes: 1 addition & 1 deletion package_maximizer/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def cli(verbose: bool, quiet: bool, config: str | None):
"-m",
type=str,
default=None,
help="Тип пакетного менеджера (apt, pacman, dnf, brew, snap, flatpak, cargo, npm, pip, gem, apk, zypper, yum, yarn, composer, vcpkg, nuget, winget, scoop, choco, conda, portage)",
help="Тип пакетного менеджера (apt, pacman, dnf, brew, snap, flatpak, cargo, npm, pip, gem, apk, zypper, yum, yarn, composer, vcpkg, nuget, winget, scoop, choco, conda, portage, nix, guix, spack)",
)
@click.option(
"--solver",
Expand Down
3 changes: 3 additions & 0 deletions package_maximizer/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class PackageManagerType(Enum):
WINGET = "winget"
SCOOP = "scoop"
CHOCO = "choco"
NIX = "nix"
GUIX = "guix"
SPACK = "spack"


class SolverType(Enum):
Expand Down
12 changes: 12 additions & 0 deletions package_maximizer/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
CondaParser,
FlatpakParser,
GemParser,
GuixParser,
NixParser,
NpmParser,
NuGetParser,
PipParser,
PortageParser,
ScoopParser,
SnapParser,
SpackParser,
VcpkgParser,
WingetParser,
YarnParser,
Expand Down Expand Up @@ -54,6 +57,9 @@
"WingetParser",
"ScoopParser",
"ChocoParser",
"NixParser",
"GuixParser",
"SpackParser",
"get_parser",
"PARSER_REGISTRY",
]
Expand Down Expand Up @@ -82,6 +88,9 @@
"winget": (".extra_parsers", "WingetParser", None),
"scoop": (".extra_parsers", "ScoopParser", None),
"choco": (".extra_parsers", "ChocoParser", None),
"nix": (".extra_parsers", "NixParser", None),
"guix": (".extra_parsers", "GuixParser", None),
"spack": (".extra_parsers", "SpackParser", None),
}

# Public class names importable directly (resolved lazily).
Expand All @@ -108,6 +117,9 @@
"WingetParser": (".extra_parsers", "WingetParser"),
"ScoopParser": (".extra_parsers", "ScoopParser"),
"ChocoParser": (".extra_parsers", "ChocoParser"),
"NixParser": (".extra_parsers", "NixParser"),
"GuixParser": (".extra_parsers", "GuixParser"),
BD0 "SpackParser": (".extra_parsers", "SpackParser"),
}


Expand Down
171 changes: 171 additions & 0 deletions package_maximizer/parsers/extra_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,174 @@ def parse(self, raw: str) -> list[Package]:
version = parts[1]
packages.append(Package(name=name, version=version, status="installed"))
return packages


_NIX_STORE_PATH_RE = re.compile(r"/(?:nix|gnu)/store/[0-9a-z]{32}-(\S+)")


def _split_drv_name(drv_name: str) -> tuple[str, str]:
"""Split ``name-version`` like Nix ``builtins.parseDrvName``.

The version starts at the first dash that is followed by a non-letter.
"""
match = re.search(r"-(?=[^A-Za-z-])", drv_name)
if match is None:
return drv_name, ""
return drv_name[: match.start()], drv_name[match.end() :]


class NixParser(PackageParser):
"""
Parser for ``nix-env -q`` and ``nix profile list`` (text or ``--json``).

Example::

hello-2.12.1
python3.11-numpy-1.26.4

Name: hello
Store paths: /nix/store/<hash>-hello-2.12.1
"""

def parse(self, raw: str) -> list[Package]:
if not raw.strip():
return []
stripped = raw.strip()
if stripped.startswith("{"):
return self._parse_json(stripped)

packages: list[Package] = []
seen: set[str] = set()
for line in stripped.splitlines():
line = line.strip()
if not line:
continue
store = _NIX_STORE_PATH_RE.search(line)
if store:
drv_name = store.group(1)
elif ":" in line or " " in line:
continue
else:
drv_name = line
name, version = _split_drv_name(drv_name)
if name and name not in seen:
seen.add(name)
packages.append(Package(name=name, version=version, status="installed"))
return packages

def _parse_json(self, raw: str) -> list[Package]:
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
logger.warning(f"Invalid nix profile JSON: {e}")
return []
elements = data.get("elements", {})
if isinstance(elements, dict):
entries = list(elements.items())
else:
entries = [(None, el) for el in elements if isinstance(el, dict)]

packages: list[Package] = []
for key, element in entries:
store_paths = element.get("storePaths") or []
match = _NIX_STORE_PATH_RE.search(store_paths[0]) if store_paths else None
name, version = _split_drv_name(match.group(1)) if match else ("", "")
name = key or name
if name:
packages.append(Package(name=name, version=version, status="installed"))
return packages


class GuixParser(PackageParser):
"""
Parser for ``guix package --list-installed`` / ``--list-available``.

Example::

hello 2.12.1 out /gnu/store/<hash>-hello-2.12.1
coreutils 9.1 out,debug gnu/packages/base.scm:351:2
"""

def parse(self, raw: str) -> list[Package]:
if not raw.strip():
return []

packages: list[Package] = []
for line in raw.strip().splitlines():
parts = line.split()
if len(parts) < 2 or not re.match(r"^\d", parts[1]):
continue
installed = len(parts) >= 4 and parts[3].startswith("/gnu/store/")
packages.append(
Package(
name=parts[0],
version=parts[1],
status="installed" if installed else "candidate",
)
)
return packages


_SPACK_SPEC_RE = re.compile(r"^([A-Za-z0-9_][A-Za-z0-9_.-]*)@=?([^\s+~%^/@]+)")


class SpackParser(PackageParser):
"""
Parser for ``spack find`` (text, ``-l``/``-v`` variants, or ``--json``).

Example::

-- linux-ubuntu22.04-x86_64 / gcc@11.4.0 ------------------------
autoconf@2.72 zlib-ng@2.1.6
==> 2 installed packages
"""

def parse(self, raw: str) -> list[Package]:
if not raw.strip():
return []
stripped = raw.strip()
if stripped.startswith("["):
return self._parse_json(stripped)

packages: list[Package] = []
seen: set[tuple[str, str]] = set()
for line in stripped.splitlines():
line = line.strip()
if not line or line.startswith(("--", "==>")):
continue
for token in line.split():
match = _SPACK_SPEC_RE.match(token)
if not match or match.groups() in seen:
continue
seen.add(match.groups())
name, version = match.groups()
packages.append(Package(name=name, version=version, status="installed"))
return packages

def _parse_json(self, raw: str) -> list[Package]:
try:
specs = json.loads(raw)
except json.JSONDecodeError as e:
logger.warning(f"Invalid spack JSON: {e}")
return []

packages: list[Package] = []
for spec in specs:
if not isinstance(spec, dict) or "name" not in spec:
continue
deps = spec.get("dependencies") or []
if isinstance(deps, dict):
depends = list(deps)
else:
depends = [
d["name"] for d in deps if isinstance(d, dict) and "name" in d
]
packages.append(
Package(
name=spec["name"],
version=str(spec.get("version", "")),
status="installed",
depends=depends,
)
)
return packages
4 changes: 3 additions & 1 deletion tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def test_tui_missing_textual_exits(self, monkeypatch):


class TestListManagersCommand:
"""list_managers shows all 22 registered package managers."""
"""list_managers shows all registered package managers."""

def test_list_managers_text(self):
runner = CliRunner()
Expand All @@ -134,6 +134,8 @@ def test_list_managers_text(self):
assert "brew" in result.output
assert "pip" in result.output
assert "conda" in result.output
assert "nix" in result.output
assert "spack" in result.output


class TestMaximizeCommand:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_parsers_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ def test_all_new_parsers_registered(self):
"winget",
"scoop",
"choco",
"nix",
"guix",
"spack",
}
for key in expected:
assert key in PARSER_REGISTRY, f"{key} not in registry"
Expand Down
Loading
Loading
0