Commit 04dcbae0 authored by Trond Aasan's avatar Trond Aasan
Browse files

Initial commit

parents
Pipeline #86522 passed with stage
in 53 seconds
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
*.pyc
poetry.lock
image: python
stages:
- test
before_script:
- pip install tox poetry
python38:
image: python:3.8
stage: test
script:
- tox -e py38
python39:
image: python:3.9
stage: test
script:
- tox -e py39
# artifacts:
# reports:
# cobertura: coverage.xml
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: black
name: black
entry: poetry run black
language: python
language_version: python3
minimum_pre_commit_version: 2.9.2
require_serial: true
types_or: [ python, pyi ]
- id: pytest
name: pytest
entry: poetry run pytest
language: python
language_version: python3
types_or: [ python, pyi ]
[![pipeline status](https://git.app.uib.no/it-bott-integrasjoner/bottint-tree/badges/master/pipeline.svg)](https://git.app.uib.no/it-bott-integrasjoner/bottint-tree/-/commits/master)
[![coverage report](https://git.app.uib.no/it-bott-integrasjoner/bottint-tree/badges/master/coverage.svg)](https://git.app.uib.no/it-bott-integrasjoner/bottint-tree/-/commits/master)
# bottint-tree
A basic generic tree structure
## Development
```shell
poetry shell
poetry install
pre-commit install
```
# TODO
- [ ] Add caching to [CI configuration](.gitlab-ci.yml)
- [ ] Add pre-commit hooks
from __future__ import annotations
from itertools import chain
from typing import (
List,
Iterable,
Generic,
TypeVar,
Dict,
Iterator,
Any,
Optional,
)
__version__ = "0.0.1"
T = TypeVar("T")
class Tree(Generic[T]):
"""Generic tree."""
def __init__(
self,
data: T,
id_key: str,
children: List[Tree[T]],
parent: Optional[Tree[T]] = None,
):
assert data is not None
assert isinstance(id_key, str)
self.data = data
self.id_key = id_key
if parent is not None:
assert isinstance(parent, Tree)
for child in children:
assert isinstance(child, Tree)
self.parent: Optional[Tree[T]] = parent
self.children = children
def __iter__(self) -> Iterator[Tree[T]]:
yield self
for v in chain(*map(iter, self.children)):
yield v
def __repr__(self):
return f"Tree(data={self.data}, children={self.children})"
def __eq__(self, other):
if not isinstance(other, Tree):
return False
return self.data == other.data and self.children == other.children
def values(self) -> Iterator[T]:
for x in self:
yield x.data
@property
def id(self) -> Any:
return getattr(self.data, self.id_key)
@property
def depth(self) -> int:
"""Root node has depth 1"""
visited = set()
def go(x: Tree[T]):
if x.id in visited:
raise LoopDetected
if x.parent is None:
return 0
visited.add(x.id)
return 1 + go(x.parent)
return 1 + go(self)
class InvalidTree(Exception):
pass
class LoopDetected(InvalidTree):
pass
class DuplicateItem(InvalidTree):
pass
class MissingRoot(InvalidTree):
pass
class ParentNotFound(InvalidTree):
pass
def build_forest(
items: Iterable[T],
id_key: str,
parent_key: str,
check_root: bool = True,
check_parent: bool = True,
) -> Dict[Any, Tree[T]]:
"""
Args:
items: Collection to build forest from
id_key: Name of id property of T
parent_key: Name of parent property of T
check_root: Check if an element with parent == None is found in items. Raise MissingRoot if it fails
check_parent: Check if parent is found in items. Raise ParentNotFound if it fails
Returns:
A dictionary of all elements, keyed by property with name id_key
Checks for loops and duplicate items and raises LoopDetected or DuplicateItem if that is the case
"""
graph_by_id: Dict[Any, Tree[T]] = {}
children: Dict[Any, List[Tree[T]]] = {}
visited = set()
has_root = False
for item in items:
id_ = getattr(item, id_key)
if id_ is None:
raise ValueError(f"`item.{id_key}` can't be None")
if id_ in visited:
raise DuplicateItem(str(id_))
visited.add(id_)
parent = getattr(item, parent_key)
if parent == id_:
raise LoopDetected("Item is it's own parent")
if id_ not in children:
children[id_] = []
node = Tree(data=item, id_key=id_key, children=children[id_])
graph_by_id[id_] = node
if parent is not None:
if parent not in children:
children[parent] = []
children[parent].append(node)
else:
has_root = True
if check_root and not has_root:
raise MissingRoot
for t in graph_by_id.values():
parent = getattr(t.data, parent_key)
if parent is not None:
if check_parent and parent not in graph_by_id:
raise ParentNotFound(str(parent))
t.parent = graph_by_id.get(parent)
return graph_by_id
[tool.poetry]
name = "bottint-tree"
version = "0.1.0"
description = ""
authors = ["BOTT-INT <bnt-int@usit.uio.no>"]
homepage = "https://git.app.uib.no/it-bott-integrasjoner/bottint-tree"
repository = "git@git.app.uib.no:it-bott-integrasjoner/bottint-tree.git"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 3 :: Only",
"Operating System :: OS Independent"
]
[tool.poetry.dependencies]
python = "^3.8"
[tool.poetry.dev-dependencies]
pytest = "^5.2"
# Must match black version in .pre-commit-config.yaml
black = { version = "21.6b0", allow-prereleases = true }
pytest-black = "^0.3.12"
pre-commit = "^2.13.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
from typing import NamedTuple, Optional, List
import pytest
from bottint_tree import (
DuplicateItem,
LoopDetected,
MissingRoot,
ParentNotFound,
Tree,
build_forest,
__version__,
)
def test_version():
assert __version__ == "0.0.1"
class Node(NamedTuple):
id: Optional[int]
parent_id: Optional[int]
def _build_forest(items: List[Node]):
return build_forest(items, "id", "parent_id")
@pytest.fixture
def node_1():
return Node(id=1, parent_id=None)
@pytest.fixture
def node_2(node_1):
return Node(id=2, parent_id=node_1.id)
@pytest.fixture
def node_3(node_1):
return Node(id=3, parent_id=node_1.id)
@pytest.fixture
def node_4(node_2):
return Node(id=4, parent_id=node_2.id)
@pytest.fixture
def node_5(node_4):
return Node(id=5, parent_id=node_4.id)
@pytest.fixture
def node_x_1():
return Node(id=10001, parent_id=None)
@pytest.fixture
def node_x_2(node_x_1):
return Node(id=10002, parent_id=node_x_1.id)
@pytest.fixture
def node_with_id_equals_parent_id():
return Node(id=20001, parent_id=20001)
def test_build_forest_loop_parent_equals_key(
node_1,
node_with_id_equals_parent_id,
):
items = [node_1, node_with_id_equals_parent_id]
with pytest.raises(LoopDetected, match=r"^Item is it's own parent$"):
_build_forest(items)
def test_build_forest_duplicate_item(
node_1,
node_2,
):
items = [node_1, node_2, node_1]
with pytest.raises(DuplicateItem, match=r"^1$"):
_build_forest(items)
def test_build_forest_missing_root(node_2, node_3):
items = [node_2, node_3]
with pytest.raises(MissingRoot):
_build_forest(items)
def test_build_forest_no_id():
items = [Node(id=None, parent_id=None)]
with pytest.raises(ValueError, match=r"^`item.id` can't be None$"):
_build_forest(items)
def test_build_forest_parent_not_found(
node_1,
node_2,
node_3,
node_5,
):
items = [
node_1,
node_2,
node_3,
node_5,
]
with pytest.raises(ParentNotFound):
_build_forest(items)
def test_build_forest(
node_1,
node_2,
node_3,
node_4,
node_5,
node_x_1,
node_x_2,
):
items = [
node_1,
node_2,
node_3,
node_4,
node_5,
node_x_1,
node_x_2,
]
t_5 = Tree(data=node_5, id_key="id", children=[])
t_4 = Tree(data=node_4, id_key="id", children=[t_5])
t_3 = Tree(data=node_3, id_key="id", children=[])
t_2 = Tree(data=node_2, id_key="id", children=[t_4])
t_1 = Tree(data=node_1, id_key="id", children=[t_2, t_3])
t_x_2 = Tree(data=node_x_2, id_key="id", children=[])
t_x_1 = Tree(data=node_x_1, id_key="id", children=[t_x_2])
forest = _build_forest(items)
assert forest == {x.id: x for x in [t_1, t_2, t_3, t_4, t_5, t_x_1, t_x_2]}
def test_tree_iter(
node_1,
node_2,
node_3,
node_4,
node_5,
node_x_1,
node_x_2,
):
items = [
node_1,
node_2,
node_3,
node_4,
node_5,
node_x_1,
node_x_2,
]
forest = _build_forest(items)
forest_data = sorted(map(lambda x: x.data, forest.values()), key=lambda x: x.id)
assert items == forest_data
[tox]
isolated_build = true
envlist = py3{8,9}
[testenv]
whitelist_externals = poetry
skip_install = True
commands =
poetry install -v
poetry run pre-commit run --all
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment