From ef2df7b82aed3272d91bf9723da99f71e41b7efc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 00:13:07 -0400 Subject: [PATCH 1/2] Use declaration names for worker diagnostics without formatting payloads --- .../source.json | 749 ++++++++++++++++++ src/textual/_work_decorator.py | 18 +- src/textual/dom.py | 4 +- src/textual/worker.py | 6 +- src/textual/worker_manager.py | 6 +- 5 files changed, 762 insertions(+), 21 deletions(-) create mode 100644 evidence/worker-description-declaration-20261007/source.json diff --git a/evidence/worker-description-declaration-20261007/source.json b/evidence/worker-description-declaration-20261007/source.json new file mode 100644 index 00000000000..52acae1ef3c --- /dev/null +++ b/evidence/worker-description-declaration-20261007/source.json @@ -0,0 +1,749 @@ +{ + "native": { + "modules": 249, + "omissions": [], + "sites": [ + [ + "src/textual/_work_decorator.py", + 141, + "self.run_worker" + ], + [ + "src/textual/binding.py", + 160, + "binding.description" + ], + [ + "src/textual/binding.py", + 161, + "binding.description" + ], + [ + "src/textual/command.py", + 1052, + "work" + ], + [ + "src/textual/css/tokenizer.py", + 119, + "self.description" + ], + [ + "src/textual/css/tokenizer.py", + 294, + "expect.description" + ], + [ + "src/textual/demo/game.py", + 476, + "work" + ], + [ + "src/textual/demo/page.py", + 67, + "work" + ], + [ + "src/textual/demo/projects.py", + 84, + "info.description" + ], + [ + "src/textual/validation.py", + 76, + "failure.description" + ], + [ + "src/textual/validation.py", + 100, + "self.description" + ], + [ + "src/textual/validation.py", + 109, + "self.description" + ], + [ + "src/textual/validation.py", + 102, + "self.description" + ], + [ + "src/textual/validation.py", + 104, + "self.description" + ], + [ + "src/textual/validation.py", + 78, + "failure.description" + ], + [ + "src/textual/widgets/_directory_tree.py", + 510, + "work" + ], + [ + "src/textual/widgets/_directory_tree.py", + 528, + "work" + ], + [ + "src/textual/widgets/_footer.py", + 117, + "self.description" + ], + [ + "src/textual/widgets/_footer.py", + 136, + "self.description" + ], + [ + "src/textual/widgets/_footer.py", + 409, + "binding.description" + ], + [ + "src/textual/widgets/_footer.py", + 420, + "key.description" + ], + [ + "src/textual/widgets/_footer.py", + 294, + "binding.description" + ], + [ + "src/textual/widgets/_footer.py", + 414, + "key.description" + ], + [ + "src/textual/widgets/_footer.py", + 418, + "key.description" + ], + [ + "src/textual/widgets/_footer.py", + 411, + "binding.description" + ], + [ + "src/textual/widgets/_footer.py", + 280, + "part.group.description" + ], + [ + "src/textual/widgets/_footer.py", + 298, + "binding.description" + ], + [ + "src/textual/widgets/_footer.py", + 412, + "binding.description" + ], + [ + "src/textual/widgets/_footer.py", + 299, + "binding.description" + ], + [ + "src/textual/widgets/_input.py", + 548, + "self.run_worker" + ], + [ + "src/textual/widgets/_key_panel.py", + 86, + "binding.description" + ], + [ + "src/textual/widgets/_key_panel.py", + 89, + "binding.description" + ], + [ + "src/textual/widgets/_log.py", + 130, + "work" + ], + [ + "src/textual/worker.py", + 173, + "self.description" + ], + [ + "src/textual/worker.py", + 194, + "self.description" + ], + [ + "src/textual/worker.py", + 324, + "work" + ], + [ + "src/textual/worker.py", + 329, + "work" + ], + [ + "src/textual/worker_manager.py", + 109, + "Worker" + ] + ] + }, + "toad": { + "modules": 288, + "omissions": [], + "sites": [ + [ + "src/toad/acp/log_records.py", + 119, + "self.observation.description" + ], + [ + "src/toad/app.py", + 442, + "work" + ], + [ + "src/toad/application_actions.py", + 85, + "app.run_worker" + ], + [ + "src/toad/application_actions.py", + 170, + "app.run_worker" + ], + [ + "src/toad/application_actions.py", + 31, + "cls.description" + ], + [ + "src/toad/application_lifetime.py", + 23, + "self.app.run_worker" + ], + [ + "src/toad/application_lifetime.py", + 73, + "app.run_worker" + ], + [ + "src/toad/application_lifetime.py", + 77, + "self.app.run_worker" + ], + [ + "src/toad/catalog_actions.py", + 36, + "self.command.description" + ], + [ + "src/toad/conversation_submission.py", + 111, + "view.run_worker" + ], + [ + "src/toad/conversation_submission.py", + 288, + "self.view.run_worker" + ], + [ + "src/toad/conversation_submission.py", + 79, + "view.run_worker" + ], + [ + "src/toad/conversation_submission.py", + 115, + "view.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 77, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 94, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 99, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 507, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 551, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 563, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 630, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 755, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 790, + "Coordination.run_worker" + ], + [ + "src/toad/core/context_inspection.py", + 124, + "original.description" + ], + [ + "src/toad/core/context_inspection.py", + 232, + "original.description" + ], + [ + "src/toad/core/context_inspection.py", + 278, + "original.description" + ], + [ + "src/toad/core/context_inspection.py", + 134, + "original.description" + ], + [ + "src/toad/core/context_inspection.py", + 238, + "original.description" + ], + [ + "src/toad/core/session_requests.py", + 50, + "admission.app.run_worker" + ], + [ + "src/toad/core/session_requests.py", + 98, + "admission.app.run_worker" + ], + [ + "src/toad/delivery_failure_view.py", + 14, + "failure.description" + ], + [ + "src/toad/mounted_message_history.py", + 404, + "self.view.run_worker" + ], + [ + "src/toad/mounted_message_history.py", + 396, + "self.view.run_worker" + ], + [ + "src/toad/project_link.py", + 148, + "widget.app.run_worker" + ], + [ + "src/toad/screens/action_modal.py", + 80, + "work" + ], + [ + "src/toad/screens/action_modal.py", + 76, + "self.operation.description" + ], + [ + "src/toad/screens/agent_modal.py", + 69, + "choice.description" + ], + [ + "src/toad/screens/comms.py", + 180, + "self.run_worker" + ], + [ + "src/toad/screens/comms.py", + 246, + "history.run_worker" + ], + [ + "src/toad/screens/historical_sessions.py", + 148, + "self.run_worker" + ], + [ + "src/toad/screens/main.py", + 86, + "mode.description" + ], + [ + "src/toad/screens/main.py", + 72, + "mode.description" + ], + [ + "src/toad/screens/settings.py", + 77, + "self.description" + ], + [ + "src/toad/screens/settings.py", + 96, + "self.description" + ], + [ + "src/toad/screens/settings.py", + 102, + "self.description" + ], + [ + "src/toad/screens/store.py", + 154, + "agent.description" + ], + [ + "src/toad/screens/store.py", + 292, + "agent.description" + ], + [ + "src/toad/screens/store.py", + 463, + "section.description" + ], + [ + "src/toad/sidebar_navigation.py", + 65, + "self.sidebar.run_worker" + ], + [ + "src/toad/slash_command.py", + 98, + "record.description" + ], + [ + "src/toad/target_commands.py", + 74, + "sidebar.run_worker" + ], + [ + "src/toad/terminal_attention.py", + 149, + "self.app.run_worker" + ], + [ + "src/toad/tool_output.py", + 422, + "view.run_worker" + ], + [ + "src/toad/tool_output.py", + 456, + "self.view.run_worker" + ], + [ + "src/toad/transcript_filter.py", + 198, + "filtering.owner.run_worker" + ], + [ + "src/toad/transcript_filter.py", + 330, + "self.owner.run_worker" + ], + [ + "src/toad/transcript_publication.py", + 649, + "view.run_worker" + ], + [ + "src/toad/transcript_publication.py", + 351, + "view.run_worker" + ], + [ + "src/toad/transcript_publication.py", + 656, + "view.run_worker" + ], + [ + "src/toad/transcript_publication.py", + 560, + "view.run_worker" + ], + [ + "src/toad/transcript_publication.py", + 742, + "view.run_worker" + ], + [ + "src/toad/transcript_source_preparation.py", + 236, + "self.run_worker" + ], + [ + "src/toad/transcript_state.py", + 237, + "owner.run_worker" + ], + [ + "src/toad/transcript_state.py", + 213, + "work" + ], + [ + "src/toad/widgets/acp_log.py", + 77, + "work" + ], + [ + "src/toad/widgets/command_pane.py", + 104, + "self.run_worker" + ], + [ + "src/toad/widgets/comms_chat.py", + 494, + "self.run_worker" + ], + [ + "src/toad/widgets/comms_command_dialog.py", + 33, + "parameter.description" + ], + [ + "src/toad/widgets/comms_sidebar.py", + 290, + "self.app.run_worker" + ], + [ + "src/toad/widgets/comms_transfer.py", + 113, + "self.app.run_worker" + ], + [ + "src/toad/widgets/context_explorer.py", + 310, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 347, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 388, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 403, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 431, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 452, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 469, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 485, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 516, + "work" + ], + [ + "src/toad/widgets/context_explorer.py", + 412, + "Coordination.run_worker" + ], + [ + "src/toad/widgets/conversation.py", + 1092, + "work" + ], + [ + "src/toad/widgets/conversation.py", + 1617, + "work" + ], + [ + "src/toad/widgets/conversation.py", + 1748, + "work" + ], + [ + "src/toad/widgets/conversation.py", + 1449, + "message.event.update.failure.description" + ], + [ + "src/toad/widgets/conversation.py", + 1442, + "message.event.update.failure.description" + ], + [ + "src/toad/widgets/menu.py", + 115, + "menu_item.description" + ], + [ + "src/toad/widgets/menu.py", + 119, + "menu_item.description" + ], + [ + "src/toad/widgets/model_switcher.py", + 143, + "work" + ], + [ + "src/toad/widgets/model_switcher.py", + 173, + "model.description" + ], + [ + "src/toad/widgets/path_search.py", + 371, + "work" + ], + [ + "src/toad/widgets/path_search.py", + 417, + "work" + ], + [ + "src/toad/widgets/path_search.py", + 451, + "work" + ], + [ + "src/toad/widgets/prepared_markdown.py", + 104, + "self.run_worker" + ], + [ + "src/toad/widgets/project_directory_tree.py", + 122, + "work" + ], + [ + "src/toad/widgets/project_panel.py", + 87, + "work" + ], + [ + "src/toad/widgets/project_panel.py", + 130, + "work" + ], + [ + "src/toad/widgets/project_panel.py", + 188, + "work" + ], + [ + "src/toad/widgets/prompt.py", + 264, + "work" + ], + [ + "src/toad/widgets/prompt.py", + 532, + "mode.description" + ], + [ + "src/toad/widgets/prompt.py", + 538, + "model.description" + ], + [ + "src/toad/widgets/prompt.py", + 580, + "mode.description" + ], + [ + "src/toad/widgets/session_sort.py", + 62, + "self.run_worker" + ], + [ + "src/toad/widgets/session_sort.py", + 166, + "self.run_worker" + ], + [ + "src/toad/widgets/side_bar.py", + 716, + "self.run_worker" + ], + [ + "src/toad/widgets/thread_comms.py", + 523, + "self.run_worker" + ], + [ + "src/toad/widgets/tool_content.py", + 191, + "self.run_worker" + ], + [ + "src/toad/widgets/viewport_body.py", + 155, + "body.run_worker" + ], + [ + "src/toad/widgets/viewport_body.py", + 1007, + "self.window.run_worker" + ], + [ + "src/toad/widgets/viewport_body.py", + 146, + "work" + ], + [ + "src/toad/widgets/worker_static.py", + 126, + "self.run_worker" + ] + ] + } +} diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 889651a4b09..3ecc844fee3 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -94,8 +94,8 @@ def work( exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. exclusive: Cancel all workers in the same group. description: Readable description of the worker for debugging purposes. - By default, it uses a string representation of the decorated method - and its arguments. + By default, it uses the decorated method's qualified name, without + formatting its arguments. thread: Mark the method as a thread worker. """ @@ -114,6 +114,8 @@ def decorator( "Can not create a worker from a non-async function unless `thread=True` is set on the work decorator." ) + debug_description = method.__qualname__ if description is None else description + @wraps(method) def decorated( *args: DecoratorParamSpec.args, **kwargs: DecoratorParamSpec.kwargs @@ -124,18 +126,6 @@ def decorated( self = args[0] assert isinstance(self, DOMNode) - if description is not None: - debug_description = description - else: - try: - positional_arguments = ", ".join(repr(arg) for arg in args[1:]) - keyword_arguments = ", ".join( - f"{name}={value!r}" for name, value in kwargs.items() - ) - tokens = [positional_arguments, keyword_arguments] - debug_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" - except Exception: - debug_description = "" worker = cast( "Worker[ReturnType]", self.run_worker( diff --git a/src/textual/dom.py b/src/textual/dom.py index c169552889f..a6146012e66 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -570,7 +570,7 @@ def run_worker( work: WorkType[ResultType], name: str | None = "", group: str = "default", - description: str = "", + description: str | None = None, exit_on_error: bool = True, start: bool = True, exclusive: bool = False, @@ -584,7 +584,7 @@ def run_worker( work: A function, async function, or an awaitable object to run in a worker. name: A short string to identify the worker (in logs and debugging). group: A short string to identify a group of workers. - description: A longer string to store longer information on the worker. + description: A longer string for debugging, or None to use the worker name. exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. start: Start the worker immediately. exclusive: Cancel all workers in the same group. diff --git a/src/textual/worker.py b/src/textual/worker.py index ff0001157f1..2bf1c70a94f 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -151,7 +151,7 @@ def __init__( *, name: str = "", group: str = "default", - description: str = "", + description: str | None = None, exit_on_error: bool = True, thread: bool = False, ) -> None: @@ -162,7 +162,7 @@ def __init__( work: A callable, coroutine, or other awaitable object to run in the worker. name: Name of the worker (short string to help identify when debugging). group: The worker group. - description: Description of the worker (longer string with more details). + description: Description of the worker, or None to use its name. exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. thread: Mark the worker as a thread worker. """ @@ -170,6 +170,8 @@ def __init__( self._work = work self.name = name self.group = group + if description is None: + description = name self.description = ( description if len(description) <= 1000 else description[:1000] + "..." ) diff --git a/src/textual/worker_manager.py b/src/textual/worker_manager.py index c9083a2968c..3c01b1fd317 100644 --- a/src/textual/worker_manager.py +++ b/src/textual/worker_manager.py @@ -85,7 +85,7 @@ def _new_worker( *, name: str | None = "", group: str = "default", - description: str = "", + description: str | None = None, exit_on_error: bool = True, start: bool = True, exclusive: bool = False, @@ -97,7 +97,7 @@ def _new_worker( work: A callable, a coroutine, or other awaitable. name: A name to identify the worker. group: The worker group. - description: A description of the worker. + description: A description of the worker, or None to use its name. exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. start: Automatically start the worker. exclusive: Cancel all workers in the same group. @@ -111,7 +111,7 @@ def _new_worker( work, name=name or getattr(work, "__name__", "") or "", group=group, - description=description or repr(work), + description=description, exit_on_error=exit_on_error, thread=thread, ) From 22e54a3aa173dadc2a14d3377e763e6000251b86 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 00:16:06 -0400 Subject: [PATCH 2/2] Verify worker diagnostics and retain named partial declarations --- .../README.md | 35 +++++++++ .../controls.log | 2 + .../description-final.log | 2 + src/textual/_work_decorator.py | 6 +- tests/workers/test_work_decorator.py | 75 +++++++++++++++++++ tests/workers/test_worker_manager.py | 21 ++++++ 6 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 evidence/worker-description-declaration-20261007/README.md create mode 100644 evidence/worker-description-declaration-20261007/controls.log create mode 100644 evidence/worker-description-declaration-20261007/description-final.log diff --git a/evidence/worker-description-declaration-20261007/README.md b/evidence/worker-description-declaration-20261007/README.md new file mode 100644 index 00000000000..09ca6be2577 --- /dev/null +++ b/evidence/worker-description-declaration-20261007/README.md @@ -0,0 +1,35 @@ +# Worker descriptions without payload formatting + +Worker launch previously walked every decorated argument through repr, joined +the full result, then truncated it in Worker. WorkerManager independently used +repr(work) for a missing or empty description, including functools.partial and +its bound payload. These are diagnostic values, not execution inputs. + +The work decorator now derives its default description once from the callable +declaration. Ordinary functions use their qualified name. Supported named partial +declarations use the explicit name when they have no qualified-name attribute. +No arguments or keyword values participate. The original partial(method, *args, +**kwargs), execution flags and worker lifetime remain unchanged. + +Worker owns the direct-launch default: description=None means use the worker +name. DOMNode.run_worker and WorkerManager forward that value instead of making +another diagnostic decision. Explicit strings, including empty strings, stay +explicit; the existing 1000-character truncation stays with Worker. Worker repr +and StateChanged retain safe string diagnostics without formatting the work. +API: direct run_worker / WorkerManager / Worker description defaults are now +str | None = None. The work decorator already used that distinction. + +61 original worker/decorator/manager/context controls passed in 1.99s. The final +two affected controls passed in .27s after preserving named partial declarations. +Real App workers verify default and explicit descriptions, zero payload and +callable repr calls at launch/display, async/thread identity delivery, unchanged +truncation and joined completion. No mocks, provider calls or private packages. +No installed ContextExplorer or live latency acceptance claimed; Parent owns +that application check and publication. + +source.json records original declaration/consumer sites using the existing +refactor-audit Package parser. Native and Toad parse without omissions. Worker +description consumers are diagnostics; no lifecycle decision uses the former +argument dump. Arbitrary external subclasses/callable attribute behavior is not +resolved by lexical sites. Explicit user descriptions remain caller-supplied. +Native supplies no automatic payload representation or new cache/registry. diff --git a/evidence/worker-description-declaration-20261007/controls.log b/evidence/worker-description-declaration-20261007/controls.log new file mode 100644 index 00000000000..e44dd8dea92 --- /dev/null +++ b/evidence/worker-description-declaration-20261007/controls.log @@ -0,0 +1,2 @@ +............................................................. [100%] +61 passed in 1.99s diff --git a/evidence/worker-description-declaration-20261007/description-final.log b/evidence/worker-description-declaration-20261007/description-final.log new file mode 100644 index 00000000000..5e9532b66fb --- /dev/null +++ b/evidence/worker-description-declaration-20261007/description-final.log @@ -0,0 +1,2 @@ +.. [100%] +2 passed in 0.27s diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 3ecc844fee3..f096ccd96d4 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -114,7 +114,11 @@ def decorator( "Can not create a worker from a non-async function unless `thread=True` is set on the work decorator." ) - debug_description = method.__qualname__ if description is None else description + debug_description = ( + getattr(method, "__qualname__", name or "") + if description is None + else description + ) @wraps(method) def decorated( diff --git a/tests/workers/test_work_decorator.py b/tests/workers/test_work_decorator.py index a7d01fa1878..de258fc7586 100644 --- a/tests/workers/test_work_decorator.py +++ b/tests/workers/test_work_decorator.py @@ -10,6 +10,81 @@ from textual.worker import Worker, WorkerState, WorkType +async def test_worker_descriptions_do_not_format_call_payloads(): + from functools import partial + + class Payload: + repr_calls = 0 + + def __repr__(self): + self.repr_calls += 1 + raise RuntimeError("Worker diagnostics must not format payloads") + + async def partially_declared(node, original, *, selected): + return original, selected + + class DescriptionApp(App): + partial_task = work( + partial(partially_declared, selected="declared selection"), + name="named partial", + ) + + @work + async def retain(self, original, *, selected): + return original, selected + + @work(description="") + async def empty(self, original): + return original + + @work(description="explicit diagnostic") + async def explicit(self, original): + return original + + @work(thread=True) + def threaded(self, original): + return original + + payload = Payload() + selected = Payload() + app = DescriptionApp() + async with app.run_test(): + decorated = app.retain(payload, selected=selected) + empty = app.empty(payload) + explicit = app.explicit(payload) + threaded = app.threaded(payload) + declared_partial = app.partial_task(payload) + + async def receive(original): + return original + + direct = app.run_worker(partial(receive, payload), name="direct", start=False) + direct_empty = app.run_worker( + partial(receive, payload), name="direct empty", description="", start=False + ) + long = "explicit " * 150 + direct_long = app.run_worker( + partial(receive, payload), description=long, start=False + ) + assert decorated.description.endswith("DescriptionApp.retain") + assert empty.description == direct_empty.description == "" + assert explicit.description == "explicit diagnostic" + assert direct.description == "direct" + assert declared_partial.description == "named partial" + assert direct_long.description == long[:1000] + "..." + assert payload.repr_calls == selected.repr_calls == 0 + for worker in (decorated, empty, explicit, threaded, declared_partial, direct, direct_empty, direct_long): + repr(worker) + assert payload.repr_calls == selected.repr_calls == 0 + app.workers.start_all() + assert await decorated.wait() == (payload, selected) + assert await declared_partial.wait() == (payload, "declared selection") + for worker in (empty, explicit, threaded, direct, direct_empty, direct_long): + assert await worker.wait() is payload + await app.workers.wait_for_complete() + assert payload.repr_calls == selected.repr_calls == 0 + + class WorkApp(App): worker: Worker diff --git a/tests/workers/test_worker_manager.py b/tests/workers/test_worker_manager.py index 7e81c734a0a..36b01ef112f 100644 --- a/tests/workers/test_worker_manager.py +++ b/tests/workers/test_worker_manager.py @@ -6,6 +6,27 @@ from textual.worker import Worker, WorkerState +async def test_direct_callable_description_does_not_call_its_repr(): + class CallableWork: + repr_calls = 0 + + def __repr__(self): + self.repr_calls += 1 + raise RuntimeError("Work repr is not launch metadata") + + def __call__(self): + return "completed" + + work = CallableWork() + app = App() + async with app.run_test(): + worker = app.run_worker(work, name="callable", thread=True) + assert worker.description == "callable" + assert work.repr_calls == 0 + assert await worker.wait() == "completed" + assert work.repr_calls == 0 + + def test_worker_manager_init(): app = App() assert isinstance(repr(app.workers), str)