gitpane 1.0.0__py3-none-any.whl
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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Describe how a program GitPane starts learns that the user asked for it.
|
|
2
|
+
|
|
3
|
+
A desktop only lets a window come to the front without the user clicking it when the program that shows it proves the
|
|
4
|
+
user asked for it: under X11 with a startup identifier carrying the time of the user's last action, under Wayland with
|
|
5
|
+
an activation token the compositor handed out for that action. GitPane reads that value when the user clicks, and hands
|
|
6
|
+
it to the program it starts through the program's environment or the file manager's D-Bus argument.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StartupHandoff(Enum):
|
|
17
|
+
"""How a started program receives the value that lets its window come to the front.
|
|
18
|
+
|
|
19
|
+
The value of each member is the name of the environment variable the started program reads, or an empty text when
|
|
20
|
+
there is nothing to hand over.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
NONE = ""
|
|
24
|
+
X11_STARTUP_ID = "DESKTOP_STARTUP_ID"
|
|
25
|
+
WAYLAND_ACTIVATION_TOKEN = "XDG_ACTIVATION_TOKEN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class StartupActivation:
|
|
30
|
+
"""The startup value read when the user clicked, with the way it reaches the started program."""
|
|
31
|
+
|
|
32
|
+
handoff: StartupHandoff
|
|
33
|
+
value: str = ""
|
|
34
|
+
|
|
35
|
+
def environment(self, base_by_name: Mapping[str, str]) -> dict[str, str]:
|
|
36
|
+
"""Copy an environment without any inherited startup value, then add this one when there is one.
|
|
37
|
+
|
|
38
|
+
Every startup variable is removed first: one inherited from GitPane's own start was already used by GitPane's
|
|
39
|
+
first window, and an identifier of another launch would name the wrong action. Qt removes its own token from
|
|
40
|
+
the C environment only, never from ``os.environ``, so a copy of ``os.environ`` still holds it. The table
|
|
41
|
+
received is left as it is.
|
|
42
|
+
"""
|
|
43
|
+
inherited_name_set: set[str] = set()
|
|
44
|
+
for handoff in StartupHandoff:
|
|
45
|
+
if handoff.value:
|
|
46
|
+
inherited_name_set.add(handoff.value)
|
|
47
|
+
environment_by_name: dict[str, str] = {}
|
|
48
|
+
for name, value in base_by_name.items():
|
|
49
|
+
if name in inherited_name_set:
|
|
50
|
+
continue
|
|
51
|
+
environment_by_name[name] = value
|
|
52
|
+
if self.value and self.handoff.value:
|
|
53
|
+
environment_by_name[self.handoff.value] = self.value
|
|
54
|
+
return environment_by_name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
NO_STARTUP_ACTIVATION = StartupActivation(StartupHandoff.NONE)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def no_startup_activation() -> StartupActivation:
|
|
61
|
+
"""Answer that there is no startup value, so the desktop alone decides whether an opened window comes to the front.
|
|
62
|
+
|
|
63
|
+
The source of a service built without a desktop, as in the tests: it never touches a window.
|
|
64
|
+
"""
|
|
65
|
+
return NO_STARTUP_ACTIVATION
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Detach GitPane from the terminal it was typed in, so closing that terminal leaves GitPane running.
|
|
2
|
+
|
|
3
|
+
The gitpane command typed in a terminal starts GitPane again as a new process in a session of its own, then waits for
|
|
4
|
+
that process to say it showed its first window. Until then, GitPane's output comes back to the terminal: an error at
|
|
5
|
+
start is shown there with GitPane's exit code. Once GitPane is ready, the command gives the terminal back, and a hangup
|
|
6
|
+
of that terminal no longer reaches GitPane. A new process is started rather than the running one forked: the command
|
|
7
|
+
has already loaded Qt, and on macOS a fork without exec after Apple's frameworks are loaded is not safe.
|
|
8
|
+
|
|
9
|
+
The channel GitPane uses to say it is ready is a pair of connected local sockets: the started GitPane receives one end,
|
|
10
|
+
whose descriptor number travels in an environment variable, takes it out of its environment and keeps it from the
|
|
11
|
+
programs it starts. GitPane writes on its end to say it is ready. The end the command keeps is never written on, and
|
|
12
|
+
it closes when the command ends, which GitPane sees at its own end, then readable: that is how GitPane knows the
|
|
13
|
+
command is gone even before it is ready.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import selectors
|
|
20
|
+
import signal
|
|
21
|
+
import socket
|
|
22
|
+
import stat
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import TYPE_CHECKING, BinaryIO, TextIO
|
|
29
|
+
|
|
30
|
+
from gitpane.config import DETACHED_READY_TIMEOUT_MS, DETACHED_STOP_GRACE_MS, ISOLATED_MODE_OPTION, LINUX_PLATFORM, MACOS_PLATFORM, MILLISECONDS_PER_SECOND, MODULE_OPTION, SAFE_PATH_OPTION, SIGNAL_EXIT_CODE_BASE, WINDOWS_PLATFORM
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
|
34
|
+
|
|
35
|
+
READY_CHANNEL_ENVIRONMENT_NAME = "GITPANE_READY_CHANNEL"
|
|
36
|
+
READY_MARKER = b"R"
|
|
37
|
+
STANDARD_INPUT_DESCRIPTOR = 0
|
|
38
|
+
STANDARD_OUTPUT_DESCRIPTOR = 1
|
|
39
|
+
STANDARD_ERROR_DESCRIPTOR = 2
|
|
40
|
+
STANDARD_DESCRIPTORS = (STANDARD_INPUT_DESCRIPTOR, STANDARD_OUTPUT_DESCRIPTOR, STANDARD_ERROR_DESCRIPTOR)
|
|
41
|
+
# Only the end of GitPane's output before it is ready is shown when it stops before its first window: a long run of
|
|
42
|
+
# warnings before an error would otherwise grow without bound in the waiting command.
|
|
43
|
+
KEPT_OUTPUT_BYTE_LIMIT = 65_536
|
|
44
|
+
# Largest ready message read: the marker and the path of the diagnostic log. A longer path is not sent.
|
|
45
|
+
READY_MESSAGE_BYTE_LIMIT = 4_096
|
|
46
|
+
OUTPUT_READ_BYTE_COUNT = 65_536
|
|
47
|
+
LOG_FILENAME_ENCODING = "utf-8"
|
|
48
|
+
# Whether the gitpane command leaves the terminal it was typed in. On Windows the installed command has no console, so
|
|
49
|
+
# there is nothing to leave; an unknown system stays attached.
|
|
50
|
+
DETACH_BY_PLATFORM = {LINUX_PLATFORM: True, MACOS_PLATFORM: True, WINDOWS_PLATFORM: False}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TerminalDetachText(Enum):
|
|
54
|
+
"""Texts the gitpane command writes to the terminal while it waits for GitPane to be ready."""
|
|
55
|
+
|
|
56
|
+
STILL_STARTING = "GitPane is still starting; it goes on without this terminal, and its output is no longer shown here."
|
|
57
|
+
"""Written when the wait ends before GitPane said it is ready."""
|
|
58
|
+
LOG_FILE = "Diagnostic log: {filename}"
|
|
59
|
+
"""Written once GitPane is ready, when it writes a diagnostic log."""
|
|
60
|
+
STOPPED_BEFORE_READY = "GitPane stopped before opening its first window (exit status {status})."
|
|
61
|
+
"""Written after GitPane's own output when it ends before it is ready."""
|
|
62
|
+
INTERRUPTED = "GitPane goes on starting without this terminal; close its window to stop it."
|
|
63
|
+
"""Written when Ctrl+C ends the wait before GitPane said it is ready."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class DetachOutcome:
|
|
68
|
+
"""What the waiting command ends with: its exit code, and the process ID of the GitPane it started."""
|
|
69
|
+
|
|
70
|
+
exit_code: int
|
|
71
|
+
child_process_id: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def detaches_from_terminal(platform: str, stream_is_terminal: Callable[[int], bool], attach: bool, environment_by_name: Mapping[str, str]) -> bool:
|
|
75
|
+
"""Tell whether the gitpane command should start GitPane again in a session of its own.
|
|
76
|
+
|
|
77
|
+
Only on Linux and macOS, only when both its standard output and its standard error are a terminal, never with
|
|
78
|
+
``--attach``, and never in a GitPane that was itself started detached. A desktop shortcut or a menu never detaches,
|
|
79
|
+
and an output or errors redirected to a file or a program keep GitPane attached, so that file or program receives
|
|
80
|
+
them. The standard input is not looked at: the detached GitPane gets the null device as its input anyway.
|
|
81
|
+
``stream_is_terminal`` receives a descriptor number (``os.isatty`` in the command); it is not called on a system that
|
|
82
|
+
never detaches.
|
|
83
|
+
"""
|
|
84
|
+
if not DETACH_BY_PLATFORM.get(platform, False):
|
|
85
|
+
return False
|
|
86
|
+
if attach:
|
|
87
|
+
return False
|
|
88
|
+
if READY_CHANNEL_ENVIRONMENT_NAME in environment_by_name:
|
|
89
|
+
return False
|
|
90
|
+
return stream_is_terminal(STANDARD_OUTPUT_DESCRIPTOR) and stream_is_terminal(STANDARD_ERROR_DESCRIPTOR)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def interrupt_stops_gitpane(platform: str, attach: bool) -> bool:
|
|
94
|
+
"""Tell whether Ctrl+C in the terminal must stop GitPane at once, which only an attached GitPane asks for.
|
|
95
|
+
|
|
96
|
+
With ``--attach`` on a system where the command would otherwise detach, the terminal is a diagnostic one and Ctrl+C
|
|
97
|
+
is expected to end GitPane right away, as for any program typed there. Elsewhere the interrupt keeps Python's
|
|
98
|
+
default handling.
|
|
99
|
+
"""
|
|
100
|
+
return attach and DETACH_BY_PLATFORM.get(platform, False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def running_interpreter_options() -> list[str]:
|
|
104
|
+
"""Return the options of the running Python (``-X dev``, ``-W …``, ``-E``, ``-s``, ``-O``, ``-I``…) as command-line arguments."""
|
|
105
|
+
# Workaround: Python has no public way to rebuild the options of the running interpreter from sys.flags,
|
|
106
|
+
# sys.warnoptions and sys._xoptions; this private function of the standard library does it, and multiprocessing
|
|
107
|
+
# relies on it to start its processes with the options of their parent.
|
|
108
|
+
return subprocess._args_from_interpreter_flags()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def detached_child_command(argv: Sequence[str], python_filename: str, module_name: str, interpreter_options: Sequence[str]) -> list[str]:
|
|
112
|
+
"""Build the command that starts GitPane again with the same arguments, on its module, without the current folder in its path.
|
|
113
|
+
|
|
114
|
+
``interpreter_options`` are the options of the command's own Python, passed on first so the started GitPane runs
|
|
115
|
+
under the same ones. The safe path option is added only when they hold neither it nor isolated mode, which already
|
|
116
|
+
implies it.
|
|
117
|
+
"""
|
|
118
|
+
command = [python_filename, *interpreter_options]
|
|
119
|
+
if SAFE_PATH_OPTION not in interpreter_options and ISOLATED_MODE_OPTION not in interpreter_options:
|
|
120
|
+
command.append(SAFE_PATH_OPTION)
|
|
121
|
+
return [*command, MODULE_OPTION, module_name, *argv]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def exit_status(return_code: int) -> int:
|
|
125
|
+
"""Turn a child's return code into a shell's exit status: its own code, or the base plus n for signal n."""
|
|
126
|
+
if return_code >= 0:
|
|
127
|
+
return return_code
|
|
128
|
+
return SIGNAL_EXIT_CODE_BASE - return_code
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def relaunch_detached(
|
|
132
|
+
command: Sequence[str],
|
|
133
|
+
environment_by_name: Mapping[str, str],
|
|
134
|
+
*,
|
|
135
|
+
timeout_ms: int = DETACHED_READY_TIMEOUT_MS,
|
|
136
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen,
|
|
137
|
+
output_stream: BinaryIO | None = None,
|
|
138
|
+
message_stream: TextIO | None = None,
|
|
139
|
+
) -> DetachOutcome:
|
|
140
|
+
"""Start ``command`` in a session of its own, then wait for its ready signal, its end or the timeout.
|
|
141
|
+
|
|
142
|
+
Returns 0 when it is ready, and writes the diagnostic log path it sent, if any. When it closes its channel without
|
|
143
|
+
the ready signal, its output is still read, then its end waited for, during ``DETACHED_STOP_GRACE_MS`` counted from
|
|
144
|
+
that close, whatever is left of the timeout: its last output is written to ``output_stream`` (the binary error
|
|
145
|
+
stream by default), then a line that says it stopped, and its exit status is returned. When the timeout comes first,
|
|
146
|
+
or the child is still alive past that delay, a line says GitPane goes on without the terminal, and 0 is returned.
|
|
147
|
+
The child's output is read during the whole wait, so a child that writes a lot before it is ready never blocks.
|
|
148
|
+
"""
|
|
149
|
+
if output_stream is None:
|
|
150
|
+
output_stream = sys.stderr.buffer
|
|
151
|
+
if message_stream is None:
|
|
152
|
+
message_stream = sys.stderr
|
|
153
|
+
deadline = time.monotonic() + timeout_ms / MILLISECONDS_PER_SECOND
|
|
154
|
+
command_socket, gitpane_socket = socket.socketpair()
|
|
155
|
+
command_end_descriptor = command_socket.detach()
|
|
156
|
+
gitpane_end_descriptor = gitpane_socket.detach()
|
|
157
|
+
try:
|
|
158
|
+
child_environment = dict(environment_by_name)
|
|
159
|
+
child_environment[READY_CHANNEL_ENVIRONMENT_NAME] = str(gitpane_end_descriptor)
|
|
160
|
+
try:
|
|
161
|
+
child = popen(
|
|
162
|
+
list(command),
|
|
163
|
+
stdin=subprocess.DEVNULL,
|
|
164
|
+
stdout=subprocess.PIPE,
|
|
165
|
+
stderr=subprocess.STDOUT,
|
|
166
|
+
start_new_session=True,
|
|
167
|
+
pass_fds=(gitpane_end_descriptor,),
|
|
168
|
+
env=child_environment,
|
|
169
|
+
)
|
|
170
|
+
finally:
|
|
171
|
+
os.close(gitpane_end_descriptor)
|
|
172
|
+
try:
|
|
173
|
+
return _wait_for_ready(child, command_end_descriptor, deadline, output_stream, message_stream)
|
|
174
|
+
finally:
|
|
175
|
+
if child.stdout is not None:
|
|
176
|
+
child.stdout.close()
|
|
177
|
+
finally:
|
|
178
|
+
os.close(command_end_descriptor)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _ChildWait:
|
|
182
|
+
"""Hold what the waiting command has read so far: the end of the child's output and the ready message."""
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
"""Start with nothing read."""
|
|
186
|
+
self.kept_output = bytearray()
|
|
187
|
+
self.ready_message = bytearray()
|
|
188
|
+
self.output_open = True
|
|
189
|
+
self.ready_open = True
|
|
190
|
+
|
|
191
|
+
def keep_output(self, chunk: bytes) -> None:
|
|
192
|
+
"""Add ``chunk`` to the kept output, dropping its oldest bytes past the limit."""
|
|
193
|
+
self.kept_output += chunk
|
|
194
|
+
excess = len(self.kept_output) - KEPT_OUTPUT_BYTE_LIMIT
|
|
195
|
+
if excess > 0:
|
|
196
|
+
del self.kept_output[:excess]
|
|
197
|
+
|
|
198
|
+
def keep_ready_message(self, chunk: bytes) -> None:
|
|
199
|
+
"""Add ``chunk`` to the ready message, up to its limit."""
|
|
200
|
+
room = READY_MESSAGE_BYTE_LIMIT - len(self.ready_message)
|
|
201
|
+
if room > 0:
|
|
202
|
+
self.ready_message += chunk[:room]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _remaining_seconds(deadline: float) -> float:
|
|
206
|
+
"""Return the seconds left until ``deadline``, never below zero."""
|
|
207
|
+
return max(0.0, deadline - time.monotonic())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _wait_for_ready(child: subprocess.Popen[bytes], read_descriptor: int, deadline: float, output_stream: BinaryIO, message_stream: TextIO) -> DetachOutcome:
|
|
211
|
+
"""Read the child's output and its ready channel until the channel ends or the deadline passes, then decide the outcome."""
|
|
212
|
+
wait = _ChildWait()
|
|
213
|
+
output_descriptor = child.stdout.fileno() if child.stdout is not None else None
|
|
214
|
+
selector = selectors.DefaultSelector()
|
|
215
|
+
try:
|
|
216
|
+
selector.register(read_descriptor, selectors.EVENT_READ)
|
|
217
|
+
if output_descriptor is not None:
|
|
218
|
+
selector.register(output_descriptor, selectors.EVENT_READ)
|
|
219
|
+
else:
|
|
220
|
+
wait.output_open = False
|
|
221
|
+
try:
|
|
222
|
+
_read_until(selector, wait, read_descriptor, deadline, lambda: wait.ready_open)
|
|
223
|
+
if wait.ready_open:
|
|
224
|
+
return _still_starting(child, message_stream)
|
|
225
|
+
if wait.ready_message.startswith(READY_MARKER):
|
|
226
|
+
return _ready(child, wait, message_stream)
|
|
227
|
+
# From the close of the channel on, the wait has its own short delay: a program GitPane started may keep
|
|
228
|
+
# the output open, and the end of a process comes a little after the close of its channel, maybe past the
|
|
229
|
+
# deadline of the ready signal.
|
|
230
|
+
stop_deadline = time.monotonic() + DETACHED_STOP_GRACE_MS / MILLISECONDS_PER_SECOND
|
|
231
|
+
_read_until(selector, wait, read_descriptor, stop_deadline, lambda: wait.output_open)
|
|
232
|
+
return _stopped_before_ready(child, wait, stop_deadline, output_stream, message_stream)
|
|
233
|
+
except KeyboardInterrupt:
|
|
234
|
+
# GitPane already runs in a session of its own, so Ctrl+C reaches only this waiting command: it says that
|
|
235
|
+
# GitPane goes on, then ends the way a shell reports an interrupted program, without a traceback.
|
|
236
|
+
return _interrupted(child, message_stream)
|
|
237
|
+
finally:
|
|
238
|
+
selector.close()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_until(selector: selectors.BaseSelector, wait: _ChildWait, read_descriptor: int, deadline: float, still_open: Callable[[], bool]) -> None:
|
|
242
|
+
"""Read whatever the child writes on either pipe while ``still_open`` holds and the deadline has not passed."""
|
|
243
|
+
while still_open():
|
|
244
|
+
remaining = _remaining_seconds(deadline)
|
|
245
|
+
if remaining <= 0:
|
|
246
|
+
return
|
|
247
|
+
for key, _event in selector.select(remaining):
|
|
248
|
+
chunk = os.read(key.fd, OUTPUT_READ_BYTE_COUNT)
|
|
249
|
+
if key.fd == read_descriptor:
|
|
250
|
+
if chunk:
|
|
251
|
+
wait.keep_ready_message(chunk)
|
|
252
|
+
continue
|
|
253
|
+
wait.ready_open = False
|
|
254
|
+
else:
|
|
255
|
+
if chunk:
|
|
256
|
+
wait.keep_output(chunk)
|
|
257
|
+
continue
|
|
258
|
+
wait.output_open = False
|
|
259
|
+
selector.unregister(key.fd)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _write_message(message_stream: TextIO, text: str) -> None:
|
|
263
|
+
"""Write one line to the terminal at once, so it is not mixed with output written around it."""
|
|
264
|
+
message_stream.write(text + "\n")
|
|
265
|
+
message_stream.flush()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _ready(child: subprocess.Popen[bytes], wait: _ChildWait, message_stream: TextIO) -> DetachOutcome:
|
|
269
|
+
"""End the wait for a ready GitPane, naming its diagnostic log when it sent one."""
|
|
270
|
+
log_filename = bytes(wait.ready_message[len(READY_MARKER) :]).decode(LOG_FILENAME_ENCODING, errors="replace")
|
|
271
|
+
if log_filename:
|
|
272
|
+
_write_message(message_stream, TerminalDetachText.LOG_FILE.value.format(filename=log_filename))
|
|
273
|
+
return DetachOutcome(0, child.pid)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _still_starting(child: subprocess.Popen[bytes], message_stream: TextIO) -> DetachOutcome:
|
|
277
|
+
"""End the wait at the deadline: GitPane goes on alone, and the terminal is given back."""
|
|
278
|
+
_write_message(message_stream, TerminalDetachText.STILL_STARTING.value)
|
|
279
|
+
return DetachOutcome(0, child.pid)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _interrupted(child: subprocess.Popen[bytes], message_stream: TextIO) -> DetachOutcome:
|
|
283
|
+
"""End the wait at Ctrl+C: say that GitPane goes on without the terminal, and return the shell's interrupt status."""
|
|
284
|
+
_write_message(message_stream, TerminalDetachText.INTERRUPTED.value)
|
|
285
|
+
return DetachOutcome(SIGNAL_EXIT_CODE_BASE + signal.SIGINT, child.pid)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _stopped_before_ready(child: subprocess.Popen[bytes], wait: _ChildWait, stop_deadline: float, output_stream: BinaryIO, message_stream: TextIO) -> DetachOutcome:
|
|
289
|
+
"""End the wait for a GitPane that closed its channel without the ready signal: show its output and return its status.
|
|
290
|
+
|
|
291
|
+
Its end is waited for until ``stop_deadline``, the delay granted from the close of its channel; a GitPane still
|
|
292
|
+
alive then goes on without the terminal.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
return_code = child.wait(timeout=_remaining_seconds(stop_deadline))
|
|
296
|
+
except subprocess.TimeoutExpired:
|
|
297
|
+
return _still_starting(child, message_stream)
|
|
298
|
+
status = exit_status(return_code)
|
|
299
|
+
message_stream.flush()
|
|
300
|
+
output_stream.write(bytes(wait.kept_output))
|
|
301
|
+
output_stream.flush()
|
|
302
|
+
_write_message(message_stream, TerminalDetachText.STOPPED_BEFORE_READY.value.format(status=status))
|
|
303
|
+
return DetachOutcome(status, child.pid)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def take_ready_channel(environment_by_name: MutableMapping[str, str]) -> int | None:
|
|
307
|
+
"""Take the ready channel out of the environment, and return its descriptor when it really is an open socket.
|
|
308
|
+
|
|
309
|
+
The variable is always removed, so no program GitPane starts sees it. Any parent can set it: a value that is not a
|
|
310
|
+
number, a standard stream, a closed descriptor or anything but a socket (a pipe, an ordinary file) is ignored. The
|
|
311
|
+
descriptor returned is no longer inherited by the programs GitPane starts, so none of them keeps the channel open
|
|
312
|
+
after GitPane ends.
|
|
313
|
+
"""
|
|
314
|
+
value = environment_by_name.pop(READY_CHANNEL_ENVIRONMENT_NAME, None)
|
|
315
|
+
if value is None:
|
|
316
|
+
return None
|
|
317
|
+
try:
|
|
318
|
+
channel = int(value)
|
|
319
|
+
except ValueError:
|
|
320
|
+
return None
|
|
321
|
+
if channel in STANDARD_DESCRIPTORS:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
mode = os.fstat(channel).st_mode
|
|
325
|
+
except OSError:
|
|
326
|
+
return None
|
|
327
|
+
if not stat.S_ISSOCK(mode):
|
|
328
|
+
return None
|
|
329
|
+
os.set_inheritable(channel, False)
|
|
330
|
+
return channel
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def redirect_standard_streams_to_null() -> None:
|
|
334
|
+
"""Point the three standard streams to the null device, once the gitpane command that read them gave them up.
|
|
335
|
+
|
|
336
|
+
Nothing reads the command's pipes any more after it gave the terminal back or ended: a program GitPane starts later
|
|
337
|
+
inherits the standard streams, and writing to a pipe nobody reads could stop it. Python's buffers are emptied first,
|
|
338
|
+
but a failure to empty them never keeps the streams on those pipes.
|
|
339
|
+
"""
|
|
340
|
+
for stream in (sys.stdout, sys.stderr):
|
|
341
|
+
if stream is None:
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
stream.flush()
|
|
345
|
+
except OSError:
|
|
346
|
+
# The command that read this stream is gone: the text left in the buffer has no reader any more.
|
|
347
|
+
pass
|
|
348
|
+
null_descriptor = os.open(os.devnull, os.O_RDWR)
|
|
349
|
+
try:
|
|
350
|
+
for descriptor in STANDARD_DESCRIPTORS:
|
|
351
|
+
os.dup2(null_descriptor, descriptor)
|
|
352
|
+
finally:
|
|
353
|
+
if null_descriptor not in STANDARD_DESCRIPTORS:
|
|
354
|
+
os.close(null_descriptor)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def notify_ready(channel: int, log_filename: str) -> None:
|
|
358
|
+
"""Tell the waiting command that GitPane is ready, after moving the standard streams off the pipes it reads.
|
|
359
|
+
|
|
360
|
+
The streams point to the null device before the signal is sent, so the command never gives the terminal back while
|
|
361
|
+
GitPane still writes to it. A waiting command already gone is not an error: the streams are moved all the same, the
|
|
362
|
+
signal is dropped and the channel is closed, and nothing is raised.
|
|
363
|
+
"""
|
|
364
|
+
redirect_standard_streams_to_null()
|
|
365
|
+
message = READY_MARKER + log_filename.encode(LOG_FILENAME_ENCODING)
|
|
366
|
+
if len(message) > READY_MESSAGE_BYTE_LIMIT:
|
|
367
|
+
message = READY_MARKER
|
|
368
|
+
try:
|
|
369
|
+
_write_all(channel, message)
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
finally:
|
|
373
|
+
try:
|
|
374
|
+
os.close(channel)
|
|
375
|
+
except OSError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _write_all(descriptor: int, data: bytes) -> None:
|
|
380
|
+
"""Write every byte of ``data``: a socket may take a long message in several writes."""
|
|
381
|
+
view = memoryview(data)
|
|
382
|
+
while view:
|
|
383
|
+
written = os.write(descriptor, view)
|
|
384
|
+
view = view[written:]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read the session values a freedesktop desktop declares in its environment, falling back on the specification's defaults.
|
|
2
|
+
|
|
3
|
+
Every function receives the environment, and the home folder when it needs one, instead of reading the process's own, so
|
|
4
|
+
each rule can be checked from any system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import posixpath
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
# The same value is written in tests/qt_platform.py, which imports nothing from GitPane; a test keeps the two identical.
|
|
17
|
+
DESKTOP_SEPARATOR = ":"
|
|
18
|
+
XDG_PATH_SEPARATOR = ":"
|
|
19
|
+
XDG_CURRENT_DESKTOP_ENVIRONMENT_NAME = "XDG_CURRENT_DESKTOP"
|
|
20
|
+
XDG_CONFIG_HOME_ENVIRONMENT_NAME = "XDG_CONFIG_HOME"
|
|
21
|
+
XDG_CONFIG_DIRS_ENVIRONMENT_NAME = "XDG_CONFIG_DIRS"
|
|
22
|
+
XDG_DATA_HOME_ENVIRONMENT_NAME = "XDG_DATA_HOME"
|
|
23
|
+
XDG_DATA_DIRS_ENVIRONMENT_NAME = "XDG_DATA_DIRS"
|
|
24
|
+
USER_CONFIG_DIR_BASENAME = ".config"
|
|
25
|
+
USER_DATA_DIR_PARTS = (".local", "share")
|
|
26
|
+
DEFAULT_XDG_CONFIG_DIRS = ("/etc/xdg",)
|
|
27
|
+
DEFAULT_XDG_DATA_DIRS = ("/usr/local/share", "/usr/share")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def desktop_names(environment_by_name: Mapping[str, str]) -> list[str]:
|
|
31
|
+
"""List the desktop names XDG_CURRENT_DESKTOP declares, lowercased, most specific first, without empty names.
|
|
32
|
+
|
|
33
|
+
Desktop-specific files, such as ``gnome-mimeapps.list``, are named after these lowercased names.
|
|
34
|
+
"""
|
|
35
|
+
names = []
|
|
36
|
+
for name in environment_by_name.get(XDG_CURRENT_DESKTOP_ENVIRONMENT_NAME, "").split(DESKTOP_SEPARATOR):
|
|
37
|
+
if name:
|
|
38
|
+
names.append(name.lower())
|
|
39
|
+
return names
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _xdg_home(environment_by_name: Mapping[str, str], environment_name: str, home_dir: str, default_parts: tuple[str, ...]) -> str:
|
|
43
|
+
"""Answer the absolute folder an XDG home variable names, or its default under ``home_dir`` when it is missing, empty or relative.
|
|
44
|
+
|
|
45
|
+
The specification asks to ignore a relative value, so nothing ever lands in the current folder.
|
|
46
|
+
"""
|
|
47
|
+
xdg_dir = environment_by_name.get(environment_name, "")
|
|
48
|
+
if xdg_dir and os.path.isabs(xdg_dir):
|
|
49
|
+
return xdg_dir
|
|
50
|
+
return os.path.join(home_dir, *default_parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def xdg_config_home(environment_by_name: Mapping[str, str], home_dir: str) -> str:
|
|
54
|
+
"""Answer the user's configuration folder: XDG_CONFIG_HOME when absolute, otherwise ``~/.config``."""
|
|
55
|
+
return _xdg_home(environment_by_name, XDG_CONFIG_HOME_ENVIRONMENT_NAME, home_dir, (USER_CONFIG_DIR_BASENAME,))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def xdg_data_home(environment_by_name: Mapping[str, str], home_dir: str) -> str:
|
|
59
|
+
"""Answer the user's data folder: XDG_DATA_HOME when absolute, otherwise ``~/.local/share``."""
|
|
60
|
+
return _xdg_home(environment_by_name, XDG_DATA_HOME_ENVIRONMENT_NAME, home_dir, USER_DATA_DIR_PARTS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _xdg_dirs(environment_by_name: Mapping[str, str], environment_name: str, default_dirs: tuple[str, ...]) -> list[str]:
|
|
64
|
+
"""List the absolute folders of an XDG folder list in order, or its defaults when no absolute folder is left.
|
|
65
|
+
|
|
66
|
+
The list is separated by ':', which is only a separator on freedesktop systems, so its entries are judged as POSIX
|
|
67
|
+
paths: a Windows path, which carries a ':' after its drive, cannot be one of them.
|
|
68
|
+
"""
|
|
69
|
+
xdg_dirs = []
|
|
70
|
+
for xdg_dir in environment_by_name.get(environment_name, "").split(XDG_PATH_SEPARATOR):
|
|
71
|
+
if xdg_dir and posixpath.isabs(xdg_dir):
|
|
72
|
+
xdg_dirs.append(xdg_dir)
|
|
73
|
+
if not xdg_dirs:
|
|
74
|
+
return list(default_dirs)
|
|
75
|
+
return xdg_dirs
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def xdg_config_dirs(environment_by_name: Mapping[str, str]) -> list[str]:
|
|
79
|
+
"""List the system configuration folders, most important first: XDG_CONFIG_DIRS, otherwise ``/etc/xdg``."""
|
|
80
|
+
return _xdg_dirs(environment_by_name, XDG_CONFIG_DIRS_ENVIRONMENT_NAME, DEFAULT_XDG_CONFIG_DIRS)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def xdg_data_dirs(environment_by_name: Mapping[str, str]) -> list[str]:
|
|
84
|
+
"""List the system data folders, most important first: XDG_DATA_DIRS, otherwise ``/usr/local/share`` then ``/usr/share``."""
|
|
85
|
+
return _xdg_dirs(environment_by_name, XDG_DATA_DIRS_ENVIRONMENT_NAME, DEFAULT_XDG_DATA_DIRS)
|
gitpane/text/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Match case-insensitive text with an optional asterisk wildcard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_left
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TextQuery:
|
|
13
|
+
"""Search ordered literal fragments without regex interpretation or backtracking."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, query: str, *, wildcard: bool = True) -> None:
|
|
16
|
+
"""Treat asterisks as gaps and preserve all other characters, including spaces."""
|
|
17
|
+
self.parts: list[str] = []
|
|
18
|
+
for part in query.split("*") if wildcard else (query,):
|
|
19
|
+
if part:
|
|
20
|
+
self.parts.append(part.casefold())
|
|
21
|
+
|
|
22
|
+
def matches(self, text: str) -> bool:
|
|
23
|
+
"""Check complete messages with Unicode case folding and no offset conversion."""
|
|
24
|
+
return self._find_span(text.casefold(), 0) is not None
|
|
25
|
+
|
|
26
|
+
def find_match(self, text: str, *, overlap: bool = False, wildcard_whitespace: bool = True) -> Iterator[tuple[int, int]]:
|
|
27
|
+
"""Yield source spans, optionally keeping wildcard gaps inside words for local highlighting."""
|
|
28
|
+
if not text:
|
|
29
|
+
return
|
|
30
|
+
folded_text = text.casefold()
|
|
31
|
+
whitespace_positions = None
|
|
32
|
+
if not wildcard_whitespace:
|
|
33
|
+
whitespace_positions = []
|
|
34
|
+
if len(self.parts) > 1:
|
|
35
|
+
for position, character in enumerate(folded_text):
|
|
36
|
+
if character.isspace():
|
|
37
|
+
whitespace_positions.append(position)
|
|
38
|
+
source_positions = []
|
|
39
|
+
if len(folded_text) != len(text):
|
|
40
|
+
for position, character in enumerate(text):
|
|
41
|
+
for _folded_character in character.casefold():
|
|
42
|
+
source_positions.append(position)
|
|
43
|
+
start = 0
|
|
44
|
+
previous_span = None
|
|
45
|
+
while start < len(folded_text):
|
|
46
|
+
span = self._find_span(folded_text, start, whitespace_positions)
|
|
47
|
+
if span is None:
|
|
48
|
+
return
|
|
49
|
+
begin, end = span
|
|
50
|
+
if source_positions:
|
|
51
|
+
last_character_index = end - 1
|
|
52
|
+
span = (source_positions[begin], source_positions[last_character_index] + 1)
|
|
53
|
+
if span != previous_span:
|
|
54
|
+
yield span
|
|
55
|
+
previous_span = span
|
|
56
|
+
if not self.parts and wildcard_whitespace:
|
|
57
|
+
return
|
|
58
|
+
start = begin + 1 if overlap else end
|
|
59
|
+
if source_positions and not overlap:
|
|
60
|
+
# Case folding can expand one source character; do not reuse half of it in the next match.
|
|
61
|
+
_source_begin, source_end = span
|
|
62
|
+
while start < len(source_positions) and source_positions[start] < source_end:
|
|
63
|
+
start += 1
|
|
64
|
+
|
|
65
|
+
def _find_span(self, folded_text: str, start: int, whitespace_positions: list[int] | None = None) -> tuple[int, int] | None:
|
|
66
|
+
"""Retry invalid word gaps using cached fragment positions instead of rescanning the same suffix."""
|
|
67
|
+
if not self.parts:
|
|
68
|
+
if whitespace_positions is None:
|
|
69
|
+
return start, len(folded_text)
|
|
70
|
+
while start < len(folded_text) and folded_text[start].isspace():
|
|
71
|
+
start += 1
|
|
72
|
+
end = start
|
|
73
|
+
while end < len(folded_text) and not folded_text[end].isspace():
|
|
74
|
+
end += 1
|
|
75
|
+
return (start, end) if end > start else None
|
|
76
|
+
position_by_part_index: dict[int, int] = {}
|
|
77
|
+
while start < len(folded_text):
|
|
78
|
+
begin = None
|
|
79
|
+
end = start
|
|
80
|
+
for part_index, part in enumerate(self.parts):
|
|
81
|
+
position = position_by_part_index.get(part_index, -1)
|
|
82
|
+
if position < end:
|
|
83
|
+
position = folded_text.find(part, end)
|
|
84
|
+
if position < 0:
|
|
85
|
+
return None
|
|
86
|
+
position_by_part_index[part_index] = position
|
|
87
|
+
if begin is not None and whitespace_positions is not None:
|
|
88
|
+
boundary_index = bisect_left(whitespace_positions, end)
|
|
89
|
+
if boundary_index < len(whitespace_positions) and whitespace_positions[boundary_index] < position:
|
|
90
|
+
start = begin + 1
|
|
91
|
+
break
|
|
92
|
+
if begin is None:
|
|
93
|
+
begin = position
|
|
94
|
+
end = position + len(part)
|
|
95
|
+
else:
|
|
96
|
+
return begin, end
|
|
97
|
+
return None
|