7c24c8c529b273198a0226ed64f8408484255a7f — Nguyễn Gia Phong 8 months ago 8778c71
Implement autoremove
2 files changed, 70 insertions(+), 22 deletions(-)

M anage.py
M pyproject.toml
M anage.py => anage.py +68 -20
@@ 14,11 14,14 @@
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from collections import defaultdict
from collections import defaultdict, deque
from os.path import basename
from pathlib import Path
from subprocess import run
from sys import executable
from textwrap import indent, wrap

from appdirs import user_data_dir
from click import argument, group
from packaging.requirements import Requirement

@@ 28,32 31,58 @@ except ModuleNotFoundError:
    from importlib_metadata import distributions

__doc__ = 'python -manage packages'
__version__ = '0.0.1'
__version__ = '0.0.2'

def dependency_graph():
    """Return the current dependency graph.

    For example, in a new virtual environment, after installing anage::
def ensure_manual():
    """Ensure the file storing manually installed packages exists.

        >>> vertices, edges = dependency_graph()
        >>> vertices
        ('anage', 'click', 'packaging', 'pip', ...)
        >>> edges
        {'anage': ('click', 'packaging', 'pip'), ...}
    Return the path to it.
    vertices, edges = [], defaultdict(list)
    directory = Path(user_data_dir('anage'))
    file = directory / 'manual'
    if not file.exists():
        # Minimal environment plus anage itself
    return file

def dependency_graph():
    """Return the current dependency graph."""
    vertices, edges = set(), defaultdict(set)
    for distribution in distributions():
        d = distribution.metadata['Name']
        for r in distribution.requires or []:
            requirement = Requirement(r)
            marker = requirement.marker
            if marker is None or marker.evaluate({'extra': ''}):
    for requirements in edges.values(): requirements.sort()
    return tuple(vertices), {k: tuple(v) for k, v in sorted(edges.items())}
    return vertices, edges

def dependencies(edges, packages):
    """Return implicit dependencies of given packages."""
    result, queue = set(), deque(packages)
    while queue:
        v = queue.popleft()
        if v in result: continue
    return result

def dependents(edges, packages):
    """Return implicit dependents of given packages."""
    egdes, result, queue = defaultdict(set), set(), deque(packages)
    for k, v in edges.items():
        for i in v: egdes[i].add(k)
    while queue:
        v = queue.popleft()
        if v in result: continue
    return result

@group(context_settings={'help_option_names': ('-h', '--help')})

@@ 65,15 94,34 @@ def cli():
@argument('requirements', nargs=-1)
def install(requirements):
    """Install distributions specified as requirements."""
    run((executable, '-m', 'pip', 'install', *requirements))
    reqset = set(requirements)
    run((executable, '-m', 'pip', 'install', *reqset))

    file = ensure_manual()
    manual = reqset.union(file.read_text().strip().split())
    file.write_text(''.join(f'{r}\n' for r in sorted(manual)))
    print('Marked the following packages as manually installed:')
    print(indent('\n'.join(wrap(' '.join(sorted(reqset)))), '  '))

@argument('distributions', nargs=-1)
def remove(distributions):
    """Remove distributions and automatically installed dependencies."""
    # TODO: remove automatically installed dependencies
    run((executable, '-m', 'pip', 'uninstall', '-y', *distributions))
    file = ensure_manual()
    vertices, edges = dependency_graph()

    must_remove = dependents(edges, distributions)
    manual = set(file.read_text().strip().split())
    must_keep = manual.difference(must_remove)
    should_keep = dependencies(edges, must_keep)
    should_remove = vertices.difference(should_keep)

    print('Remove the following packages?  (^C to abort)')
    input(indent('\n'.join(wrap(' '.join(sorted(should_remove)))), '  '))
    run((executable, '-m', 'pip', 'uninstall', '-y', *should_remove))
    file.write_text(''.join(f'{r}\n' for r in sorted(

if __name__ == '__main__':

M pyproject.toml => pyproject.toml +2 -2
@@ 7,11 7,11 @@ module = 'anage'
author = 'Nguyễn Gia Phong'
author-email = 'mcsinyx@disroot.org'
home-page = 'https://github.com/McSinyx/anage'
requires = ['click', 'importlib_metadata; python_version < "3.8"',
requires = ['appdirs', 'click', 'importlib_metadata; python_version < "3.8"',
            'pip', 'packaging']
description-file = 'README.md'
classifiers = [
    'Development Status :: 1 - Planning',
    'Development Status :: 2 - Pre-Alpha',
    'Environment :: Console',
    'Intended Audience :: End Users/Desktop',
    'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',