vcti-deck 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vcti_deck-1.0.1/LICENSE +8 -0
- vcti_deck-1.0.1/PKG-INFO +139 -0
- vcti_deck-1.0.1/README.md +122 -0
- vcti_deck-1.0.1/pyproject.toml +44 -0
- vcti_deck-1.0.1/setup.cfg +4 -0
- vcti_deck-1.0.1/src/vcti/deck/__init__.py +17 -0
- vcti_deck-1.0.1/src/vcti/deck/deck.py +183 -0
- vcti_deck-1.0.1/src/vcti/deck/policy_bound_deck.py +99 -0
- vcti_deck-1.0.1/src/vcti/deck/py.typed +0 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/PKG-INFO +139 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/SOURCES.txt +15 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/dependency_links.txt +1 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/requires.txt +10 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/top_level.txt +1 -0
- vcti_deck-1.0.1/src/vcti_deck.egg-info/zip-safe +1 -0
- vcti_deck-1.0.1/tests/test_deck.py +404 -0
- vcti_deck-1.0.1/tests/test_policy_bound_deck.py +123 -0
vcti_deck-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
|
|
2
|
+
All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
This software is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
distribution, or use of this software, via any medium, is strictly
|
|
6
|
+
prohibited. Access is granted only to authorized VCollab developers
|
|
7
|
+
and individuals explicitly authorized by Visual Collaboration
|
|
8
|
+
Technologies Inc.
|
vcti_deck-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-deck
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Generic ordered collection with named items and policy-based compatibility
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
12
|
+
Provides-Extra: lint
|
|
13
|
+
Requires-Dist: ruff; extra == "lint"
|
|
14
|
+
Provides-Extra: typecheck
|
|
15
|
+
Requires-Dist: pyright; extra == "typecheck"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Deck
|
|
19
|
+
|
|
20
|
+
Generic ordered collection with named items and policy-based compatibility
|
|
21
|
+
for Python.
|
|
22
|
+
|
|
23
|
+
## Purpose
|
|
24
|
+
|
|
25
|
+
VCollab applications need ordered collections where items are referenced
|
|
26
|
+
by name, support custom ordering, and optionally enforce compatibility
|
|
27
|
+
rules. `Deck` provides this as a lightweight, zero-dependency building
|
|
28
|
+
block.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install vcti-deck
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Basic Deck
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from vcti.deck import Deck
|
|
42
|
+
|
|
43
|
+
deck = Deck[str]()
|
|
44
|
+
deck.add("header", "Welcome")
|
|
45
|
+
deck.add("body", "Content")
|
|
46
|
+
deck.add("footer", "Copyright")
|
|
47
|
+
|
|
48
|
+
deck.get("header") # "Welcome"
|
|
49
|
+
deck.has("body") # True
|
|
50
|
+
"footer" in deck # True
|
|
51
|
+
len(deck) # 3
|
|
52
|
+
list(deck) # ["Welcome", "Content", "Copyright"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Custom Ordering
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
deck.set_order(["footer", "header"])
|
|
59
|
+
list(deck) # ["Copyright", "Welcome"] — body skipped
|
|
60
|
+
deck.ids() # ["footer", "header"]
|
|
61
|
+
|
|
62
|
+
deck.clear_order()
|
|
63
|
+
list(deck) # ["Welcome", "Content", "Copyright"] — insertion order
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### PolicyBoundDeck
|
|
67
|
+
|
|
68
|
+
Use `PolicyBoundDeck` when items must satisfy compatibility rules:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from enum import StrEnum, auto
|
|
72
|
+
from vcti.deck import PolicyBoundDeck, CompatibilityPolicy
|
|
73
|
+
|
|
74
|
+
class LayerKind(StrEnum):
|
|
75
|
+
IMAGE = auto()
|
|
76
|
+
VECTOR = auto()
|
|
77
|
+
|
|
78
|
+
class Layer:
|
|
79
|
+
def __init__(self, kind: LayerKind):
|
|
80
|
+
self.kind = kind
|
|
81
|
+
|
|
82
|
+
class SameKindPolicy(CompatibilityPolicy[LayerKind, Layer]):
|
|
83
|
+
def is_compatible(self, item: Layer, deck_type: LayerKind) -> bool:
|
|
84
|
+
return item.kind == deck_type
|
|
85
|
+
|
|
86
|
+
image_deck = PolicyBoundDeck(deck_type=LayerKind.IMAGE, policy=SameKindPolicy())
|
|
87
|
+
image_deck.add("photo", Layer(LayerKind.IMAGE)) # OK
|
|
88
|
+
image_deck.add("lines", Layer(LayerKind.VECTOR)) # Raises ValueError
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Subclass Compatibility
|
|
92
|
+
|
|
93
|
+
For simpler cases, override `_is_compatible` directly:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from vcti.deck import Deck
|
|
97
|
+
|
|
98
|
+
class PositiveOnlyDeck(Deck[int]):
|
|
99
|
+
def _is_compatible(self, item: int) -> bool:
|
|
100
|
+
return item > 0
|
|
101
|
+
|
|
102
|
+
deck = PositiveOnlyDeck()
|
|
103
|
+
deck.add("a", 5) # OK
|
|
104
|
+
deck.add("b", -1) # Raises ValueError
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API Summary
|
|
108
|
+
|
|
109
|
+
### Deck[ItemT]
|
|
110
|
+
|
|
111
|
+
| Method | Description |
|
|
112
|
+
|--------|-------------|
|
|
113
|
+
| `add(id, item, *, replace=False)` | Add item; raise if duplicate unless replace=True |
|
|
114
|
+
| `get(id)` | Return item or None |
|
|
115
|
+
| `has(id)` | Check if ID exists |
|
|
116
|
+
| `remove(id)` | Remove item; raise if missing |
|
|
117
|
+
| `set_order(ids)` | Set custom iteration order |
|
|
118
|
+
| `clear_order()` | Reset to insertion order |
|
|
119
|
+
| `ids()` | IDs in effective order |
|
|
120
|
+
| `items()` | Items in effective order |
|
|
121
|
+
|
|
122
|
+
### PolicyBoundDeck[DeckTypeT, ItemT]
|
|
123
|
+
|
|
124
|
+
Extends `Deck` with:
|
|
125
|
+
|
|
126
|
+
| Attribute/Method | Description |
|
|
127
|
+
|-----------------|-------------|
|
|
128
|
+
| `deck_type` | The type/category this deck is bound to |
|
|
129
|
+
| `policy` | The `CompatibilityPolicy` instance |
|
|
130
|
+
|
|
131
|
+
### CompatibilityPolicy[DeckTypeT, ItemT]
|
|
132
|
+
|
|
133
|
+
| Method | Description |
|
|
134
|
+
|--------|-------------|
|
|
135
|
+
| `is_compatible(item, deck_type)` | Return True if item is allowed |
|
|
136
|
+
|
|
137
|
+
## Dependencies
|
|
138
|
+
|
|
139
|
+
None. Standard library only.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Deck
|
|
2
|
+
|
|
3
|
+
Generic ordered collection with named items and policy-based compatibility
|
|
4
|
+
for Python.
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
VCollab applications need ordered collections where items are referenced
|
|
9
|
+
by name, support custom ordering, and optionally enforce compatibility
|
|
10
|
+
rules. `Deck` provides this as a lightweight, zero-dependency building
|
|
11
|
+
block.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install vcti-deck
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### Basic Deck
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from vcti.deck import Deck
|
|
25
|
+
|
|
26
|
+
deck = Deck[str]()
|
|
27
|
+
deck.add("header", "Welcome")
|
|
28
|
+
deck.add("body", "Content")
|
|
29
|
+
deck.add("footer", "Copyright")
|
|
30
|
+
|
|
31
|
+
deck.get("header") # "Welcome"
|
|
32
|
+
deck.has("body") # True
|
|
33
|
+
"footer" in deck # True
|
|
34
|
+
len(deck) # 3
|
|
35
|
+
list(deck) # ["Welcome", "Content", "Copyright"]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Custom Ordering
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
deck.set_order(["footer", "header"])
|
|
42
|
+
list(deck) # ["Copyright", "Welcome"] — body skipped
|
|
43
|
+
deck.ids() # ["footer", "header"]
|
|
44
|
+
|
|
45
|
+
deck.clear_order()
|
|
46
|
+
list(deck) # ["Welcome", "Content", "Copyright"] — insertion order
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### PolicyBoundDeck
|
|
50
|
+
|
|
51
|
+
Use `PolicyBoundDeck` when items must satisfy compatibility rules:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from enum import StrEnum, auto
|
|
55
|
+
from vcti.deck import PolicyBoundDeck, CompatibilityPolicy
|
|
56
|
+
|
|
57
|
+
class LayerKind(StrEnum):
|
|
58
|
+
IMAGE = auto()
|
|
59
|
+
VECTOR = auto()
|
|
60
|
+
|
|
61
|
+
class Layer:
|
|
62
|
+
def __init__(self, kind: LayerKind):
|
|
63
|
+
self.kind = kind
|
|
64
|
+
|
|
65
|
+
class SameKindPolicy(CompatibilityPolicy[LayerKind, Layer]):
|
|
66
|
+
def is_compatible(self, item: Layer, deck_type: LayerKind) -> bool:
|
|
67
|
+
return item.kind == deck_type
|
|
68
|
+
|
|
69
|
+
image_deck = PolicyBoundDeck(deck_type=LayerKind.IMAGE, policy=SameKindPolicy())
|
|
70
|
+
image_deck.add("photo", Layer(LayerKind.IMAGE)) # OK
|
|
71
|
+
image_deck.add("lines", Layer(LayerKind.VECTOR)) # Raises ValueError
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Subclass Compatibility
|
|
75
|
+
|
|
76
|
+
For simpler cases, override `_is_compatible` directly:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from vcti.deck import Deck
|
|
80
|
+
|
|
81
|
+
class PositiveOnlyDeck(Deck[int]):
|
|
82
|
+
def _is_compatible(self, item: int) -> bool:
|
|
83
|
+
return item > 0
|
|
84
|
+
|
|
85
|
+
deck = PositiveOnlyDeck()
|
|
86
|
+
deck.add("a", 5) # OK
|
|
87
|
+
deck.add("b", -1) # Raises ValueError
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## API Summary
|
|
91
|
+
|
|
92
|
+
### Deck[ItemT]
|
|
93
|
+
|
|
94
|
+
| Method | Description |
|
|
95
|
+
|--------|-------------|
|
|
96
|
+
| `add(id, item, *, replace=False)` | Add item; raise if duplicate unless replace=True |
|
|
97
|
+
| `get(id)` | Return item or None |
|
|
98
|
+
| `has(id)` | Check if ID exists |
|
|
99
|
+
| `remove(id)` | Remove item; raise if missing |
|
|
100
|
+
| `set_order(ids)` | Set custom iteration order |
|
|
101
|
+
| `clear_order()` | Reset to insertion order |
|
|
102
|
+
| `ids()` | IDs in effective order |
|
|
103
|
+
| `items()` | Items in effective order |
|
|
104
|
+
|
|
105
|
+
### PolicyBoundDeck[DeckTypeT, ItemT]
|
|
106
|
+
|
|
107
|
+
Extends `Deck` with:
|
|
108
|
+
|
|
109
|
+
| Attribute/Method | Description |
|
|
110
|
+
|-----------------|-------------|
|
|
111
|
+
| `deck_type` | The type/category this deck is bound to |
|
|
112
|
+
| `policy` | The `CompatibilityPolicy` instance |
|
|
113
|
+
|
|
114
|
+
### CompatibilityPolicy[DeckTypeT, ItemT]
|
|
115
|
+
|
|
116
|
+
| Method | Description |
|
|
117
|
+
|--------|-------------|
|
|
118
|
+
| `is_compatible(item, deck_type)` | Return True if item is allowed |
|
|
119
|
+
|
|
120
|
+
## Dependencies
|
|
121
|
+
|
|
122
|
+
None. Standard library only.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vcti-deck"
|
|
7
|
+
version = "1.0.1"
|
|
8
|
+
description = "Generic ordered collection with named items and policy-based compatibility"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Visual Collaboration Technologies Inc."}
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
18
|
+
include = ["vcti.deck", "vcti.deck.*"]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest", "pytest-cov"]
|
|
22
|
+
lint = ["ruff"]
|
|
23
|
+
typecheck = ["pyright"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
"vcti.deck" = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
zip-safe = true
|
|
30
|
+
|
|
31
|
+
[tool.pyright]
|
|
32
|
+
include = ["src"]
|
|
33
|
+
pythonVersion = "3.12"
|
|
34
|
+
typeCheckingMode = "standard"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
addopts = "--cov=vcti.deck --cov-report=term-missing --cov-fail-under=95"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py312"
|
|
41
|
+
line-length = 99
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "W", "I", "UP"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""vcti.deck — generic ordered collection with named items and policy-based compatibility."""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
from .deck import Deck
|
|
8
|
+
from .policy_bound_deck import CompatibilityPolicy, PolicyBoundDeck
|
|
9
|
+
|
|
10
|
+
__version__ = version("vcti-deck")
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"CompatibilityPolicy",
|
|
15
|
+
"Deck",
|
|
16
|
+
"PolicyBoundDeck",
|
|
17
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Deck — a generic ordered collection with custom ordering support.
|
|
4
|
+
|
|
5
|
+
A Deck maintains items in order with support for both insertion order and
|
|
6
|
+
user-defined custom ordering. Each item is associated with a unique string
|
|
7
|
+
identifier, making it suitable for collections where items need to be
|
|
8
|
+
referenced by name and reordered flexibly.
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
from vcti.deck import Deck
|
|
13
|
+
|
|
14
|
+
deck = Deck[str]()
|
|
15
|
+
deck.add("first", "Item 1")
|
|
16
|
+
deck.add("second", "Item 2")
|
|
17
|
+
deck.set_order(["second", "first"])
|
|
18
|
+
list(deck) # Custom order: ['Item 2', 'Item 1']
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from collections.abc import Iterable, Iterator
|
|
23
|
+
|
|
24
|
+
_MISSING: object = object()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Deck[ItemT]:
|
|
28
|
+
"""A generic ordered collection of named items with compatibility and custom ordering.
|
|
29
|
+
|
|
30
|
+
This class provides a container that maintains items in a specific order,
|
|
31
|
+
with support for both insertion order and user-defined custom ordering.
|
|
32
|
+
Each item is associated with a unique string identifier.
|
|
33
|
+
|
|
34
|
+
This class is **not** thread-safe. External synchronisation is required
|
|
35
|
+
when a single ``Deck`` instance is accessed from multiple threads.
|
|
36
|
+
|
|
37
|
+
Items may be any value allowed by *ItemT*, including ``None``. The
|
|
38
|
+
deck places no constraints on item values — only on item IDs (which
|
|
39
|
+
must be non-empty strings) and compatibility (via ``_is_compatible``).
|
|
40
|
+
|
|
41
|
+
Type Parameters:
|
|
42
|
+
ItemT: The type of items stored in the deck.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("_items", "_user_order")
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
"""Initialize an empty deck with no custom ordering."""
|
|
49
|
+
self._items: OrderedDict[str, ItemT] = OrderedDict()
|
|
50
|
+
self._user_order: list[str] | None = None
|
|
51
|
+
|
|
52
|
+
def add(self, item_id: str, item: ItemT, *, replace: bool = False) -> None:
|
|
53
|
+
"""Add an item by ID, ensuring compatibility and maintaining insertion order.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
item_id: Unique identifier for the item.
|
|
57
|
+
item: The item to add.
|
|
58
|
+
replace: If True, replace existing item with same ID; otherwise raise.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If item_id is not a non-empty string.
|
|
62
|
+
ValueError: If the item is incompatible (as determined by _is_compatible).
|
|
63
|
+
KeyError: If an item with the same ID already exists and replace is False.
|
|
64
|
+
"""
|
|
65
|
+
if not isinstance(item_id, str) or not item_id:
|
|
66
|
+
raise ValueError(f"item_id must be a non-empty string, got {item_id!r}")
|
|
67
|
+
|
|
68
|
+
if not self._is_compatible(item):
|
|
69
|
+
raise ValueError(f"Incompatible item {item!r} can't be added.")
|
|
70
|
+
|
|
71
|
+
if item_id in self._items and not replace:
|
|
72
|
+
raise KeyError(f"Item with id '{item_id}' already exists.")
|
|
73
|
+
|
|
74
|
+
self._items[item_id] = item
|
|
75
|
+
|
|
76
|
+
if self._user_order is not None and item_id not in self._user_order:
|
|
77
|
+
self._user_order.append(item_id)
|
|
78
|
+
|
|
79
|
+
def get(self, item_id: str, default: ItemT | None = None) -> ItemT | None:
|
|
80
|
+
"""Return an item by ID, or *default* if not found.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
item_id: The identifier to look up.
|
|
84
|
+
default: Value to return when *item_id* is absent (default ``None``).
|
|
85
|
+
"""
|
|
86
|
+
return self._items.get(item_id, default)
|
|
87
|
+
|
|
88
|
+
def has(self, item_id: str) -> bool:
|
|
89
|
+
"""Check whether an item with the given ID exists."""
|
|
90
|
+
return item_id in self._items
|
|
91
|
+
|
|
92
|
+
def remove(self, item_id: str) -> None:
|
|
93
|
+
"""Remove an item by ID.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
KeyError: If no item with the given ID exists.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
del self._items[item_id]
|
|
100
|
+
except KeyError:
|
|
101
|
+
raise KeyError(f"Item with id '{item_id}' not found.") from None
|
|
102
|
+
|
|
103
|
+
if self._user_order is not None and item_id in self._user_order:
|
|
104
|
+
self._user_order.remove(item_id)
|
|
105
|
+
|
|
106
|
+
def set_order(self, order: Iterable[str]) -> None:
|
|
107
|
+
"""Define a custom user-specified order for iteration.
|
|
108
|
+
|
|
109
|
+
Only items listed in the provided order will be included in iteration.
|
|
110
|
+
Items not listed will be skipped.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
order: Iterable of item IDs in the desired order.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
KeyError: If any ID in the order doesn't exist in the deck.
|
|
117
|
+
ValueError: If any ID appears more than once in the order.
|
|
118
|
+
"""
|
|
119
|
+
order_list = list(order)
|
|
120
|
+
|
|
121
|
+
unknown = [oid for oid in order_list if oid not in self._items]
|
|
122
|
+
if unknown:
|
|
123
|
+
raise KeyError(f"Unknown item IDs in custom order: {unknown}")
|
|
124
|
+
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
duplicates: list[str] = []
|
|
127
|
+
for oid in order_list:
|
|
128
|
+
if oid in seen:
|
|
129
|
+
duplicates.append(oid)
|
|
130
|
+
seen.add(oid)
|
|
131
|
+
if duplicates:
|
|
132
|
+
raise ValueError(f"Duplicate item IDs in custom order: {duplicates}")
|
|
133
|
+
|
|
134
|
+
self._user_order = order_list
|
|
135
|
+
|
|
136
|
+
def clear_order(self) -> None:
|
|
137
|
+
"""Reset to insertion order, removing any custom ordering."""
|
|
138
|
+
self._user_order = None
|
|
139
|
+
|
|
140
|
+
def ids(self) -> list[str]:
|
|
141
|
+
"""Return item IDs in the effective order (user-defined or insertion)."""
|
|
142
|
+
if self._user_order is not None:
|
|
143
|
+
return [oid for oid in self._user_order if oid in self._items]
|
|
144
|
+
return list(self._items.keys())
|
|
145
|
+
|
|
146
|
+
def items(self) -> list[ItemT]:
|
|
147
|
+
"""Return items in the effective order."""
|
|
148
|
+
return [self._items[oid] for oid in self.ids()]
|
|
149
|
+
|
|
150
|
+
def _is_compatible(self, item: ItemT) -> bool:
|
|
151
|
+
"""Hook for subclasses to implement item compatibility checks.
|
|
152
|
+
|
|
153
|
+
Override this method to enforce type-specific compatibility rules
|
|
154
|
+
when adding items to the deck. Returns True by default.
|
|
155
|
+
"""
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def __iter__(self) -> Iterator[ItemT]:
|
|
159
|
+
"""Iterate over items in the effective order.
|
|
160
|
+
|
|
161
|
+
Iteration uses a snapshot of the current order taken when iteration
|
|
162
|
+
begins. Items removed from the deck after the snapshot is taken are
|
|
163
|
+
silently skipped. Items added after the snapshot are not visited.
|
|
164
|
+
"""
|
|
165
|
+
for oid in self.ids():
|
|
166
|
+
if oid in self._items:
|
|
167
|
+
yield self._items[oid]
|
|
168
|
+
|
|
169
|
+
def __len__(self) -> int:
|
|
170
|
+
"""Return the number of items in the deck."""
|
|
171
|
+
return len(self._items)
|
|
172
|
+
|
|
173
|
+
def __contains__(self, item_id: object) -> bool:
|
|
174
|
+
"""Check if an item ID exists in the deck.
|
|
175
|
+
|
|
176
|
+
Accepts any object to conform to the ``__contains__`` protocol.
|
|
177
|
+
Non-string values always return ``False``.
|
|
178
|
+
"""
|
|
179
|
+
return item_id in self._items
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
"""Return a string representation showing item IDs."""
|
|
183
|
+
return f"{self.__class__.__name__}({list(self._items.keys())})"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""PolicyBoundDeck — a Deck that enforces item compatibility via a pluggable policy.
|
|
4
|
+
|
|
5
|
+
Example::
|
|
6
|
+
|
|
7
|
+
from vcti.deck import Deck, PolicyBoundDeck, CompatibilityPolicy
|
|
8
|
+
|
|
9
|
+
class PositiveOnly(CompatibilityPolicy[str, int]):
|
|
10
|
+
def is_compatible(self, item: int, deck_type: str) -> bool:
|
|
11
|
+
return item > 0
|
|
12
|
+
|
|
13
|
+
deck = PolicyBoundDeck(deck_type="positive", policy=PositiveOnly())
|
|
14
|
+
deck.add("a", 5) # OK
|
|
15
|
+
deck.add("b", -1) # Raises ValueError
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
|
|
20
|
+
from .deck import Deck
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompatibilityPolicy[DeckTypeT, ItemT](ABC):
|
|
24
|
+
"""Abstract base for compatibility policies.
|
|
25
|
+
|
|
26
|
+
Implement ``is_compatible`` to define rules governing which items
|
|
27
|
+
can be added to a deck of a given type.
|
|
28
|
+
|
|
29
|
+
Type Parameters:
|
|
30
|
+
DeckTypeT: The type/category of the deck.
|
|
31
|
+
ItemT: The type of items to validate.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def is_compatible(self, item: ItemT, deck_type: DeckTypeT) -> bool:
|
|
36
|
+
"""Check if *item* is compatible with *deck_type*.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if the item may be added, False otherwise.
|
|
40
|
+
"""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PolicyBoundDeck[DeckTypeT, ItemT](Deck[ItemT]):
|
|
45
|
+
"""A Deck bound to a compatibility policy.
|
|
46
|
+
|
|
47
|
+
Extends :class:`Deck` by delegating ``_is_compatible`` to an external
|
|
48
|
+
:class:`CompatibilityPolicy` instance. The policy is set at construction
|
|
49
|
+
time and cannot be changed.
|
|
50
|
+
|
|
51
|
+
Type Parameters:
|
|
52
|
+
DeckTypeT: The type/category of this deck.
|
|
53
|
+
ItemT: The type of items stored in this deck.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
from enum import StrEnum, auto
|
|
58
|
+
from vcti.deck import PolicyBoundDeck, CompatibilityPolicy
|
|
59
|
+
|
|
60
|
+
class LayerKind(StrEnum):
|
|
61
|
+
IMAGE = auto()
|
|
62
|
+
VECTOR = auto()
|
|
63
|
+
|
|
64
|
+
class Layer:
|
|
65
|
+
def __init__(self, kind: LayerKind):
|
|
66
|
+
self.kind = kind
|
|
67
|
+
|
|
68
|
+
class SameKindPolicy(CompatibilityPolicy[LayerKind, Layer]):
|
|
69
|
+
def is_compatible(self, item: Layer, deck_type: LayerKind) -> bool:
|
|
70
|
+
return item.kind == deck_type
|
|
71
|
+
|
|
72
|
+
image_deck = PolicyBoundDeck(deck_type=LayerKind.IMAGE, policy=SameKindPolicy())
|
|
73
|
+
image_deck.add("img1", Layer(LayerKind.IMAGE)) # OK
|
|
74
|
+
image_deck.add("vec1", Layer(LayerKind.VECTOR)) # Raises ValueError
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("deck_type", "policy")
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
deck_type: DeckTypeT,
|
|
82
|
+
policy: CompatibilityPolicy[DeckTypeT, ItemT],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Initialize with a deck type and a bound compatibility policy.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
deck_type: The type/category of this deck, forwarded to the policy.
|
|
88
|
+
policy: The compatibility policy that governs item admission.
|
|
89
|
+
"""
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.deck_type = deck_type
|
|
92
|
+
self.policy = policy
|
|
93
|
+
|
|
94
|
+
def _is_compatible(self, item: ItemT) -> bool:
|
|
95
|
+
"""Delegate compatibility check to the bound policy."""
|
|
96
|
+
return self.policy.is_compatible(item, self.deck_type)
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"PolicyBoundDeck(type={self.deck_type}, items={len(self)})"
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-deck
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Generic ordered collection with named items and policy-based compatibility
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
12
|
+
Provides-Extra: lint
|
|
13
|
+
Requires-Dist: ruff; extra == "lint"
|
|
14
|
+
Provides-Extra: typecheck
|
|
15
|
+
Requires-Dist: pyright; extra == "typecheck"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Deck
|
|
19
|
+
|
|
20
|
+
Generic ordered collection with named items and policy-based compatibility
|
|
21
|
+
for Python.
|
|
22
|
+
|
|
23
|
+
## Purpose
|
|
24
|
+
|
|
25
|
+
VCollab applications need ordered collections where items are referenced
|
|
26
|
+
by name, support custom ordering, and optionally enforce compatibility
|
|
27
|
+
rules. `Deck` provides this as a lightweight, zero-dependency building
|
|
28
|
+
block.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install vcti-deck
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Basic Deck
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from vcti.deck import Deck
|
|
42
|
+
|
|
43
|
+
deck = Deck[str]()
|
|
44
|
+
deck.add("header", "Welcome")
|
|
45
|
+
deck.add("body", "Content")
|
|
46
|
+
deck.add("footer", "Copyright")
|
|
47
|
+
|
|
48
|
+
deck.get("header") # "Welcome"
|
|
49
|
+
deck.has("body") # True
|
|
50
|
+
"footer" in deck # True
|
|
51
|
+
len(deck) # 3
|
|
52
|
+
list(deck) # ["Welcome", "Content", "Copyright"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Custom Ordering
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
deck.set_order(["footer", "header"])
|
|
59
|
+
list(deck) # ["Copyright", "Welcome"] — body skipped
|
|
60
|
+
deck.ids() # ["footer", "header"]
|
|
61
|
+
|
|
62
|
+
deck.clear_order()
|
|
63
|
+
list(deck) # ["Welcome", "Content", "Copyright"] — insertion order
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### PolicyBoundDeck
|
|
67
|
+
|
|
68
|
+
Use `PolicyBoundDeck` when items must satisfy compatibility rules:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from enum import StrEnum, auto
|
|
72
|
+
from vcti.deck import PolicyBoundDeck, CompatibilityPolicy
|
|
73
|
+
|
|
74
|
+
class LayerKind(StrEnum):
|
|
75
|
+
IMAGE = auto()
|
|
76
|
+
VECTOR = auto()
|
|
77
|
+
|
|
78
|
+
class Layer:
|
|
79
|
+
def __init__(self, kind: LayerKind):
|
|
80
|
+
self.kind = kind
|
|
81
|
+
|
|
82
|
+
class SameKindPolicy(CompatibilityPolicy[LayerKind, Layer]):
|
|
83
|
+
def is_compatible(self, item: Layer, deck_type: LayerKind) -> bool:
|
|
84
|
+
return item.kind == deck_type
|
|
85
|
+
|
|
86
|
+
image_deck = PolicyBoundDeck(deck_type=LayerKind.IMAGE, policy=SameKindPolicy())
|
|
87
|
+
image_deck.add("photo", Layer(LayerKind.IMAGE)) # OK
|
|
88
|
+
image_deck.add("lines", Layer(LayerKind.VECTOR)) # Raises ValueError
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Subclass Compatibility
|
|
92
|
+
|
|
93
|
+
For simpler cases, override `_is_compatible` directly:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from vcti.deck import Deck
|
|
97
|
+
|
|
98
|
+
class PositiveOnlyDeck(Deck[int]):
|
|
99
|
+
def _is_compatible(self, item: int) -> bool:
|
|
100
|
+
return item > 0
|
|
101
|
+
|
|
102
|
+
deck = PositiveOnlyDeck()
|
|
103
|
+
deck.add("a", 5) # OK
|
|
104
|
+
deck.add("b", -1) # Raises ValueError
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API Summary
|
|
108
|
+
|
|
109
|
+
### Deck[ItemT]
|
|
110
|
+
|
|
111
|
+
| Method | Description |
|
|
112
|
+
|--------|-------------|
|
|
113
|
+
| `add(id, item, *, replace=False)` | Add item; raise if duplicate unless replace=True |
|
|
114
|
+
| `get(id)` | Return item or None |
|
|
115
|
+
| `has(id)` | Check if ID exists |
|
|
116
|
+
| `remove(id)` | Remove item; raise if missing |
|
|
117
|
+
| `set_order(ids)` | Set custom iteration order |
|
|
118
|
+
| `clear_order()` | Reset to insertion order |
|
|
119
|
+
| `ids()` | IDs in effective order |
|
|
120
|
+
| `items()` | Items in effective order |
|
|
121
|
+
|
|
122
|
+
### PolicyBoundDeck[DeckTypeT, ItemT]
|
|
123
|
+
|
|
124
|
+
Extends `Deck` with:
|
|
125
|
+
|
|
126
|
+
| Attribute/Method | Description |
|
|
127
|
+
|-----------------|-------------|
|
|
128
|
+
| `deck_type` | The type/category this deck is bound to |
|
|
129
|
+
| `policy` | The `CompatibilityPolicy` instance |
|
|
130
|
+
|
|
131
|
+
### CompatibilityPolicy[DeckTypeT, ItemT]
|
|
132
|
+
|
|
133
|
+
| Method | Description |
|
|
134
|
+
|--------|-------------|
|
|
135
|
+
| `is_compatible(item, deck_type)` | Return True if item is allowed |
|
|
136
|
+
|
|
137
|
+
## Dependencies
|
|
138
|
+
|
|
139
|
+
None. Standard library only.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/vcti/deck/__init__.py
|
|
5
|
+
src/vcti/deck/deck.py
|
|
6
|
+
src/vcti/deck/policy_bound_deck.py
|
|
7
|
+
src/vcti/deck/py.typed
|
|
8
|
+
src/vcti_deck.egg-info/PKG-INFO
|
|
9
|
+
src/vcti_deck.egg-info/SOURCES.txt
|
|
10
|
+
src/vcti_deck.egg-info/dependency_links.txt
|
|
11
|
+
src/vcti_deck.egg-info/requires.txt
|
|
12
|
+
src/vcti_deck.egg-info/top_level.txt
|
|
13
|
+
src/vcti_deck.egg-info/zip-safe
|
|
14
|
+
tests/test_deck.py
|
|
15
|
+
tests/test_policy_bound_deck.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vcti
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Tests for Deck."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import vcti.deck
|
|
10
|
+
from vcti.deck import Deck
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestVersion:
|
|
14
|
+
def test_version_exists(self):
|
|
15
|
+
assert hasattr(vcti.deck, "__version__")
|
|
16
|
+
|
|
17
|
+
def test_version_is_valid_semver(self):
|
|
18
|
+
assert re.match(r"^\d+\.\d+\.\d+", vcti.deck.__version__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestDeckAdd:
|
|
22
|
+
def test_add_single(self):
|
|
23
|
+
deck = Deck[str]()
|
|
24
|
+
deck.add("a", "alpha")
|
|
25
|
+
assert deck.get("a") == "alpha"
|
|
26
|
+
assert len(deck) == 1
|
|
27
|
+
|
|
28
|
+
def test_add_multiple_preserves_insertion_order(self):
|
|
29
|
+
deck = Deck[int]()
|
|
30
|
+
deck.add("x", 1)
|
|
31
|
+
deck.add("y", 2)
|
|
32
|
+
deck.add("z", 3)
|
|
33
|
+
assert deck.ids() == ["x", "y", "z"]
|
|
34
|
+
assert deck.items() == [1, 2, 3]
|
|
35
|
+
|
|
36
|
+
def test_add_duplicate_raises(self):
|
|
37
|
+
deck = Deck[str]()
|
|
38
|
+
deck.add("a", "first")
|
|
39
|
+
with pytest.raises(KeyError, match="already exists"):
|
|
40
|
+
deck.add("a", "second")
|
|
41
|
+
|
|
42
|
+
def test_add_duplicate_with_replace(self):
|
|
43
|
+
deck = Deck[str]()
|
|
44
|
+
deck.add("a", "first")
|
|
45
|
+
deck.add("a", "second", replace=True)
|
|
46
|
+
assert deck.get("a") == "second"
|
|
47
|
+
assert len(deck) == 1
|
|
48
|
+
|
|
49
|
+
def test_add_during_custom_order(self):
|
|
50
|
+
deck = Deck[str]()
|
|
51
|
+
deck.add("a", "alpha")
|
|
52
|
+
deck.add("b", "beta")
|
|
53
|
+
deck.set_order(["b", "a"])
|
|
54
|
+
deck.add("c", "gamma")
|
|
55
|
+
assert deck.ids() == ["b", "a", "c"]
|
|
56
|
+
|
|
57
|
+
def test_add_non_string_id_raises(self):
|
|
58
|
+
deck = Deck[str]()
|
|
59
|
+
with pytest.raises(ValueError, match="non-empty string"):
|
|
60
|
+
deck.add(123, "alpha") # type: ignore[arg-type]
|
|
61
|
+
|
|
62
|
+
def test_add_none_id_raises(self):
|
|
63
|
+
deck = Deck[str]()
|
|
64
|
+
with pytest.raises(ValueError, match="non-empty string"):
|
|
65
|
+
deck.add(None, "alpha") # type: ignore[arg-type]
|
|
66
|
+
|
|
67
|
+
def test_add_empty_string_id_raises(self):
|
|
68
|
+
deck = Deck[str]()
|
|
69
|
+
with pytest.raises(ValueError, match="non-empty string"):
|
|
70
|
+
deck.add("", "alpha")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestDeckGet:
|
|
74
|
+
def test_get_existing(self):
|
|
75
|
+
deck = Deck[str]()
|
|
76
|
+
deck.add("a", "alpha")
|
|
77
|
+
assert deck.get("a") == "alpha"
|
|
78
|
+
|
|
79
|
+
def test_get_missing_returns_none(self):
|
|
80
|
+
deck = Deck[str]()
|
|
81
|
+
assert deck.get("missing") is None
|
|
82
|
+
|
|
83
|
+
def test_get_missing_returns_default(self):
|
|
84
|
+
deck = Deck[str]()
|
|
85
|
+
assert deck.get("missing", "fallback") == "fallback"
|
|
86
|
+
|
|
87
|
+
def test_get_existing_ignores_default(self):
|
|
88
|
+
deck = Deck[str]()
|
|
89
|
+
deck.add("a", "alpha")
|
|
90
|
+
assert deck.get("a", "fallback") == "alpha"
|
|
91
|
+
|
|
92
|
+
def test_get_distinguishes_none_value_from_missing(self):
|
|
93
|
+
sentinel = object()
|
|
94
|
+
deck = Deck[object | None]()
|
|
95
|
+
deck.add("a", None)
|
|
96
|
+
assert deck.get("a", sentinel) is None
|
|
97
|
+
assert deck.get("missing", sentinel) is sentinel
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestDeckHas:
|
|
101
|
+
def test_has_existing(self):
|
|
102
|
+
deck = Deck[str]()
|
|
103
|
+
deck.add("a", "alpha")
|
|
104
|
+
assert deck.has("a") is True
|
|
105
|
+
|
|
106
|
+
def test_has_missing(self):
|
|
107
|
+
deck = Deck[str]()
|
|
108
|
+
assert deck.has("missing") is False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestDeckContains:
|
|
112
|
+
def test_contains(self):
|
|
113
|
+
deck = Deck[str]()
|
|
114
|
+
deck.add("a", "alpha")
|
|
115
|
+
assert "a" in deck
|
|
116
|
+
assert "b" not in deck
|
|
117
|
+
|
|
118
|
+
def test_contains_non_string_returns_false(self):
|
|
119
|
+
deck = Deck[str]()
|
|
120
|
+
deck.add("a", "alpha")
|
|
121
|
+
assert 123 not in deck # type: ignore[operator]
|
|
122
|
+
assert None not in deck # type: ignore[operator]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestDeckRemove:
|
|
126
|
+
def test_remove_existing(self):
|
|
127
|
+
deck = Deck[str]()
|
|
128
|
+
deck.add("a", "alpha")
|
|
129
|
+
deck.remove("a")
|
|
130
|
+
assert len(deck) == 0
|
|
131
|
+
assert deck.get("a") is None
|
|
132
|
+
|
|
133
|
+
def test_remove_missing_raises(self):
|
|
134
|
+
deck = Deck[str]()
|
|
135
|
+
with pytest.raises(KeyError, match="not found"):
|
|
136
|
+
deck.remove("missing")
|
|
137
|
+
|
|
138
|
+
def test_remove_updates_custom_order(self):
|
|
139
|
+
deck = Deck[str]()
|
|
140
|
+
deck.add("a", "alpha")
|
|
141
|
+
deck.add("b", "beta")
|
|
142
|
+
deck.set_order(["b", "a"])
|
|
143
|
+
deck.remove("b")
|
|
144
|
+
assert deck.ids() == ["a"]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestDeckOrder:
|
|
148
|
+
def test_default_insertion_order(self):
|
|
149
|
+
deck = Deck[str]()
|
|
150
|
+
deck.add("c", "gamma")
|
|
151
|
+
deck.add("a", "alpha")
|
|
152
|
+
deck.add("b", "beta")
|
|
153
|
+
assert deck.ids() == ["c", "a", "b"]
|
|
154
|
+
|
|
155
|
+
def test_set_order(self):
|
|
156
|
+
deck = Deck[str]()
|
|
157
|
+
deck.add("a", "alpha")
|
|
158
|
+
deck.add("b", "beta")
|
|
159
|
+
deck.add("c", "gamma")
|
|
160
|
+
deck.set_order(["c", "a", "b"])
|
|
161
|
+
assert deck.ids() == ["c", "a", "b"]
|
|
162
|
+
assert deck.items() == ["gamma", "alpha", "beta"]
|
|
163
|
+
|
|
164
|
+
def test_set_order_partial(self):
|
|
165
|
+
deck = Deck[str]()
|
|
166
|
+
deck.add("a", "alpha")
|
|
167
|
+
deck.add("b", "beta")
|
|
168
|
+
deck.add("c", "gamma")
|
|
169
|
+
deck.set_order(["b"])
|
|
170
|
+
assert deck.ids() == ["b"]
|
|
171
|
+
|
|
172
|
+
def test_set_order_unknown_id_raises(self):
|
|
173
|
+
deck = Deck[str]()
|
|
174
|
+
deck.add("a", "alpha")
|
|
175
|
+
with pytest.raises(KeyError, match="Unknown"):
|
|
176
|
+
deck.set_order(["a", "missing"])
|
|
177
|
+
|
|
178
|
+
def test_set_order_duplicate_ids_raises(self):
|
|
179
|
+
deck = Deck[str]()
|
|
180
|
+
deck.add("a", "alpha")
|
|
181
|
+
deck.add("b", "beta")
|
|
182
|
+
with pytest.raises(ValueError, match="Duplicate"):
|
|
183
|
+
deck.set_order(["a", "b", "a"])
|
|
184
|
+
|
|
185
|
+
def test_set_order_empty(self):
|
|
186
|
+
deck = Deck[str]()
|
|
187
|
+
deck.add("a", "alpha")
|
|
188
|
+
deck.add("b", "beta")
|
|
189
|
+
deck.set_order([])
|
|
190
|
+
assert deck.ids() == []
|
|
191
|
+
assert deck.items() == []
|
|
192
|
+
assert list(deck) == []
|
|
193
|
+
|
|
194
|
+
def test_clear_order(self):
|
|
195
|
+
deck = Deck[str]()
|
|
196
|
+
deck.add("a", "alpha")
|
|
197
|
+
deck.add("b", "beta")
|
|
198
|
+
deck.set_order(["b", "a"])
|
|
199
|
+
deck.clear_order()
|
|
200
|
+
assert deck.ids() == ["a", "b"]
|
|
201
|
+
|
|
202
|
+
def test_clear_order_noop_when_no_custom_order(self):
|
|
203
|
+
deck = Deck[str]()
|
|
204
|
+
deck.add("a", "alpha")
|
|
205
|
+
deck.clear_order()
|
|
206
|
+
assert deck.ids() == ["a"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestDeckIteration:
|
|
210
|
+
def test_iter(self):
|
|
211
|
+
deck = Deck[str]()
|
|
212
|
+
deck.add("a", "alpha")
|
|
213
|
+
deck.add("b", "beta")
|
|
214
|
+
assert list(deck) == ["alpha", "beta"]
|
|
215
|
+
|
|
216
|
+
def test_iter_with_custom_order(self):
|
|
217
|
+
deck = Deck[str]()
|
|
218
|
+
deck.add("a", "alpha")
|
|
219
|
+
deck.add("b", "beta")
|
|
220
|
+
deck.set_order(["b", "a"])
|
|
221
|
+
assert list(deck) == ["beta", "alpha"]
|
|
222
|
+
|
|
223
|
+
def test_len(self):
|
|
224
|
+
deck = Deck[str]()
|
|
225
|
+
assert len(deck) == 0
|
|
226
|
+
deck.add("a", "alpha")
|
|
227
|
+
assert len(deck) == 1
|
|
228
|
+
|
|
229
|
+
def test_repr(self):
|
|
230
|
+
deck = Deck[str]()
|
|
231
|
+
deck.add("a", "alpha")
|
|
232
|
+
assert "Deck" in repr(deck)
|
|
233
|
+
assert "'a'" in repr(deck)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestDeckIterationMutation:
|
|
237
|
+
def test_remove_during_iteration_skips_removed(self):
|
|
238
|
+
deck = Deck[str]()
|
|
239
|
+
deck.add("a", "alpha")
|
|
240
|
+
deck.add("b", "beta")
|
|
241
|
+
deck.add("c", "gamma")
|
|
242
|
+
collected = []
|
|
243
|
+
for item in deck:
|
|
244
|
+
collected.append(item)
|
|
245
|
+
if item == "alpha":
|
|
246
|
+
deck.remove("b")
|
|
247
|
+
assert collected == ["alpha", "gamma"]
|
|
248
|
+
|
|
249
|
+
def test_add_during_iteration_not_visited(self):
|
|
250
|
+
deck = Deck[str]()
|
|
251
|
+
deck.add("a", "alpha")
|
|
252
|
+
deck.add("b", "beta")
|
|
253
|
+
collected = []
|
|
254
|
+
for item in deck:
|
|
255
|
+
collected.append(item)
|
|
256
|
+
if item == "alpha":
|
|
257
|
+
deck.add("c", "gamma")
|
|
258
|
+
assert collected == ["alpha", "beta"]
|
|
259
|
+
assert deck.has("c")
|
|
260
|
+
|
|
261
|
+
def test_set_order_during_iteration_no_effect(self):
|
|
262
|
+
deck = Deck[str]()
|
|
263
|
+
deck.add("a", "alpha")
|
|
264
|
+
deck.add("b", "beta")
|
|
265
|
+
deck.add("c", "gamma")
|
|
266
|
+
collected = []
|
|
267
|
+
for item in deck:
|
|
268
|
+
collected.append(item)
|
|
269
|
+
if item == "alpha":
|
|
270
|
+
deck.set_order(["c", "b", "a"])
|
|
271
|
+
assert collected == ["alpha", "beta", "gamma"]
|
|
272
|
+
|
|
273
|
+
def test_clear_order_during_iteration_no_effect(self):
|
|
274
|
+
deck = Deck[str]()
|
|
275
|
+
deck.add("a", "alpha")
|
|
276
|
+
deck.add("b", "beta")
|
|
277
|
+
deck.set_order(["b", "a"])
|
|
278
|
+
collected = []
|
|
279
|
+
for item in deck:
|
|
280
|
+
collected.append(item)
|
|
281
|
+
if item == "beta":
|
|
282
|
+
deck.clear_order()
|
|
283
|
+
assert collected == ["beta", "alpha"]
|
|
284
|
+
|
|
285
|
+
def test_remove_all_during_iteration(self):
|
|
286
|
+
deck = Deck[str]()
|
|
287
|
+
deck.add("a", "alpha")
|
|
288
|
+
deck.add("b", "beta")
|
|
289
|
+
deck.add("c", "gamma")
|
|
290
|
+
collected = []
|
|
291
|
+
for item in deck:
|
|
292
|
+
collected.append(item)
|
|
293
|
+
deck.remove("a")
|
|
294
|
+
deck.remove("b")
|
|
295
|
+
deck.remove("c")
|
|
296
|
+
break
|
|
297
|
+
assert collected == ["alpha"]
|
|
298
|
+
assert len(deck) == 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class TestDeckEmpty:
|
|
302
|
+
def test_empty_iter(self):
|
|
303
|
+
assert list(Deck()) == []
|
|
304
|
+
|
|
305
|
+
def test_empty_ids(self):
|
|
306
|
+
assert Deck().ids() == []
|
|
307
|
+
|
|
308
|
+
def test_empty_items(self):
|
|
309
|
+
assert Deck().items() == []
|
|
310
|
+
|
|
311
|
+
def test_empty_len(self):
|
|
312
|
+
assert len(Deck()) == 0
|
|
313
|
+
|
|
314
|
+
def test_empty_repr(self):
|
|
315
|
+
assert repr(Deck()) == "Deck([])"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class TestDeckCompatibility:
|
|
319
|
+
def test_default_compatibility_accepts_all(self):
|
|
320
|
+
deck = Deck[object]()
|
|
321
|
+
deck.add("a", 42)
|
|
322
|
+
deck.add("b", "hello")
|
|
323
|
+
deck.add("c", [1, 2, 3])
|
|
324
|
+
assert len(deck) == 3
|
|
325
|
+
|
|
326
|
+
def test_subclass_can_reject(self):
|
|
327
|
+
class PositiveOnlyDeck(Deck[int]):
|
|
328
|
+
def _is_compatible(self, item: int) -> bool:
|
|
329
|
+
return item > 0
|
|
330
|
+
|
|
331
|
+
deck = PositiveOnlyDeck()
|
|
332
|
+
deck.add("a", 5)
|
|
333
|
+
with pytest.raises(ValueError, match="Incompatible"):
|
|
334
|
+
deck.add("b", -1)
|
|
335
|
+
|
|
336
|
+
def test_none_item_allowed(self):
|
|
337
|
+
deck = Deck[None]()
|
|
338
|
+
deck.add("a", None)
|
|
339
|
+
assert deck.get("a") is None
|
|
340
|
+
assert list(deck) == [None]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class TestDeckReentrant:
|
|
344
|
+
def test_set_order_inside_is_compatible(self):
|
|
345
|
+
"""Calling set_order from within _is_compatible must not corrupt state."""
|
|
346
|
+
|
|
347
|
+
class ReorderingDeck(Deck[str]):
|
|
348
|
+
def _is_compatible(self, item: str) -> bool:
|
|
349
|
+
if len(self._items) >= 2:
|
|
350
|
+
self.set_order(sorted(self._items.keys()))
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
deck = ReorderingDeck()
|
|
354
|
+
deck.add("b", "beta")
|
|
355
|
+
deck.add("a", "alpha")
|
|
356
|
+
deck.add("c", "gamma")
|
|
357
|
+
# set_order was called inside _is_compatible for "c"
|
|
358
|
+
assert deck.has("c")
|
|
359
|
+
assert set(deck.ids()) == {"a", "b", "c"}
|
|
360
|
+
|
|
361
|
+
def test_remove_inside_is_compatible(self):
|
|
362
|
+
"""Calling remove from within _is_compatible must not corrupt state."""
|
|
363
|
+
|
|
364
|
+
class EvictingDeck(Deck[str]):
|
|
365
|
+
def _is_compatible(self, item: str) -> bool:
|
|
366
|
+
if self.has("evict_me"):
|
|
367
|
+
self.remove("evict_me")
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
deck = EvictingDeck()
|
|
371
|
+
deck.add("evict_me", "temporary")
|
|
372
|
+
deck.add("keeper", "permanent")
|
|
373
|
+
assert not deck.has("evict_me")
|
|
374
|
+
assert deck.has("keeper")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class TestDeckScale:
|
|
378
|
+
def test_add_and_iterate_1000_items(self):
|
|
379
|
+
deck = Deck[int]()
|
|
380
|
+
n = 1_000
|
|
381
|
+
for i in range(n):
|
|
382
|
+
deck.add(f"item{i}", i)
|
|
383
|
+
assert len(deck) == n
|
|
384
|
+
assert list(deck) == list(range(n))
|
|
385
|
+
|
|
386
|
+
def test_custom_order_1000_items(self):
|
|
387
|
+
deck = Deck[int]()
|
|
388
|
+
n = 1_000
|
|
389
|
+
for i in range(n):
|
|
390
|
+
deck.add(f"item{i}", i)
|
|
391
|
+
reversed_ids = [f"item{i}" for i in reversed(range(n))]
|
|
392
|
+
deck.set_order(reversed_ids)
|
|
393
|
+
assert deck.ids() == reversed_ids
|
|
394
|
+
assert deck.items() == list(reversed(range(n)))
|
|
395
|
+
|
|
396
|
+
def test_add_remove_cycle_1000(self):
|
|
397
|
+
deck = Deck[int]()
|
|
398
|
+
n = 1_000
|
|
399
|
+
for i in range(n):
|
|
400
|
+
deck.add(f"item{i}", i)
|
|
401
|
+
for i in range(n):
|
|
402
|
+
deck.remove(f"item{i}")
|
|
403
|
+
assert len(deck) == 0
|
|
404
|
+
assert list(deck) == []
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Tests for PolicyBoundDeck."""
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from vcti.deck import CompatibilityPolicy, PolicyBoundDeck
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Kind(StrEnum):
|
|
13
|
+
ALPHA = auto()
|
|
14
|
+
BETA = auto()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Item:
|
|
18
|
+
def __init__(self, kind: Kind):
|
|
19
|
+
self.kind = kind
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SameKindPolicy(CompatibilityPolicy[Kind, Item]):
|
|
23
|
+
def is_compatible(self, item: Item, deck_type: Kind) -> bool:
|
|
24
|
+
return item.kind == deck_type
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AcceptAllPolicy(CompatibilityPolicy[str, int]):
|
|
28
|
+
def is_compatible(self, item: int, deck_type: str) -> bool:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RejectAllPolicy(CompatibilityPolicy[str, int]):
|
|
33
|
+
def is_compatible(self, item: int, deck_type: str) -> bool:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestPolicyBoundDeckBasic:
|
|
38
|
+
def test_compatible_item_accepted(self):
|
|
39
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
40
|
+
item = Item(Kind.ALPHA)
|
|
41
|
+
deck.add("a", item)
|
|
42
|
+
assert deck.get("a") is item
|
|
43
|
+
|
|
44
|
+
def test_incompatible_item_rejected(self):
|
|
45
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
46
|
+
item = Item(Kind.BETA)
|
|
47
|
+
with pytest.raises(ValueError, match="Incompatible"):
|
|
48
|
+
deck.add("b", item)
|
|
49
|
+
|
|
50
|
+
def test_accept_all_policy(self):
|
|
51
|
+
deck = PolicyBoundDeck(deck_type="any", policy=AcceptAllPolicy())
|
|
52
|
+
deck.add("a", 1)
|
|
53
|
+
deck.add("b", 2)
|
|
54
|
+
assert len(deck) == 2
|
|
55
|
+
|
|
56
|
+
def test_reject_all_policy(self):
|
|
57
|
+
deck = PolicyBoundDeck(deck_type="none", policy=RejectAllPolicy())
|
|
58
|
+
with pytest.raises(ValueError, match="Incompatible"):
|
|
59
|
+
deck.add("a", 1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestPolicyBoundDeckAttributes:
|
|
63
|
+
def test_deck_type_stored(self):
|
|
64
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
65
|
+
assert deck.deck_type == Kind.ALPHA
|
|
66
|
+
|
|
67
|
+
def test_policy_stored(self):
|
|
68
|
+
policy = SameKindPolicy()
|
|
69
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=policy)
|
|
70
|
+
assert deck.policy is policy
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestPolicyBoundDeckInheritsOrdering:
|
|
74
|
+
def test_custom_order(self):
|
|
75
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
76
|
+
deck.add("a", Item(Kind.ALPHA))
|
|
77
|
+
deck.add("b", Item(Kind.ALPHA))
|
|
78
|
+
deck.add("c", Item(Kind.ALPHA))
|
|
79
|
+
deck.set_order(["c", "a", "b"])
|
|
80
|
+
assert deck.ids() == ["c", "a", "b"]
|
|
81
|
+
|
|
82
|
+
def test_remove(self):
|
|
83
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
84
|
+
deck.add("a", Item(Kind.ALPHA))
|
|
85
|
+
deck.remove("a")
|
|
86
|
+
assert len(deck) == 0
|
|
87
|
+
|
|
88
|
+
def test_replace(self):
|
|
89
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
90
|
+
item1 = Item(Kind.ALPHA)
|
|
91
|
+
item2 = Item(Kind.ALPHA)
|
|
92
|
+
deck.add("a", item1)
|
|
93
|
+
deck.add("a", item2, replace=True)
|
|
94
|
+
assert deck.get("a") is item2
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestCompatibilityPolicyIsAbstract:
|
|
98
|
+
def test_cannot_instantiate_base(self):
|
|
99
|
+
with pytest.raises(TypeError, match="abstract"):
|
|
100
|
+
CompatibilityPolicy()
|
|
101
|
+
|
|
102
|
+
def test_incomplete_subclass_cannot_instantiate(self):
|
|
103
|
+
class Incomplete(CompatibilityPolicy[str, int]):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
with pytest.raises(TypeError, match="abstract"):
|
|
107
|
+
Incomplete()
|
|
108
|
+
|
|
109
|
+
def test_replace_still_checks_compatibility(self):
|
|
110
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
111
|
+
deck.add("a", Item(Kind.ALPHA))
|
|
112
|
+
with pytest.raises(ValueError, match="Incompatible"):
|
|
113
|
+
deck.add("a", Item(Kind.BETA), replace=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestPolicyBoundDeckRepr:
|
|
117
|
+
def test_repr(self):
|
|
118
|
+
deck = PolicyBoundDeck(deck_type=Kind.ALPHA, policy=SameKindPolicy())
|
|
119
|
+
deck.add("a", Item(Kind.ALPHA))
|
|
120
|
+
r = repr(deck)
|
|
121
|
+
assert "PolicyBoundDeck" in r
|
|
122
|
+
assert "alpha" in r
|
|
123
|
+
assert "items=1" in r
|