Problem
Develop a tool named pkgplan that resolves the installation order of dependencies given a JSON file containing a list of packages and their dependencies.
- Time limit: 90-120 minutes
- Difficulty: medium
- Limitation: standard library only
These JSON files cover valid dependencies, a missing dependency, and a circular dependency:
{
"packages": {
"webapp": {
"depends": ["python", "nginx"]
},
"python": {
"depends": ["libc", "openssl"]
},
"nginx": {
"depends": ["libc", "openssl"]
},
"openssl": {
"depends": ["libc"]
},
"libc": {
"depends": []
}
}
}
{
"packages": {
"webapp": {
"depends": ["python", "nginx"]
},
"python": {
"depends": ["libc", "openssl"]
},
"nginx": {
"depends": ["libc", "openssl"]
},
"openssl": {
"depends": ["libc"]
}
}
}
{
"packages": {
"webapp": {
"depends": ["python", "nginx"]
},
"python": {
"depends": ["libc", "openssl"]
},
"nginx": {
"depends": ["libc", "openssl"]
},
"openssl": {
"depends": ["libc"]
},
"libc": {
"depends": ["openssl"]
}
}
}
From the code directory, run:
pkgplan tests/json/pacakges.json webapp
The expected output for each JSON file is:
Installation plan for webapp:
1. libc
2. openssl
3. python
4. nginx
5. webapp
Requires installing 5 packages.
Error: Missing dependency 'libc' required by 'python'.
Error: Circular dependency detected: libc -> openssl -> libc.
Fundamental logic
The standard library in Python provides the TopologicalSorter in the graphlib module. This class contains the core logic for resolving previous JSON file into an installation plan. We can use the CycleError to detect circular dependencies. Regarding missing packages, we must implement our own logic. For the CLI logic, we can use the argparse module.
Architecture
The layout for this application follows the usual one for CLI applications.
pkgplan/
├── src
│ └── pkgplan
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── dependencies.py
│ ├── loader.py
│ └── print.py
├── tests
│ ├── expected
│ │ ├── broken.txt
│ │ ├── circular.txt
│ │ └── packages.txt
│ ├── json
│ │ ├── broken.json
│ │ ├── circular.json
│ │ └── pacakges.json
│ └── test_pkgplan.py
├── pyproject.toml
└── README.rst
Metadata
pyproject.toml uses Flit
to build the package. With dynamic = ["version"], Flit reads __version__
from __init__.py. The [project.scripts] entry creates the pkgplan command
and connects it to pkgplan.__main__:main. README.rst provides the package
description and instructions for installation, use, and testing.
Package Dependency Planner
==========================
About
-----
``pkgplan`` reads a JSON package catalog and prints the installation order for
the requested package and its dependencies.
Prerequisites
-------------
You need Python 3.12 or later and pip. The planner uses only the Python standard
library at runtime.
Getting Started
---------------
From this directory, install the package:
.. code-block:: console
python -m pip install .
Generate an installation plan:
.. code-block:: console
pkgplan tests/json/pacakges.json webapp
User Guide
----------
The installed package also supports module execution:
.. code-block:: console
python -m pkgplan tests/json/pacakges.json webapp
Use ``pkgplan --help`` to see the command-line arguments.
The JSON file must contain a ``packages`` object. Each package has a ``depends``
list of package names. Use an empty list for packages with no dependencies:
.. code-block:: json
{
"packages": {
"app": {
"depends": ["libc"]
},
"libc": {
"depends": []
}
}
}
The planner includes only the requested package and its dependencies. Shared
dependencies appear once, before the packages that require them. Every
package's metadata is validated, but missing references and cycles are checked
only in the requested dependency graph.
Successful plans go to standard output and return exit status 0. File errors,
invalid catalogs, unknown packages, missing dependencies, and cycles go to
standard error and return status 1. These failures produce no partial plan.
Invalid or missing command-line arguments return status 2.
The ``tests/json`` directory contains test inputs, including valid,
missing-dependency, and circular examples. Their expected output is stored in
``tests/expected``. To run the error examples:
.. code-block:: console
pkgplan tests/json/broken.json webapp
pkgplan tests/json/circular.json webapp
Flit builds the package using ``pyproject.toml``. The version is declared once
as ``__version__`` in ``src/pkgplan/__init__.py`` and read by Flit through
``dynamic = ["version"]``. The ``[project.scripts]`` entry creates the
``pkgplan`` command and calls ``pkgplan.__main__:main``.
The suite has one test each for valid, circular, and broken dependencies.
Each test loads a catalog from ``tests/json`` and checks the result against
the corresponding text file in ``tests/expected``. The tests call the loader,
resolver, and printer directly.
Run the standard-library tests from this directory:
.. code-block:: console
python -m unittest discover -s tests
[build-system]
build-backend = "flit_core.buildapi"
requires = [ "flit_core>=3.11,<5" ]
[project]
name = "pkgplan"
dynamic = [ "version" ]
description = "Plan package installation from JSON dependencies."
readme = "README.rst"
requires-python = ">=3.12"
authors = [ { name = "Jorge Martinez" } ]
dependencies = []
[project.scripts]
pkgplan = "pkgplan.__main__:main"
[tool.flit.sdist]
include = [ "tests/" ]
"""Resolve package installation order using the Python standard library.
The loader validates JSON catalogs, the dependency resolver builds an
installation plan, and the printing module formats plans and errors.
Importing this package does not run the command-line interface.
Notes
-----
Flit reads ``__version__`` when building the package. Update this value to
change the distribution version.
"""
__version__ = "1.0.dev0"
Implementation
Use Python 3.12 or later. Install the package from the code directory with
python -m pip install ..
You can then run pkgplan or python -m pkgplan.
"""Run the package dependency planner.
The `main` function connects argument parsing, catalog loading, dependency
resolution, and output. Both the installed ``pkgplan`` command and
``python -m pkgplan`` use this entry point.
"""
from collections.abc import Sequence
from .cli import parse_args
from .dependencies import resolve_dependencies
from .loader import load_packages
from .print import print_error, print_pkgplan
def main(argv: Sequence[str] | None = None) -> int:
"""Run the planner and return its exit status.
Load the requested catalog, build the installation plan, and print it.
File, decoding, catalog, and dependency errors are written to standard
error without printing an installation plan.
Parameters
----------
argv : sequence of str, optional
Command-line arguments, excluding the executable name. If ``None``,
use ``sys.argv[1:]``.
Returns
-------
int
0 if the plan was printed successfully, or 1 if reading the file,
validating the catalog, or resolving dependencies failed.
Raises
------
SystemExit
If argument parsing exits: status 0 for ``--help`` or status 2
for invalid or missing arguments.
See Also
--------
pkgplan.loader.load_packages : Read and validate the catalog.
pkgplan.dependencies.resolve_dependencies : Build the installation plan.
pkgplan.print.print_pkgplan : Print the plan and package count.
Notes
-----
The catalog is opened as UTF-8 text and closed before dependencies are
resolved. The caller converts the returned status into a process exit
status; calling this function directly does not exit after planning.
"""
args = parse_args(argv)
try:
with args.jsonfile.open(encoding="utf-8") as file:
packages = load_packages(file)
plan = resolve_dependencies(packages, args.package)
except (OSError, UnicodeError, ValueError) as error:
print_error(error)
return 1
print_pkgplan(args.package, plan)
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Define arguments for the package dependency planner.
The ``parse_args`` function reads two positional command-line arguments:
``jsonfile``, converted to a ``pathlib.Path``, and ``package``, kept as a string.
Parsing does not open the file or resolve dependencies.
Notes
-----
The parser exits with status 0 for ``--help`` and status 2 for invalid
arguments. The application calls ``parse_args`` from its entry point.
"""
import argparse
from collections.abc import Sequence
from pathlib import Path
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Parse arguments, using the command line when ``argv`` is ``None``."""
parser = argparse.ArgumentParser(
prog="pkgplan",
description="Find the installation order for a package and its dependencies.",
)
parser.add_argument(
"jsonfile",
type=Path,
help="JSON file containing available packages and their dependencies.",
)
parser.add_argument("package", help="Package to install.")
return parser.parse_args(argv)
"""Resolve installation order and detect dependency errors.
The resolver collects the requested package's dependency graph and uses
`graphlib.TopologicalSorter` to order packages before their dependents.
"""
from collections.abc import Mapping, Sequence
from graphlib import CycleError, TopologicalSorter
def resolve_dependencies(
packages: Mapping[str, Sequence[str]], package: str
) -> list[str]:
"""Return the installation order for a package and its dependencies.
Include only packages reachable from the requested package. Shared
dependencies appear once in the returned plan.
Parameters
----------
packages : mapping of str to sequence of str
Package names mapped to dependency lists, as returned by the
catalog loader. The mapping is not modified.
package : str
Name of the package to install.
Returns
-------
list of str
Package names in dependency order, ending with `package`. Each
dependency appears before the packages that require it.
Raises
------
ValueError
If `package` is unknown, a reachable package requires a missing
dependency, or the reachable graph contains a cycle. Cycle errors
include a closed path through the conflicting packages.
See Also
--------
pkgplan.loader.load_packages : Read and validate a package catalog.
graphlib.TopologicalSorter : Order a graph by predecessor relationships.
Notes
-----
An explicit stack collects the reachable graph without recursion.
Dependencies are pushed in reverse order to visit them in their listed
order. The graph maps each package to its prerequisites, as required by
`graphlib.TopologicalSorter`. Multiple installation orders can be valid
when packages do not depend on one another.
Missing references and cycles in unrelated packages do not affect the
plan. Traversal and sorting take O(V + E) time and space for the
reachable graph, where V is the number of packages and E the number of
dependency references.
Examples
--------
>>> from pkgplan.dependencies import resolve_dependencies
>>> packages = {"app": ["python"], "python": ["libc"], "libc": []}
>>> resolve_dependencies(packages, "app")
['libc', 'python', 'app']
"""
if package not in packages:
raise ValueError(f"Unknown package '{package}'.")
graph: dict[str, Sequence[str]] = {}
pending = [package]
while pending:
name = pending.pop()
if name in graph:
continue
dependencies = packages[name]
for dependency in dependencies:
if dependency not in packages:
raise ValueError(
f"Missing dependency '{dependency}' required by '{name}'."
)
graph[name] = dependencies
pending.extend(reversed(dependencies))
try:
return list(TopologicalSorter(graph).static_order())
except CycleError as error:
cycle = " -> ".join(error.args[1])
raise ValueError(f"Circular dependency detected: {cycle}.") from error
"""Load and validate JSON package catalogs.
The loader converts package metadata into the dependency mapping accepted
by the resolver. It checks the catalog structure before graph traversal.
"""
import json
from typing import TextIO
def load_packages(file: TextIO) -> dict[str, list[str]]:
"""Read and validate package dependencies from a JSON file.
Convert the catalog's ``packages`` object into a mapping from package
names to their dependency lists.
Parameters
----------
file : file-like object
Readable text stream containing a JSON object with a ``packages``
mapping. Each package must have a metadata object whose ``depends``
value is a list of strings, including an empty list for no
dependencies. Reading starts at the stream's current position.
Returns
-------
dict of str to list of str
Package names mapped to dependency lists in catalog order.
Raises
------
json.JSONDecodeError
If the stream contains invalid JSON.
ValueError
If the catalog has no ``packages`` object, package metadata is not
an object, or a ``depends`` value is not a list of strings.
OSError
If reading the stream fails.
UnicodeError
If decoding the text stream fails.
See Also
--------
pkgplan.dependencies.resolve_dependencies : Check dependency references.
Notes
-----
The caller owns the stream and must close it. This function validates
every package's metadata, including packages outside the requested
graph. Missing dependency references and cycles are checked later by
the resolver.
Examples
--------
>>> import io
>>> from pkgplan.loader import load_packages
>>> catalog = io.StringIO('{"packages": {"libc": {"depends": []}}}')
>>> load_packages(catalog)
{'libc': []}
"""
catalog = json.load(file)
if not isinstance(catalog, dict) or not isinstance(catalog.get("packages"), dict):
raise ValueError("JSON file must contain a 'packages' object.")
packages: dict[str, list[str]] = {}
for name, metadata in catalog["packages"].items():
if not isinstance(metadata, dict):
raise ValueError(f"Package '{name}' must be an object.")
dependencies = metadata.get("depends")
if not isinstance(dependencies, list) or not all(
isinstance(dependency, str) for dependency in dependencies
):
raise ValueError(f"Package '{name}' must have a 'depends' list of strings.")
packages[name] = dependencies
return packages
"""Print installation plans and errors.
Plans are written to standard output. Error messages are written to
standard error so callers can capture them separately.
"""
import sys
from collections.abc import Iterable
def print_pkgplan(package: str, plan: Iterable[str]) -> None:
"""Print the installation order and package count to standard output.
Write the package heading, numbered installation steps, and total
count using the command-line output format.
Parameters
----------
package : str
Name of the requested package, used in the heading.
plan : iterable of str
Package names in installation order. The iterable must be finite.
A generator is consumed once and materialized before printing.
See Also
--------
pkgplan.dependencies.resolve_dependencies : Build the installation plan.
print_error : Write an error to standard error.
Notes
-----
The count includes the requested package. This function preserves the
supplied order and does not validate or deduplicate the plan.
Examples
--------
>>> from pkgplan.print import print_pkgplan
>>> print_pkgplan("python", iter(["libc", "python"]))
Installation plan for python:
<BLANKLINE>
1. libc
2. python
<BLANKLINE>
Requires installing 2 packages.
"""
installation_order = list(plan)
print(f"Installation plan for {package}:\n")
for index, dependency in enumerate(installation_order, 1):
print(f"{index}. {dependency}")
count = len(installation_order)
noun = "package" if count == 1 else "packages"
print(f"\nRequires installing {count} {noun}.")
def print_error(error: Exception | str) -> None:
"""Print an error to standard error.
Prefix the error description with ``Error:`` and append a newline.
Parameters
----------
error : Exception or str
Error description, converted to a string for display.
See Also
--------
print_pkgplan : Write a successful installation plan to standard output.
Examples
--------
>>> from contextlib import redirect_stderr
>>> from io import StringIO
>>> from pkgplan.print import print_error
>>> stream = StringIO()
>>> with redirect_stderr(stream):
... print_error(ValueError("Unknown package 'app'."))
>>> print(stream.getvalue(), end="")
Error: Unknown package 'app'.
"""
print(f"Error: {error}", file=sys.stderr)
Explanation
__main__.py coordinates the planner. It calls parse_args() from cli.py
to parse the JSON file path and package name with argparse. The entry point
opens the file as UTF-8 text, and loader.py validates the catalog and returns
a dictionary mapping package names to dependency lists.
dependencies.py uses an explicit stack to collect the requested package’s
dependencies, checking for missing references along the way. It passes this
graph to graphlib.TopologicalSorter, which places dependencies before their
dependents and includes each package once. Unknown packages, missing dependencies,
and cycles raise ValueError; cycle messages include the conflicting path.
Unrelated packages are excluded from the plan.
print.py writes the numbered plan and package count to standard output, or
errors to standard error. Planning finishes before output begins, so failures
produce no partial plan. Both pkgplan and python -m pkgplan call main():
success returns status 0, file or planning errors return 1, and invalid arguments
exit with 2. __init__.py identifies the package without running the planner.
Tests
The test suite uses the standard library’s unittest module. Each test loads
a catalog from tests/json and compares its result with a text file in
tests/expected. From the code directory, run:
python -m unittest discover -s tests
PlannerTests has one test for each case: valid, circular, and broken
dependencies. The valid test resolves the installation order, captures
print_pkgplan() output, and compares it with packages.txt. The circular and
broken tests check that resolve_dependencies() raises ValueError with the
expected message. The tests call the loader, resolver, and printer directly.
"""Test valid, circular, and broken package dependencies."""
import unittest
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from pkgplan.dependencies import resolve_dependencies
from pkgplan.loader import load_packages
from pkgplan.print import print_pkgplan
TESTS_DIR = Path(__file__).resolve().parent
def load_catalog(name: str) -> dict[str, list[str]]:
with (TESTS_DIR / "json" / f"{name}.json").open(encoding="utf-8") as file:
return load_packages(file)
def expected_output(name: str) -> str:
return (TESTS_DIR / "expected" / f"{name}.txt").read_text(encoding="utf-8")
class PlannerTests(unittest.TestCase):
def test_valid_dependencies(self) -> None:
packages = load_catalog("pacakges")
plan = resolve_dependencies(packages, "webapp")
output = StringIO()
with redirect_stdout(output):
print_pkgplan("webapp", plan)
self.assertEqual(output.getvalue(), expected_output("packages"))
def test_circular_dependencies(self) -> None:
packages = load_catalog("circular")
with self.assertRaises(ValueError) as error:
resolve_dependencies(packages, "webapp")
self.assertEqual(f"Error: {error.exception}\n", expected_output("circular"))
def test_broken_dependencies(self) -> None:
packages = load_catalog("broken")
with self.assertRaises(ValueError) as error:
resolve_dependencies(packages, "webapp")
self.assertEqual(f"Error: {error.exception}\n", expected_output("broken"))
if __name__ == "__main__":
unittest.main()