Skip to content

Commit 4348c1e

Browse files
feat!: First working implementation
1 parent 2af51ac commit 4348c1e

24 files changed

+2635
-1
lines changed

.codespellrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[codespell]
2+
skip = CHANGELOG.md
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Test and Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
pre-commit:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.9"
18+
19+
# Install and run pre-commit
20+
- run: |
21+
pip install pre-commit
22+
pre-commit install
23+
pre-commit run --all-files
24+
25+
pytest:
26+
name: Pytest ${{ matrix.config.name }}
27+
runs-on: ${{ matrix.config.os }}
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
python-version: ["3.9", "3.13"]
32+
config:
33+
- { name: "Linux", os: ubuntu-latest }
34+
- { name: "MacOSX", os: macos-latest }
35+
- { name: "Windows", os: windows-latest }
36+
37+
defaults:
38+
run:
39+
shell: bash
40+
41+
steps:
42+
- name: Checkout
43+
uses: actions/checkout@v4
44+
45+
- name: Set up Python ${{ matrix.python-version }}
46+
uses: actions/setup-python@v5
47+
with:
48+
python-version: ${{ matrix.python-version }}
49+
50+
- name: Install and Run Tests
51+
run: |
52+
pip install .[dev]
53+
pytest -s ./tests
54+
55+
release:
56+
needs: [pre-commit, pytest]
57+
runs-on: ubuntu-latest
58+
if: github.event_name == 'push'
59+
environment:
60+
name: pypi
61+
url: https://pypi.org/p/py-undo-stack
62+
permissions:
63+
id-token: write # IMPORTANT: mandatory for trusted publishing
64+
contents: write # IMPORTANT: mandatory for making GitHub Releases
65+
66+
steps:
67+
- name: Checkout
68+
uses: actions/checkout@v4
69+
with:
70+
fetch-depth: 0
71+
72+
- name: Python Semantic Release
73+
id: release
74+
uses: python-semantic-release/python-semantic-release@master
75+
with:
76+
github_token: ${{ secrets.GITHUB_TOKEN }}
77+
78+
# https://docs.pypi.org/trusted-publishers/using-a-publisher/
79+
- name: Publish package distributions to PyPI
80+
if: steps.release.outputs.released == 'true'
81+
uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.DS_Store
2+
.venv
3+
4+
# local env files
5+
.env.local
6+
.env.*.local
7+
8+
# Editor directories and files
9+
.idea
10+
.vscode
11+
*.suo
12+
*.ntvs*
13+
*.njsproj
14+
*.sln
15+
*.sw?
16+
17+
__pycache__
18+
*egg-info
19+
*pyc

.pre-commit-config.yaml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
ci:
2+
autoupdate_commit_msg: "chore: update pre-commit hooks"
3+
autofix_commit_msg: "style: pre-commit fixes"
4+
5+
exclude: ^.cruft.json|.copier-answers.yml$
6+
7+
repos:
8+
- repo: https://github.com/adamchainz/blacken-docs
9+
rev: "1.19.1"
10+
hooks:
11+
- id: blacken-docs
12+
additional_dependencies: [black==24.*]
13+
14+
- repo: https://github.com/pre-commit/pre-commit-hooks
15+
rev: "v5.0.0"
16+
hooks:
17+
- id: check-added-large-files
18+
- id: check-case-conflict
19+
- id: check-merge-conflict
20+
- id: check-symlinks
21+
- id: check-yaml
22+
- id: debug-statements
23+
- id: end-of-file-fixer
24+
- id: mixed-line-ending
25+
- id: name-tests-test
26+
args: ["--pytest-test-first"]
27+
exclude: "mock_undo_command.py"
28+
- id: requirements-txt-fixer
29+
- id: trailing-whitespace
30+
31+
- repo: https://github.com/pre-commit/pygrep-hooks
32+
rev: "v1.10.0"
33+
hooks:
34+
- id: rst-backticks
35+
- id: rst-directive-colons
36+
- id: rst-inline-touching-normal
37+
38+
- repo: https://github.com/rbubley/mirrors-prettier
39+
rev: "v3.4.2"
40+
hooks:
41+
- id: prettier
42+
types_or: [yaml, markdown, html, css, scss, javascript, json]
43+
args: [--prose-wrap=always]
44+
45+
- repo: https://github.com/astral-sh/ruff-pre-commit
46+
rev: "v0.9.1"
47+
hooks:
48+
- id: ruff
49+
args: ["--fix", "--show-fixes", "--line-length", "120"]
50+
- id: ruff-format
51+
52+
- repo: https://github.com/codespell-project/codespell
53+
rev: "v2.3.0"
54+
hooks:
55+
- id: codespell
56+
57+
- repo: https://github.com/shellcheck-py/shellcheck-py
58+
rev: "v0.10.0.1"
59+
hooks:
60+
- id: shellcheck
61+
62+
- repo: local
63+
hooks:
64+
- id: disallow-caps
65+
name: Disallow improper capitalization
66+
language: pygrep
67+
entry: PyBind|Numpy|Cmake|CCache|Github|PyTest
68+
exclude: .pre-commit-config.yaml
69+
70+
- repo: https://github.com/abravalheri/validate-pyproject
71+
rev: "v0.23"
72+
hooks:
73+
- id: validate-pyproject
74+
additional_dependencies: ["validate-pyproject-schema-store[all]"]

LICENSE

29 Bytes
Binary file not shown.

README.md

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,171 @@
11
# py-undo-stack
2-
Pure python Undo / Redo command stack
2+
3+
This library implements a pure python undo / redo stack pattern.
4+
5+
## Overview
6+
7+
This library revolves around the concept of UndoStack and UndoCommand and is an
8+
implementation of the Command pattern. When an action needs to be undoable, an
9+
UndoCommand is created which encapsulates how to go from an undo state to a redo
10+
state.
11+
12+
UndoCommands are pushed to the UndoStack and the UndoStack is responsible for
13+
managing the sequences of undo / redo and the stack size depending on what is
14+
pushed on the stack.
15+
16+
### UndoGroup
17+
18+
UndoGroup can be used to manage multiple UndoStack. One stack can be active at
19+
once per UndoGroup. When calling the UndoGroup API, the calls are delegated to
20+
the active stack for processing.
21+
22+
### Signal
23+
24+
Signal class provides a basic signal / slot implementation. Signals are
25+
triggered in the UndoStack and UndoGroup to inform that their states have
26+
changed. The signals are emitted regardless of async or not async variations.
27+
28+
### Compression
29+
30+
UndoCommand provides a mechanism to compress commands with one another. Commands
31+
pushed to the stack try to merge with previous commands provided that their
32+
`do_try_merge` methods return True.
33+
34+
When commands are merged, the pushed command is discarded and only the previous
35+
command is kept containing both commands information.
36+
37+
### Obsolete
38+
39+
Commands can be marked as obsolete. When a command is obsolete it will be
40+
removed from the stack automatically.
41+
42+
### Async
43+
44+
UndoStack and UndoGroup provide async variations for their `push` `undo` and
45+
`redo` methods. UndoCommand provides async variations for its `undo` and `redo`
46+
methods.
47+
48+
When async undo / redo methods are called, the actions are awaited.
49+
50+
## Examples
51+
52+
```python
53+
"""
54+
This example shows an example of undo / redo for mutating lists and dicts.
55+
"""
56+
57+
import logging
58+
from contextlib import contextmanager
59+
from copy import deepcopy
60+
61+
from undo_stack import UndoCommand, UndoStack
62+
63+
logging.basicConfig(level=logging.DEBUG)
64+
65+
66+
class ListDictUndoCommand(UndoCommand):
67+
"""
68+
We create a command which will save the state of the object before and after.
69+
We also create a utility context manager to make usage easier in the code.
70+
"""
71+
72+
def __init__(self, obj_before, obj_after, obj):
73+
super().__init__()
74+
self._before = obj_before
75+
self._after = obj_after
76+
self._obj = obj
77+
78+
def is_obsolete(self) -> bool:
79+
"""
80+
If the before and after states are strictly the same, then we can discard this undo / redo.
81+
"""
82+
return self._before == self._after
83+
84+
def _restore(self, state) -> None:
85+
"""Generic method to restore the state while keeping the original instance."""
86+
if isinstance(self._obj, dict):
87+
self._obj.clear()
88+
self._obj.update(state)
89+
elif isinstance(self._obj, list):
90+
self._obj.clear()
91+
self._obj.extend(state)
92+
else:
93+
raise NotImplementedError()
94+
95+
def undo(self) -> None:
96+
"""Restore the previous state."""
97+
self._restore(self._before)
98+
99+
def redo(self) -> None:
100+
"""Reapply the modified state."""
101+
self._restore(self._after)
102+
103+
@classmethod
104+
@contextmanager
105+
def save_for_undo(cls, undo_stack: UndoStack, obj):
106+
"""
107+
In the context manager, we save the state before, and state after yield.
108+
The before and after states will allow us to restore the state of the object.
109+
"""
110+
before = deepcopy(obj)
111+
yield
112+
after = deepcopy(obj)
113+
undo_stack.push(cls(before, after, obj))
114+
115+
116+
"""
117+
In our basic application, we create one undo stack.
118+
With this undo stack we will monitor the changes of a list and a dict.
119+
"""
120+
121+
undo_stack = UndoStack()
122+
123+
a_list = []
124+
a_dict = {}
125+
126+
# With our context manager, we can track changes to the dict and list
127+
with ListDictUndoCommand.save_for_undo(undo_stack, a_dict):
128+
a_dict["hello"] = "world"
129+
130+
with ListDictUndoCommand.save_for_undo(undo_stack, a_list):
131+
a_list.append(42)
132+
133+
logging.info(undo_stack.n_commands())
134+
# >>> 2
135+
136+
logging.info(undo_stack.can_undo())
137+
# >>> True
138+
139+
# Our undo stack is ordered. Undoing will undo the latest undo on the stack which is the list append.
140+
undo_stack.undo()
141+
logging.info(a_list)
142+
# >>> []
143+
144+
undo_stack.redo()
145+
logging.info(a_list)
146+
# >>> [42]
147+
148+
# We can undo further to reset the content of the dict
149+
undo_stack.undo()
150+
undo_stack.undo()
151+
152+
logging.info(a_dict)
153+
# >>> {}
154+
155+
undo_stack.redo()
156+
logging.info(a_dict)
157+
# >>> {"hello": "world"}
158+
159+
# Pushing in the stack will make the list modification obsolete
160+
with ListDictUndoCommand.save_for_undo(undo_stack, a_dict):
161+
a_dict["hello"] = str(reversed("world"))
162+
163+
logging.info(undo_stack.can_redo())
164+
# >>> False
165+
```
166+
167+
## Acknowledgments
168+
169+
This implementation is inspired by Qt's
170+
[QUndoStack, QUndoCommand, QUndoGroup](https://doc.qt.io/qt-6/qundostack.html).
171+
For usage in Qt based application, direct usage of Qt's classes is recommended.

0 commit comments

Comments
 (0)