Skip to content

Commit 35b86af

Browse files
committed
When reopening a project, restore download tasks correctly
1 parent c733415 commit 35b86af

File tree

7 files changed

+128
-44
lines changed

7 files changed

+128
-44
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ Release Notes ⋮
1212

1313
### main
1414

15+
* Workflow improvements
16+
* When reopening a project, restore download tasks correctly.
17+
1518
* Support changes **(Breaking Change)**
1619
* macOS 14+ is now the minimum macOS version.
1720
Drop support for macOS 13.

src/crystal/browser/tasktree.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,11 @@ def change_root_task(self, new_root_task: Task) -> None:
160160
# Dispose the old root TaskTreeNode
161161
self.root.dispose()
162162

163-
# Create new root TaskTreeNode for the new root task
163+
# Dispose all peer TreeItems
164+
self.peer.DeleteChildren(self.peer.GetRootItem())
165+
166+
# 1. Create new root TaskTreeNode for the new root task
167+
# 2. Create new peer TreeItems
164168
self.root = TaskTreeNode(new_root_task)
165169
self.tree.root = self.root.tree_node
166170

@@ -573,7 +577,7 @@ def fg_task() -> None:
573577
)
574578

575579
# Commit changes to the children list
576-
new_children = [] # type: List[NodeView1]
580+
new_children = [] # type: List[NodeView]
577581
if first_more_node.more_count != 0:
578582
new_children.append(first_more_node)
579583
new_children.extend(intermediate_nodes)

src/crystal/model.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -472,17 +472,20 @@ def _start_scheduler(self) -> None:
472472
"""
473473
import crystal.task
474474

475-
# Capture old root task if it exists (for reopen scenarios)
475+
# Recreate the root task
476476
old_root_task = getattr(self, 'root_task', None) # capture
477+
new_root_task = crystal.task.RootTask()
478+
self.root_task = new_root_task # export
477479

478-
self.root_task = crystal.task.RootTask()
480+
# Notify listeners if this is a root task change (not initial creation)
481+
if old_root_task is not None:
482+
self._root_task_did_change(old_root_task, new_root_task)
483+
484+
# Start the scheduler (after preparing everything else)
479485
self._scheduler_thread = (
480-
crystal.task.start_scheduler_thread(self.root_task)
486+
crystal.task.start_scheduler_thread(new_root_task)
481487
) # type: threading.Thread | None
482488

483-
# Notify listeners if this is a root task change (not initial creation)
484-
if old_root_task is not None:
485-
self._root_task_did_change(old_root_task, self.root_task)
486489

487490
# --- Load: Validity ---
488491

@@ -1994,6 +1997,10 @@ def _save_as_coro(self,
19941997
# Project state it may fail again.
19951998
self.close(capture_crashes=False, _will_reopen=True)
19961999

2000+
# Reset session state that could affect how tasks are unhibernated
2001+
for r in self._materialized_resources:
2002+
r.already_downloaded_this_session = False
2003+
19972004
# Move/copy the project directory to the new path.
19982005
# - If the project is untitled, it will be renamed.
19992006
# - If the project is titled, it will be copied.

src/crystal/task.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,30 @@ def task_crash_reason_did_change(self, task: Task) -> None:
800800

801801
if hasattr(self, 'child_task_did_crash'):
802802
self.child_task_did_crash(task)
803+
804+
# === Debugging ===
805+
806+
def print_tree(self) -> None:
807+
"""Print a formatted view of this task and its descendants."""
808+
lines: list[str] = []
809+
def format_tree(parent: Task, depth: int) -> None:
810+
children_seq = parent.children # cache
811+
for index in range(len(children_seq)):
812+
try:
813+
child = children_seq[index]
814+
except UnmaterializedItemError:
815+
lines.append(f"{' ' * depth}{index}: <unmaterialized child>")
816+
else:
817+
lines.append(f"{' ' * depth}{index}: {child.title} -- {child.subtitle}")
818+
format_tree(child, depth + 1)
819+
if len(self.children) == 0:
820+
lines.append('<empty>')
821+
else:
822+
format_tree(self, 0)
823+
824+
tree_output = '\n'.join(lines)
825+
print(tree_output)
826+
print()
803827

804828
# === Utility ===
805829

src/crystal/tests/test_untitled_projects.py

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections.abc import AsyncIterator, Iterator
22
from contextlib import asynccontextmanager, contextmanager
33
from crystal.browser import MainWindow as RealMainWindow
4+
from crystal.browser.tasktree import TaskTreeNode
45
from crystal.model import (
56
Project, ProjectReadOnlyError, Resource, ResourceGroup, RootResource,
67
)
@@ -32,6 +33,7 @@
3233
from crystal.util.wx_dialog import mocked_show_modal
3334
from crystal.util.xappdirs import user_untitled_projects_dir
3435
from crystal.util.xos import is_ci, is_linux, is_mac_os, is_windows
36+
from crystal.util.xtyping import not_none
3537
from dataclasses import dataclass
3638
import errno
3739
from functools import cache, wraps
@@ -137,6 +139,8 @@ async def test_when_project_properties_changed_then_untitled_project_becomes_dir
137139

138140
@awith_subtests
139141
async def test_when_untitled_project_saved_then_becomes_clean_and_titled(subtests: SubtestsContext) -> None:
142+
DEBUG_TASK_TREE = False
143+
140144
with scheduler_disabled() as disabled_scheduler, \
141145
served_project('testdata_xkcd.crystalproj.zip') as sp:
142146
atom_feed_url = sp.get_request_url('https://xkcd.com/atom.xml')
@@ -256,40 +260,78 @@ async def test_when_untitled_project_saved_then_becomes_clean_and_titled(subtest
256260
# Test saving an untitled project while downloads are in progress
257261
# AKA: test_when_save_as_project_with_active_tasks_then_hibernates_and_restores_tasks
258262
with subtests.test(tasks_running=True), \
259-
_untitled_project() as project, \
260263
xtempfile.TemporaryDirectory() as new_container_dirpath:
261-
# Download a resource revision, so that comic URLs are discovered
262-
r = Resource(project, home_url)
263-
rr_future = r.download()
264-
await step_scheduler_until_done(project)
265-
266-
# Start downloading a group and an individual resource
267-
g = ResourceGroup(project, 'Comic', comic_pattern)
268-
g.download()
269-
await step_scheduler(project)
270-
root_r = RootResource(project, '', Resource(project, comic50_url))
271-
root_r.download()
272-
await step_scheduler(project)
273-
(old_drg_task, old_dr_task) = project.root_task.children
274-
assert isinstance(old_drg_task, DownloadResourceGroupTask)
275-
assert isinstance(old_dr_task, DownloadResourceTask)
276-
277-
# Save untitled project to somewhere else
278-
new_project_dirpath = os.path.join(
279-
new_container_dirpath,
280-
os.path.basename(old_project_dirpath))
281-
await save_as_without_ui(project, new_project_dirpath)
282-
append_deferred_top_level_tasks(project)
283-
284-
# Ensure tasks are restored
285-
(new_drg_task, new_dr_task) = project.root_task.children
286-
assert isinstance(new_drg_task, DownloadResourceGroupTask)
287-
assert isinstance(new_dr_task, DownloadResourceTask)
288-
assert new_drg_task is not old_drg_task
289-
assert new_dr_task is not old_dr_task
290-
291-
# Ensure tasks can still be stepped without error
292-
await step_scheduler(project)
264+
# Create untitled project with UI
265+
async with (await OpenOrCreateDialog.wait_for()).create() as (mw, project):
266+
rmw = RealMainWindow._last_created
267+
assert rmw is not None
268+
269+
# Download a resource revision, so that comic URLs are discovered
270+
r = Resource(project, home_url)
271+
rr_future = r.download()
272+
await step_scheduler_until_done(project)
273+
274+
# Start downloading a group and an individual resource
275+
g = ResourceGroup(project, 'Comic', comic_pattern)
276+
g.download()
277+
await step_scheduler(project)
278+
root_r = RootResource(project, '', Resource(project, comic50_url))
279+
root_r.download()
280+
await step_scheduler(project)
281+
(old_drg_task, old_dr_task) = project.root_task.children
282+
assert isinstance(old_drg_task, DownloadResourceGroupTask)
283+
assert isinstance(old_dr_task, DownloadResourceTask)
284+
285+
if DEBUG_TASK_TREE:
286+
print(f'Task tree before save:')
287+
project.root_task.print_tree()
288+
289+
# Save untitled project to somewhere else
290+
new_project_dirpath = os.path.join(
291+
new_container_dirpath,
292+
os.path.basename(old_project_dirpath))
293+
await save_as_with_ui(rmw, new_project_dirpath)
294+
append_deferred_top_level_tasks(project)
295+
296+
# Ensure tasks are restored (in Task Tree)
297+
(new_drg_task, new_dr_task) = project.root_task.children
298+
assert isinstance(new_drg_task, DownloadResourceGroupTask)
299+
assert isinstance(new_dr_task, DownloadResourceTask)
300+
assert new_drg_task is not old_drg_task
301+
assert new_dr_task is not old_dr_task
302+
303+
# Ensure tasks are restored (in Task Tree UI)
304+
top_level_ttns = [
305+
not_none(TaskTreeNode.for_node_view(nv))
306+
for nv in rmw.task_tree.root.tree_node.children
307+
]
308+
(new_drg_ttn, new_dr_ttn) = top_level_ttns
309+
assert isinstance(new_drg_ttn.task, DownloadResourceGroupTask)
310+
assert isinstance(new_dr_ttn.task, DownloadResourceTask)
311+
assert new_drg_ttn.task is new_drg_task
312+
assert new_dr_ttn.task is new_dr_task
313+
314+
# Ensure tasks are restored (in low level Task Tree UI)
315+
assert 2 == rmw.task_tree.peer.GetChildrenCount(rmw.task_tree.peer.GetRootItem(), recursively=False)
316+
317+
# 1. Ensure restored tasks can still be stepped without error
318+
# 2. Ensure restored tasks will complete
319+
done = False
320+
step_count = 0
321+
if DEBUG_TASK_TREE:
322+
print(f'Task tree after save + 0 step(s):')
323+
project.root_task.print_tree()
324+
while not done:
325+
done = await step_scheduler(project, expect_done=None)
326+
step_count += 1
327+
if DEBUG_TASK_TREE:
328+
print(f'Task tree after save + {step_count} step(s):')
329+
project.root_task.print_tree()
330+
331+
# Ensure effects of restored tasks are correct
332+
assert root_r.resource.has_any_revisions()
333+
for member in g.members:
334+
assert member.has_any_revisions(), f'Member {member.url!r} did not finish downloading'
293335

294336

295337
# === Untitled Project: Create Tests ===

src/crystal/tests/util/tasks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def scheduler_thread_context(enabled: bool=True) -> Iterator[None]:
234234
@scheduler_affinity # manual control of scheduler thread is assumed
235235
async def step_scheduler(
236236
project: Project,
237-
*, expect_done: bool=False,
237+
*, expect_done: bool | None=False,
238238
after_get: Callable[[], None] | None=None,
239239
) -> bool:
240240
"""
@@ -256,12 +256,12 @@ async def step_scheduler(
256256
if after_get is not None:
257257
after_get()
258258
if unit is None:
259-
if not expect_done:
259+
if expect_done == False:
260260
raise AssertionError('Expected there to be no more tasks')
261261
_set_scheduler_is_waiting(project.root_task, True)
262262
return True
263263
else:
264-
if expect_done:
264+
if expect_done == True:
265265
raise AssertionError('Expected there to be at least one task remaining')
266266
await bg_call_and_wait(scheduler_thread_context()(unit))
267267
return False

src/crystal/ui/tree2.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Clients should be able to import TreeView from this package
77
from crystal.ui.tree import NodeView as NodeView1
88
from crystal.ui.tree import TreeView
9+
from typing import List
910

1011

1112
class NodeView(NodeView1):
@@ -34,6 +35,9 @@ def _set_subtitle(self, value: str) -> None:
3435
self._update_base_title()
3536
subtitle = property(_get_subtitle, _set_subtitle)
3637

38+
# NOTE: Assumes that tree of NodeViews will all be of the same type
39+
children: 'List[NodeView]' # type: ignore[assignment]
40+
3741
def _update_base_title(self) -> None:
3842
subtitle = self.__subtitle # cache
3943
combined_title = (

0 commit comments

Comments
 (0)