8000 fix: close PROJECT_PLAN 1.3 known debts by devin-ai-integration[bot] · Pull Request #104 · 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
31 changes: 23 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ 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).

## [Unreleased]

### Fixed
- `web/app.py`: `APP_VERSION` now comes from `package_maximizer.__version__`; the stale `0.6.1` fallback is gone. The fallback versions in `__init__.py`, `pyproject.toml` (`setuptools_scm.fallback_version`) and `CITATION.cff` are synced to `0.29.0` and checked by `tests/test_version_sync.py`.
- `package-maximizer tui` without the `tui` extra (textual) now falls back to the text `shell` instead of exiting with an error.
- `APTParser`: `dpkg -l` header/legend rows (`Desired=…`, `||/ Name …`, `+++-…`) are no longer parsed as packages.
- Homebrew: `brew list --formula --cask` (mutually exclusive flags, always failed) replaced with `brew list -1`; installed packages are marked `installed`.

### Tests
- `tests/test_real_system.py` (`real_system` marker): `RealRepoIntegration` against the host's real APT/Homebrew. Skipped when the tool is absent.

### Changed
- CHANGELOG: removed the stale "Planned" section (JWT, Flask-Limiter) and merged the duplicated `[0.6.0]` sections.

### Releases 0.11.0 – 0.29.0 (summary)
These releases were tagged without CHANGELOG entries. Main changes, from merged PRs:
- 0.11.0–0.12.0: maximize/propose endpoints use metadata adapters; `--metadata` flag (#78, #84).
- 0.13.0–0.17.0: TUI metadata checkbox; `benchmark`, `analyze`, `compare` CLI commands and web endpoints; CLI `shell` and `history` (#85–#89).
- 0.18.0–0.21.x: TUI enhancements; web API history and export endpoints; real-world data runner with `--parallel` (#91–#93).
- 0.22.0–0.29.0: real-world solver benchmarks and nixpkgs-derived package data; fixed conflict detection from nixpkgs sources (#95–#101).

## [0.10.0] - 2026-09-05

### Added
Expand Down Expand Up @@ -94,10 +115,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Result exporters: JSON, CSV, GraphML.
- Config-driven CLI defaults via `-C/--config`.

### Planned (not yet implemented)
- JWT authentication support alongside API key.
- Flask-Limiter based rate limiting (currently in-memory fallback).

### Changed
- Lazy solver/parser registries to avoid optional-dependency import failures.
- `SolverType` enum extended with `ENHANCED_GREEDY`.
Expand All @@ -108,16 +125,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `pulp_solver` import compatibility.
- Makefile `PYTHON` override for venv compatibility.

## [0.6.0] - 2026-09-02

### Added
### Added (package managers)
- 12 new package managers: apk, zypper, yum, pip, gem, yarn, composer, vcpkg, nuget, winget, scoop, choco.
- Registered CondaParser and PortageParser (were implemented but not registered).
- Total registered package managers: **8 → 22**.
- CLI `list-managers` command shows all supported package managers.
- Updated README with full list of 22 package managers.

### Changed
### Changed (package managers)
- Updated `--manager` help text with all 22 options.
- Updated package description to reflect 22+ package manager support.

Expand Down
8000
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ license: MIT
repository-code: "https://github.com/dominicusin/package-maximizer"
url: "https://pypi.org/project/package-maximizer/"
keywords: [packaging, dependency-resolution, optimization]
version: 0.6.1
version: 0.29.0
8 changes: 4 additions & 4 deletions PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@

### 1.3. Известные долги и баги

- [ ] `CHANGELOG.md`: секция "Planned" устарела (JWT, Flask-Limiter)
- [ ] `web/app.py`: версия hard-coded как "0.5.0" в health/openapi
- [ ] `tui/app.py`: требует `textual` extra, нет fallback
- [ ] `integrations/real_repo_integration.py`: 90% coverage, но не тестируется на реальных системах
- [x] `CHANGELOG.md`: секция "Planned" устарела (JWT, Flask-Limiter)
- [x] `web/app.py`: версия hard-coded как "0.5.0" в health/openapi
- [x] `tui/app.py`: требует `textual` extra, нет fallback (теперь fallback на `shell`)
- [x] `integrations/real_repo_integration.py`: 90% coverage, но не тестируется на реальных системах (`tests/test_real_system.py`)

---

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.12.0"
__version__ = "0.29.0"
except ImportError: # pragma: no cover — Python < 3.8
__version__ = "0.12.0"
__version__ = "0.29.0"
__author__ = "Package Maximizer Team"
__email__ = "team@package-maximizer.dev"
__license__ = "MIT"
Expand Down
16 changes: 12 additions & 4 deletions package_maximizer/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1246,14 +1246,22 @@ def compare(packages, manager, metadata, output):
def tui_command() -> None:
"""
Запустить интерактивный TUI (Textual).

Без extra ``tui`` (textual) запускается текстовая оболочка ``shell``.
"""
try:
from ..tui.app import run_tui

run_tui()
except ImportError as e:
click.echo(f"Ошибка: {e}", err=True)
sys.exit(1)
click.echo(
f"Textual недоступен ({e}); запускаю текстовую оболочку. "
"Для TUI: pip install 'package-maximizer[tui]'",
err=True,
)
from ..tui.shell import MaximizerShell

MaximizerShell().run()
return
run_tui()


@cli.command(name="history")
Expand Down
4 changes: 2 additions & 2 deletions package_maximizer/integrations/real_repo_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ def _get_installed_brew(self) -> list[Package]:
"""Получить установленные пакеты через Brew."""
try:
result = subprocess.run(
["brew", "list", "--formula", "--cask"],
["brew", "list", "-1"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return self.parser.parse(result.stdout)
return self.parser.parse_installed(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.warning(f"Failed to get installed Brew packages: {e}")
return []
Expand Down
5 changes: 5 additions & 0 deletions package_maximizer/parsers/apt_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

logger = logging.getLogger(__name__)

# dpkg -l status column, e.g. "ii", "rc", "iU", "hiR"; rejects header rows.
_DPKG_STATUS_RE = re.compile(r"[uihrp][ncUHFWti][R ]?")


class APTParser(PackageParser):
"""
Expand Down Expand Up @@ -85,6 +88,8 @@ def _parse_dpkg_l(self, raw: str) -> list[Package]:
continue

status = parts[0]
if not _DPKG_STATUS_RE.fullmatch(status):
continue
name = parts[1]
version = parts[2]

Expand Down
11 changes: 9 additions & 2 deletions package_maximizer/parsers/brew_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ def _parse_brew_info(self, raw: str) -> list[Package]:

return packages

def parse_installed(self, raw: str) -> list[Package]:
"""Parse ``brew list -1`` output (headers only appear on a TTY)."""
packages = self.parse(raw)
for pkg in packages:
pkg.status = "installed"
return packages

def _parse_simple_list(self, raw: str) -> list[Package]:
"""
Разобрать простой список имен пакетов.
Expand Down Expand Up @@ -184,13 +191,13 @@ def _get_all_installed(self) -> list[Package]:
"""
try:
result = subprocess.run(
["brew", "list", "--formula", "--cask"],
["brew", "list", "-1"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return self._parse_brew_list(result.stdout)
return self.parse_installed(result.stdout)
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as e:
logger.warning(f"Failed to get installed packages: {e}")

Expand Down
12 changes: 2 additions & 10 deletions package_maximizer/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from flask import Flask, g, jsonify, request

from .. import __version__
from ..core.enums import PackageManagerType
from ..core.maximizer import PackageMaximizer
from ..core.package import Package
Expand All @@ -17,16 +18,7 @@
app = Flask(__name__)

# ─── Configuration ───────────────────────────────────────────
try:
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _dist_version

try:
APP_VERSION = _dist_version("package-maximizer")
except PackageNotFoundError:
APP_VERSION = "0.6.1"
except ImportError: # pragma: no cover
APP_VERSION = "0.6.1"
APP_VERSION = __version__

API_KEY = os.environ.get("PM_API_KEY", "")
CACHE_ENABLED = os.environ.get("PM_CACHE_ENABLED", "true").lower() == "true"
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ module = [
]
ignore_errors = true

[tool.pytest.ini_options]
markers = [
"real_system: runs real package-manager commands; skipped when the tool is absent",
< 10BC8 td class="blob-code blob-code-addition js-file-line"> ]

[tool.setuptools_scm]
fallback_version = "0.8.0"
fallback_version = "0.29.0"

[tool.package-maximizer]
plugin-metadata-version = "1"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_brew_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,22 @@ def test_parse_from_system_file_not_found(self):
with patch("subprocess.run", side_effect=FileNotFoundError("brew not found")):
result = parser.parse_from_system()
assert result == []


class TestBrewParseInstalled:
"""`brew list -1` output when piped has no section headers."""

def test_parse_installed_marks_status(self):
result = BrewParser().parse_installed("vim\nwget\nfirefox\n")
assert [p.name for p in result] == ["vim", "wget", "firefox"]
assert all(p.status == "installed" for p in result)

def test_get_all_installed_uses_valid_brew_flags(self):
completed = MagicMock(returncode=0, stdout="vim\n")
with patch(
"package_maximizer.parsers.brew_parser.subprocess.run",
return_value=completed,
) as run:
result = BrewParser()._get_all_installed()
assert run.call_args[0][0] == ["brew", "list", "-1"]
assert result[0].status == "installed"
7 changes: 4 additions & 3 deletions tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ def test_init_config_skip_existing(self, tmp_path):
class TestTuiCommand:
"""tui_command should fail cleanly when textual is unavailable."""

def test_tui_missing_textual_exits(self, monkeypatch):
def test_tui_missing_textual_falls_back_to_shell(self, monkeypatch):
monkeypatch.setitem(sys.modules, "package_maximizer.tui.app", None)
runner = CliRunner()
result = runner.invoke(tui_command, [])
assert result.exit_code != 0
result = runner.invoke(tui_command, [], input="quit\n")
assert result.exit_code == 0
assert "pip install 'package-maximizer[tui]'" in result.output


class TestListManagersCommand:
Expand Down
7 changes: 4 additions & 3 deletions tests/test_cli_extended_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,12 @@ def test_export_with_conflicts(self):
class TestTuiCommand:
"""tui command coverage."""

def test_tui_missing_textual_exits(self, monkeypatch):
def test_tui_missing_textual_falls_back_to_shell(self, monkeypatch):
monkeypatch.setitem(sys.modules, "package_maximizer.tui.app", None)
runner = CliRunner()
result = runner.invoke(tui_command, [])
assert result.exit_code != 0
result = runner.invoke(tui_command, [], input="quit\n")
assert result.exit_code == 0
assert "pip install 'package-maximizer[tui]'" in result.output


class TestMaximizeExtended:
Expand Down
14 changes: 14 additions & 0 deletions tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ def test_parse_dpkg_l_format(self):
assert any(p.name == "vim" for p in result)
assert any(p.name == "apache2" for p in result)

def test_parse_dpkg_l_skips_header_rows(self):
"""Real `dpkg -l` output starts with a 5-line legend/header."""
parser = APTParser()
raw = """Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-============-============-=================================
ii vim 2:8.2.3995-1 amd64 Vi IMproved
iU half 1.0 amd64 Unpacked package
"""
result = parser.parse(raw)
assert [p.name for p in result] == ["vim", "half"]

def test_parse_apt_list_format(self):
"""Test parsing apt list --installed format."""
parser = APTParser()
Expand Down
58 changes: 58 additions & 0 deletions tests/test_real_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""RealRepoIntegration against the host's real package manager.

Each test is skipped unless the corresponding tool is installed, so on CI the
APT tests run on ubuntu runners and the Homebrew tests on macOS runners.
"""

from __future__ import annotations

import shutil

import pytest

from package_maximizer.integrations.real_repo_integration import RealRepoIntegration

pytestmark = pytest.mark.real_system

needs_apt = pytest.mark.skipif(
not (shutil.which("dpkg") and shutil.which("apt-cache")),
reason="dpkg/apt-cache not installed",
)
needs_brew = pytest.mark.skipif(not shutil.which("brew"), reason="brew not installed")


@needs_apt
class TestRealAPT:
def test_installed_packages_are_real_rows(self):
packages = RealRepoIntegration("apt").get_installed_packages()
assert packages
names = {p.name for p in packages}
assert "dpkg" in names
assert not any(n.startswith(("Err?", "Name", "|", "+")) for n in names)
assert all(p.version for p in packages)

def test_check_package_installed(self):
integration = RealRepoIntegration("apt")
assert integration.check_package_installed("dpkg")
assert not integration.check_package_installed("no-such-package-pm-xyz")

def test_package_info(self):
info = RealRepoIntegration("apt").get_package_info("dpkg")
if info is None:
pytest.skip("apt cache has no entry for dpkg (lists not downloaded)")
assert info.name == "dpkg"
assert info.version

def test_unknown_package_info_is_none(self):
assert (
RealRepoIntegration("apt").get_package_info("no-such-package-pm-xyz")
is None
)


@needs_brew
class TestRealBrew:
def test_installed_packages(self):
packages = RealRepoIntegration("brew").get_installed_packages()
assert all(p.status == "installed" for p in packages)
assert not any(p.name.startswith("==>") for p in packages)
34 changes: 34 additions & 0 deletions tests/test_version_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Fallback version strings must not drift apart."""

from __future__ import annotations

import importlib
import re
from pathlib import Path

import package_maximizer

ROOT = Path(__file__).resolve().parent.parent


def _fallbacks() -> dict[str, str]:
init = (ROOT / "package_maximizer" / "__init__.py").read_text(encoding="utf-8")
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
citation = (ROOT / "CITATION.cff").read_text(encoding="utf-8")
return {
"__init__": set(re.findall(r'__version__ = "([^"]+)"', init)),
"pyproject": re.search(r'fallback_version = "([^"]+)"', pyproject).group(1),
"CITATION": re.search(r"^version: (\S+)", citation, re.M).group(1),
}


def test_fallback_versions_match():
found = _fallbacks()
assert len(found["__init__"]) == 1
assert found["__init__"] == {found["pyproject"]} == {found["CITATION"]}


def test_web_app_uses_package_version():
web_app = importlib.import_module("package_maximizer.web.app")

assert web_app.APP_VERSION == package_maximizer.__version__
Loading
0