diff --git a/docs/rtd_environment.yml b/docs/rtd_environment.yml index 803d0221b..9bf674588 100644 --- a/docs/rtd_environment.yml +++ b/docs/rtd_environment.yml @@ -25,10 +25,10 @@ dependencies: - click-default-group - networkx >=2.4 - pluggy - - pony >=0.7.15 - pybaum >=0.1.1 - pexpect - rich + - sqlalchemy >=1.4.36 - tomli >=1.0.0 - pip: diff --git a/docs/source/changes.md b/docs/source/changes.md index c0b83b04e..216178049 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -8,6 +8,7 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## 0.4.0 - 2023-xx-xx - {pull}`323` remove Python 3.7 support and use a new Github action to provide mamba. +- {pull}`387` replaces pony with sqlalchemy. ## 0.3.2 - 2023-06-07 diff --git a/docs/source/reference_guides/configuration.md b/docs/source/reference_guides/configuration.md index cad669495..407553d60 100644 --- a/docs/source/reference_guides/configuration.md +++ b/docs/source/reference_guides/configuration.md @@ -42,6 +42,23 @@ are welcome to also support macOS. ```` +````{confval} database_url + +pytask uses a database to keep track of tasks, products, and dependencies over runs. By +default, it will create an SQLITE database in the project's root directory called +`.pytask.sqlite3`. If you want to use a different name or a different dialect +[supported by sqlalchemy](https://docs.sqlalchemy.org/en/latest/core/engines.html#backend-specific-urls), +use either {option}`pytask build --database-url` or `database_url` in the config. + +```toml +database_url = "sqlite:///.pytask.sqlite3" +``` + +Relative paths for SQLITE databases are interpreted as either relative to the +configuration file or the root directory. + +```` + ````{confval} editor_url_scheme Depending on your terminal, pytask is able to turn task ids into clickable links to the diff --git a/environment.yml b/environment.yml index 09a9838d6..1c58b9c53 100644 --- a/environment.yml +++ b/environment.yml @@ -16,9 +16,9 @@ dependencies: - click-default-group - networkx >=2.4 - pluggy - - pony >=0.7.15 - pybaum >=0.1.1 - rich + - sqlalchemy >=1.4.36 - tomli >=1.0.0 # Misc diff --git a/setup.cfg b/setup.cfg index 09600d21f..700c89d57 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,9 +36,9 @@ install_requires = networkx>=2.4 packaging pluggy - pony>=0.7.15 pybaum>=0.1.1 rich + sqlalchemy>=1.4.36 tomli>=1.0.0 python_requires = >=3.8 include_package_data = True diff --git a/src/_pytask/clean.py b/src/_pytask/clean.py index 7ec12f4ec..2004670e2 100644 --- a/src/_pytask/clean.py +++ b/src/_pytask/clean.py @@ -195,7 +195,10 @@ def _collect_all_paths_known_to_pytask(session: Session) -> set[Path]: if session.config["config"]: known_paths.add(session.config["config"]) known_paths.add(session.config["root"]) - known_paths.add(session.config["database_filename"]) + + database_url = session.config["database_url"] + if database_url.drivername == "sqlite" and database_url.database: + known_paths.add(Path(database_url.database)) # Add files tracked by git. if is_git_installed(): diff --git a/src/_pytask/click.py b/src/_pytask/click.py index b55fe0b63..d972cd257 100644 --- a/src/_pytask/click.py +++ b/src/_pytask/click.py @@ -242,11 +242,11 @@ def _format_help_text( # noqa: C901, PLR0912, PLR0915 if show_default_is_str or (show_default and (default_value is not None)): if show_default_is_str: - default_string = f"({param.show_default})" # type: ignore[attr-defined] + default_string = param.show_default # type: ignore[attr-defined] elif isinstance(default_value, (list, tuple)): default_string = ", ".join(str(d) for d in default_value) elif inspect.isfunction(default_value): - default_string = _("(dynamic)") + default_string = _("dynamic") elif param.is_bool_flag and param.secondary_opts: # type: ignore[attr-defined] # For boolean flags that have distinct True/False opts, # use the opt without prefix instead of the value. diff --git a/src/_pytask/dag.py b/src/_pytask/dag.py index 102e223d4..c6acf0346 100644 --- a/src/_pytask/dag.py +++ b/src/_pytask/dag.py @@ -16,6 +16,7 @@ from _pytask.dag_utils import node_and_neighbors from _pytask.dag_utils import task_and_descending_tasks from _pytask.dag_utils import TopologicalSorter +from _pytask.database_utils import DatabaseSession from _pytask.database_utils import State from _pytask.exceptions import ResolvingDependenciesError from _pytask.mark import Mark @@ -30,7 +31,6 @@ from _pytask.shared import reduce_names_of_multiple_nodes from _pytask.shared import reduce_node_name from _pytask.traceback import render_exc_info -from pony import orm from pybaum import tree_map from rich.text import Text from rich.tree import Tree @@ -126,7 +126,6 @@ def _have_task_or_neighbors_changed( ) -@orm.db_session @hookimpl(trylast=True) def pytask_dag_has_node_changed(node: MetaNode, task_name: str) -> bool: """Indicate whether a single dependency or product has changed.""" @@ -136,11 +135,11 @@ def pytask_dag_has_node_changed(node: MetaNode, task_name: str) -> bool: if file_state is None: return True + with DatabaseSession() as session: + db_state = session.get(State, (task_name, node.name)) + # If the node is not in the database. - try: - name = node.name - db_state = State[task_name, name] # type: ignore[type-arg, valid-type] - except orm.ObjectNotFound: + if db_state is None: return True # If the modification times match, the node has not been changed. diff --git a/src/_pytask/database.py b/src/_pytask/database.py index c229717e4..ea797e81f 100644 --- a/src/_pytask/database.py +++ b/src/_pytask/database.py @@ -1,86 +1,43 @@ -"""Implement the database managed with pony.""" +"""Contains hooks related to the database.""" from __future__ import annotations -import enum from pathlib import Path from typing import Any -import click -from _pytask.click import EnumChoice from _pytask.config import hookimpl from _pytask.database_utils import create_database -from click import Context - - -class _DatabaseProviders(enum.Enum): - SQLITE = "sqlite" - POSTGRES = "postgres" - MYSQL = "mysql" - ORACLE = "oracle" - COCKROACH = "cockroach" - - -def _database_filename_callback( - ctx: Context, name: str, value: str | None # noqa: ARG001 -) -> str | None: - if value is None: - return ctx.params["root"].joinpath(".pytask.sqlite3") - return value - - -@hookimpl -def pytask_extend_command_line_interface(cli: click.Group) -> None: - """Extend command line interface.""" - additional_parameters = [ - click.Option( - ["--database-provider"], - type=EnumChoice(_DatabaseProviders), - help=( - "Database provider. All providers except sqlite are considered " - "experimental." - ), - default=_DatabaseProviders.SQLITE, - ), - click.Option( - ["--database-filename"], - type=click.Path(file_okay=True, dir_okay=False, path_type=Path), - help=("Path to database relative to root."), - default=Path(".pytask.sqlite3"), - callback=_database_filename_callback, - ), - click.Option( - ["--database-create-db"], - type=bool, - help="Create database if it does not exist.", - default=True, - ), - click.Option( - ["--database-create-tables"], - type=bool, - help="Create tables if they do not exist.", - default=True, - ), - ] - cli.commands["build"].params.extend(additional_parameters) +from sqlalchemy.engine import make_url @hookimpl def pytask_parse_config(config: dict[str, Any]) -> None: """Parse the configuration.""" - if not config["database_filename"].is_absolute(): - config["database_filename"] = config["root"].joinpath( - config["database_filename"] + # Set default. + if not config["database_url"]: + config["database_url"] = make_url( + f"sqlite:///{config['root'].as_posix()}/.pytask.sqlite3" ) - config["database"] = { - "provider": config["database_provider"].value, - "filename": config["database_filename"].as_posix(), - "create_db": config["database_create_db"], - "create_tables": config["database_create_tables"], - } + if ( + config["database_url"].drivername == "sqlite" + and config["database_url"].database + ) and not Path(config["database_url"].database).is_absolute(): + if config["config"]: + full_path = ( + config["config"] + .parent.joinpath(config["database_url"].database) + .resolve() + ) + else: + full_path = ( + config["root"].joinpath(config["database_url"].database).resolve() + ) + config["database_url"] = config["database_url"]._replace( + database=full_path.as_posix() + ) @hookimpl def pytask_post_parse(config: dict[str, Any]) -> None: """Post-parse the configuration.""" - create_database(**config["database"]) + create_database(config["database_url"]) diff --git a/src/_pytask/database_utils.py b/src/_pytask/database_utils.py index 1ad974abe..486e33320 100644 --- a/src/_pytask/database_utils.py +++ b/src/_pytask/database_utils.py @@ -6,54 +6,64 @@ from _pytask.dag_utils import node_and_neighbors from _pytask.nodes import Task from _pytask.session import Session -from pony import orm +from sqlalchemy import Column +from sqlalchemy import create_engine +from sqlalchemy import String +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import sessionmaker -__all__ = ["create_database", "db", "update_states_in_database"] +__all__ = ["create_database", "update_states_in_database", "DatabaseSession"] -db = orm.Database() +DatabaseSession = sessionmaker() -class State(db.Entity): # type: ignore[name-defined] +Base = declarative_base() + + +class State(Base): # type: ignore[valid-type, misc] """Represent the state of a node in relation to a task.""" - task = orm.Required(str) - node = orm.Required(str) - modification_time = orm.Required(str) - file_hash = orm.Optional(str) + __tablename__ = "state" - orm.PrimaryKey(task, node) + task = Column(String, primary_key=True) + node = Column(String, primary_key=True) + modification_time = Column(String) + file_hash = Column(String) -def create_database( - provider: str, filename: str, *, create_db: bool, create_tables: bool -) -> None: +def create_database(url: str) -> None: """Create the database.""" try: - db.bind(provider=provider, filename=filename, create_db=create_db) - db.generate_mapping(create_tables=create_tables) - except orm.BindingError: - pass + engine = create_engine(url) + Base.metadata.create_all(bind=engine) + DatabaseSession.configure(bind=engine) + except Exception: + raise -@orm.db_session def _create_or_update_state( first_key: str, second_key: str, modification_time: str, file_hash: str ) -> None: """Create or update a state.""" - try: - state_in_db = State[first_key, second_key] # type: ignore[type-arg, valid-type] - except orm.ObjectNotFound: - State( - task=first_key, - node=second_key, - modification_time=modification_time, - file_hash=file_hash, - ) - else: - state_in_db.modification_time = modification_time - state_in_db.file_hash = file_hash + with DatabaseSession() as session: + state_in_db = session.get(State, (first_key, second_key)) + + if not state_in_db: + session.add( + State( + task=first_key, + node=second_key, + modification_time=modification_time, + file_hash=file_hash, + ) + ) + else: + state_in_db.modification_time = modification_time + state_in_db.file_hash = file_hash + + session.commit() def update_states_in_database(session: Session, task_name: str) -> None: diff --git a/src/_pytask/parameters.py b/src/_pytask/parameters.py index 3690b014e..d3fd83599 100644 --- a/src/_pytask/parameters.py +++ b/src/_pytask/parameters.py @@ -6,6 +6,10 @@ import click from _pytask.config import hookimpl from _pytask.config_utils import set_defaults_from_config +from click import Context +from sqlalchemy.engine import make_url +from sqlalchemy.engine import URL +from sqlalchemy.exc import ArgumentError _CONFIG_OPTION = click.Option( @@ -67,11 +71,34 @@ """click.Option: An option to embed URLs in task ids.""" +def _database_url_callback( + ctx: Context, name: str, value: str | None # noqa: ARG001 +) -> URL: + try: + return make_url(value) + except ArgumentError: + raise click.BadParameter( + "The 'database_url' must conform to sqlalchemy's url standard: " + "https://docs.sqlalchemy.org/en/latest/core/engines.html" + "#backend-specific-urls." + ) from None + + +_DATABASE_URL_OPTION = click.Option( + ["--database-url"], + type=str, + help=("Url to the database."), + default=None, + show_default="sqlite:///.../.pytask.sqlite3", + callback=_database_url_callback, +) + + @hookimpl(trylast=True) def pytask_extend_command_line_interface(cli: click.Group) -> None: """Register general markers.""" for command in ("build", "clean", "collect", "dag", "profile"): - cli.commands[command].params.append(_PATH_ARGUMENT) + cli.commands[command].params.extend([_PATH_ARGUMENT, _DATABASE_URL_OPTION]) for command in ("build", "clean", "collect", "dag", "markers", "profile"): cli.commands[command].params.append(_CONFIG_OPTION) for command in ("build", "clean", "collect", "profile"): diff --git a/src/_pytask/profile.py b/src/_pytask/profile.py index cd8e71102..3b6f93695 100644 --- a/src/_pytask/profile.py +++ b/src/_pytask/profile.py @@ -19,7 +19,8 @@ from _pytask.config import hookimpl from _pytask.console import console from _pytask.console import format_task_id -from _pytask.database_utils import db +from _pytask.database_utils import Base +from _pytask.database_utils import DatabaseSession from _pytask.exceptions import CollectionError from _pytask.exceptions import ConfigurationError from _pytask.nodes import FilePathNode @@ -30,8 +31,10 @@ from _pytask.report import ExecutionReport from _pytask.session import Session from _pytask.traceback import render_exc_info -from pony import orm from rich.table import Table +from sqlalchemy import Column +from sqlalchemy import Float +from sqlalchemy import String if TYPE_CHECKING: @@ -44,12 +47,14 @@ class _ExportFormats(enum.Enum): CSV = "csv" -class Runtime(db.Entity): # type: ignore[name-defined] +class Runtime(Base): # type: ignore[valid-type, misc] """Record of runtimes of tasks.""" - task = orm.PrimaryKey(str) - date = orm.Required(float) - duration = orm.Required(float) + __tablename__ = "runtime" + + task = Column(String, primary_key=True) + date = Column(Float) + duration = Column(Float) @hookimpl(tryfirst=True) @@ -84,16 +89,18 @@ def pytask_execute_task_process_report(report: ExecutionReport) -> None: _create_or_update_runtime(task.name, *duration) -@orm.db_session def _create_or_update_runtime(task_name: str, start: float, end: float) -> None: """Create or update a runtime entry.""" - try: - runtime = Runtime[task_name] # type: ignore[type-arg, valid-type] - except orm.ObjectNotFound: - Runtime(task=task_name, date=start, duration=end - start) - else: - for attr, val in (("date", start), ("duration", end - start)): - setattr(runtime, attr, val) + with DatabaseSession() as session: + runtime = session.get(Runtime, task_name) + + if not runtime: + session.add(Runtime(task=task_name, date=start, duration=end - start)) + else: + for attr, val in (("date", start), ("duration", end - start)): + setattr(runtime, attr, val) + + session.commit() @click.command(cls=ColoredCommand) @@ -198,10 +205,10 @@ def pytask_profile_add_info_on_task( profile[name]["Duration (in s)"] = round(duration, 2) -@orm.db_session def _collect_runtimes(task_names: list[str]) -> dict[str, float]: """Collect runtimes.""" - runtimes = [Runtime.get(task=task_name) for task_name in task_names] + with DatabaseSession() as session: + runtimes = [session.get(Runtime, task_name) for task_name in task_names] runtimes = [r for r in runtimes if r is not None] return {r.task: r.duration for r in runtimes} diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py index 7f1407f3d..3dcdc2a9e 100644 --- a/src/pytask/__init__.py +++ b/src/pytask/__init__.py @@ -13,7 +13,9 @@ from _pytask.compat import import_optional_dependency from _pytask.config import hookimpl from _pytask.console import console -from _pytask.database_utils import db +from _pytask.database_utils import create_database +from _pytask.database_utils import DatabaseSession +from _pytask.database_utils import State from _pytask.exceptions import CollectionError from _pytask.exceptions import ConfigurationError from _pytask.exceptions import ExecutionError @@ -44,6 +46,7 @@ from _pytask.outcomes import SkippedAncestorFailed from _pytask.outcomes import SkippedUnchanged from _pytask.outcomes import TaskOutcome +from _pytask.profile import Runtime from _pytask.report import CollectionReport from _pytask.report import DagReport from _pytask.report import ExecutionReport @@ -69,6 +72,8 @@ "ColoredCommand", "ColoredGroup", "ConfigurationError", + "DagReport", + "DatabaseSession", "EnumChoice", "ExecutionError", "ExecutionReport", @@ -84,11 +89,12 @@ "Persisted", "PytaskError", "ResolvingDependenciesError", - "DagReport", + "Runtime", "Session", "Skipped", "SkippedAncestorFailed", "SkippedUnchanged", + "State", "Task", "TaskOutcome", "WarningReport", @@ -98,7 +104,7 @@ "cli", "console", "count_outcomes", - "db", + "create_database", "depends_on", "format_exception_without_traceback", "get_all_marks", diff --git a/tests/test_clean.py b/tests/test_clean.py index c29a2a074..a74752740 100644 --- a/tests/test_clean.py +++ b/tests/test_clean.py @@ -56,11 +56,29 @@ def task_write_text(produces): return tmp_path +@pytest.mark.end_to_end() +def test_clean_database_ignored(project, runner): + cwd = Path.cwd() + os.chdir(project) + result = runner.invoke(cli, ["build"]) + assert result.exit_code == ExitCode.OK + result = runner.invoke(cli, ["clean"]) + assert result.exit_code == ExitCode.OK + os.chdir(cwd) + + assert result.exit_code == ExitCode.OK + text_without_linebreaks = result.output.replace("\n", "") + assert "to_be_deleted_file_1.txt" in text_without_linebreaks + assert "to_be_deleted_file_2.txt" in text_without_linebreaks + assert ".pytask.sqlite3" not in text_without_linebreaks + + @pytest.mark.end_to_end() def test_clean_with_auto_collect(project, runner): cwd = Path.cwd() os.chdir(project) result = runner.invoke(cli, ["clean"]) + assert result.exit_code == ExitCode.OK os.chdir(cwd) assert result.exit_code == ExitCode.OK diff --git a/tests/test_click.py b/tests/test_click.py index 05b6504c4..2f0055242 100644 --- a/tests/test_click.py +++ b/tests/test_click.py @@ -12,20 +12,13 @@ def test_choices_are_displayed_in_help_page(runner): result = runner.invoke(cli, ["build", "--help"]) assert "[no|stdout|stderr|all]" in result.output - # Test that a long meta var is folded. - assert "[sqlite|postgres|mysql|oracle|cockroach]" not in result.output - assert "sqlite" in result.output - assert "postgres" in result.output - assert "mysql" in result.output - assert "oracle" in result.output - assert "cockroach" not in result.output assert "[fd|no|sys|tee-sys]" in result.output @pytest.mark.end_to_end() def test_defaults_are_displayed(runner): result = runner.invoke(cli, ["build", "--help"]) - assert "[default: True]" in result.output + assert "[default: all]" in result.output @pytest.mark.unit() diff --git a/tests/test_compat.py b/tests/test_compat.py index 394c67980..323087f0b 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -91,9 +91,9 @@ def test_import_optional(): @pytest.mark.unit() -def test_pony_version_fallback(): - pytest.importorskip("pony") - import_optional_dependency("pony") +def test_sqlalchemy_version_fallback(): + pytest.importorskip("sqlalchemy") + import_optional_dependency("sqlalchemy") @pytest.mark.unit() diff --git a/tests/test_database.py b/tests/test_database.py index a11852551..36d831d47 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -4,10 +4,11 @@ import pytest from _pytask.database_utils import create_database +from _pytask.database_utils import DatabaseSession from _pytask.database_utils import State -from pony import orm from pytask import cli from pytask import ExitCode +from sqlalchemy.engine import make_url @pytest.mark.end_to_end() @@ -30,14 +31,11 @@ def task_write(produces): assert result.exit_code == ExitCode.OK - with orm.db_session: - create_database( - "sqlite", - tmp_path.joinpath(".pytask.sqlite3").as_posix(), - create_db=True, - create_tables=False, - ) + create_database( + make_url("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix()) + ) + with DatabaseSession() as session: task_id = task_path.as_posix() + "::task_write" out_path = tmp_path.joinpath("out.txt") @@ -46,26 +44,29 @@ def task_write(produces): (in_path.as_posix(), in_path), (out_path.as_posix(), out_path), ): - modification_time = State[task_id, id_].modification_time + modification_time = session.get(State, (task_id, id_)).modification_time assert float(modification_time) == path.stat().st_mtime @pytest.mark.end_to_end() def test_rename_database_w_config(tmp_path, runner): """Modification dates of input and output files are stored in database.""" + path_to_db = tmp_path.joinpath(".db.sqlite") tmp_path.joinpath("pyproject.toml").write_text( - "[tool.pytask.ini_options]\ndatabase_filename='.db.sqlite3'" + "[tool.pytask.ini_options]\ndatabase_url='sqlite:///.db.sqlite'" ) result = runner.invoke(cli, [tmp_path.as_posix()]) assert result.exit_code == ExitCode.OK - tmp_path.joinpath(".db.sqlite3").exists() + assert path_to_db.exists() @pytest.mark.end_to_end() def test_rename_database_w_cli(tmp_path, runner): """Modification dates of input and output files are stored in database.""" + path_to_db = tmp_path.joinpath(".db.sqlite") result = runner.invoke( - cli, ["--database-filename", ".db.sqlite3", tmp_path.as_posix()] + cli, + ["--database-url", "sqlite:///.db.sqlite", tmp_path.as_posix()], ) assert result.exit_code == ExitCode.OK - tmp_path.joinpath(".db.sqlite3").exists() + assert path_to_db.exists() diff --git a/tests/test_persist.py b/tests/test_persist.py index 0b6bf8bb1..071155841 100644 --- a/tests/test_persist.py +++ b/tests/test_persist.py @@ -3,10 +3,10 @@ import textwrap import pytest -from _pytask.database_utils import create_database from _pytask.database_utils import State from _pytask.persist import pytask_execute_task_process_report -from pony import orm +from pytask import create_database +from pytask import DatabaseSession from pytask import ExitCode from pytask import main from pytask import Persisted @@ -63,17 +63,13 @@ def task_dummy(depends_on, produces): assert session.execution_reports[0].outcome == TaskOutcome.PERSISTENCE assert isinstance(session.execution_reports[0].exc_info[1], Persisted) - with orm.db_session: - create_database( - "sqlite", - tmp_path.joinpath(".pytask.sqlite3").as_posix(), - create_db=True, - create_tables=False, - ) + create_database("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix()) + + with DatabaseSession() as session: task_id = tmp_path.joinpath("task_module.py").as_posix() + "::task_dummy" node_id = tmp_path.joinpath("out.txt").as_posix() - modification_time = State[task_id, node_id].modification_time + modification_time = session.get(State, (task_id, node_id)).modification_time assert float(modification_time) == tmp_path.joinpath("out.txt").stat().st_mtime session = main({"paths": tmp_path}) diff --git a/tests/test_profile.py b/tests/test_profile.py index 03c5ee41e..fe241aa54 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -6,10 +6,10 @@ import pytest from _pytask.cli import cli -from _pytask.database_utils import create_database from _pytask.profile import _to_human_readable_size from _pytask.profile import Runtime -from pony import orm +from pytask import create_database +from pytask import DatabaseSession from pytask import ExitCode from pytask import main @@ -30,17 +30,12 @@ def task_example(): time.sleep(2) duration = task.attributes["duration"] assert duration[1] - duration[0] > 2 - with orm.db_session: - create_database( - "sqlite", - tmp_path.joinpath(".pytask.sqlite3").as_posix(), - create_db=True, - create_tables=False, - ) + create_database("sqlite:///" + tmp_path.joinpath(".pytask.sqlite3").as_posix()) + with DatabaseSession() as session: task_name = tmp_path.joinpath("task_example.py").as_posix() + "::task_example" - runtime = Runtime[task_name] + runtime = session.get(Runtime, task_name) assert runtime.duration > 2 diff --git a/tox.ini b/tox.ini index 1bce2eb61..e6e8b0ce1 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ conda_deps = click-default-group networkx >=2.4 pluggy - pony >=0.7.15 + sqlalchemy >=1.4.36 pybaum >=0.1.1 rich tomli >=1.0.0