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,136 @@
|
|
|
1
|
+
"""Choose the command a desktop shortcut runs to start the running GitPane installation.
|
|
2
|
+
|
|
3
|
+
pip and pipx write a small launcher for every script an installed package declares, in the scripts folder of the
|
|
4
|
+
Python that installed it. A desktop shortcut starts that launcher when the running installation has one, so it follows
|
|
5
|
+
whichever installation is running: a pipx environment, a global or ``--user`` pip install, or the editable install of a
|
|
6
|
+
development checkout. An installation without that launcher (an editable install made before GitPane declared its
|
|
7
|
+
command) is started through the running Python itself, on GitPane's application module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ntpath
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import sysconfig
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from gitpane.config import LINUX_PLATFORM, MACOS_PLATFORM, MODULE_OPTION, SAFE_PATH_OPTION, WINDOWS_PLATFORM
|
|
22
|
+
from gitpane.process.process_group import WINDOWLESS_INTERPRETER_BASENAME
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable, Sequence
|
|
26
|
+
|
|
27
|
+
# The name of the gui-script that pyproject.toml declares, hence the name of the launcher pip writes.
|
|
28
|
+
GITPANE_COMMAND_NAME = "gitpane"
|
|
29
|
+
LAUNCHER_BASENAME_BY_PLATFORM = {
|
|
30
|
+
WINDOWS_PLATFORM: GITPANE_COMMAND_NAME + ".exe",
|
|
31
|
+
LINUX_PLATFORM: GITPANE_COMMAND_NAME,
|
|
32
|
+
MACOS_PLATFORM: GITPANE_COMMAND_NAME,
|
|
33
|
+
}
|
|
34
|
+
SCRIPTS_PATH_NAME = "scripts"
|
|
35
|
+
USER_SCHEME_KIND = "user"
|
|
36
|
+
# How a program and its arguments are written as one line the user can paste: Windows' own quoting there, a POSIX
|
|
37
|
+
# shell's everywhere else.
|
|
38
|
+
COMMAND_LINE_JOIN_BY_PLATFORM: dict[str, Callable[[Sequence[str]], str]] = {
|
|
39
|
+
WINDOWS_PLATFORM: subprocess.list2cmdline,
|
|
40
|
+
}
|
|
41
|
+
DEFAULT_COMMAND_LINE_JOIN = shlex.join
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class StartCommand:
|
|
46
|
+
"""The program a shortcut starts and the arguments it passes before its own, never ``--log``."""
|
|
47
|
+
|
|
48
|
+
program_filename: str
|
|
49
|
+
arguments: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def launcher_basename(platform: str) -> str:
|
|
53
|
+
"""Return the file name pip gives the gitpane launcher on ``platform``; an unknown system raises OSError."""
|
|
54
|
+
basename = LAUNCHER_BASENAME_BY_PLATFORM.get(platform)
|
|
55
|
+
if basename is None:
|
|
56
|
+
raise OSError("No gitpane launcher name known for " + platform)
|
|
57
|
+
return basename
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def running_in_virtual_environment() -> bool:
|
|
61
|
+
"""Tell whether the running Python is a virtual environment (a checkout's .venv, a pipx environment)."""
|
|
62
|
+
return sys.prefix != sys.base_prefix
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def installation_script_dirs(in_virtual_environment: bool) -> list[str]:
|
|
66
|
+
"""List the scripts folders where the running installation keeps its launchers: its own, then, outside a venv, the one of ``pip install --user``.
|
|
67
|
+
|
|
68
|
+
A venv only looks in its own folder: a gitpane link that another installation (pipx) put in ``~/.local/bin`` must
|
|
69
|
+
never pass for the GitPane of that venv. The user folder comes from ``sysconfig.get_preferred_scheme("user")``,
|
|
70
|
+
never from ``os.name + "_user"``: on a macOS framework Python (Homebrew, python.org) the user scheme is
|
|
71
|
+
``osx_framework_user``, whose scripts folder is not the one ``posix_user`` names. Empty and repeated folders are
|
|
72
|
+
left out, the order is kept.
|
|
73
|
+
"""
|
|
74
|
+
candidate_dirs = [sysconfig.get_path(SCRIPTS_PATH_NAME)]
|
|
75
|
+
if not in_virtual_environment:
|
|
76
|
+
try:
|
|
77
|
+
user_scheme = sysconfig.get_preferred_scheme(USER_SCHEME_KIND)
|
|
78
|
+
candidate_dirs.append(sysconfig.get_path(SCRIPTS_PATH_NAME, user_scheme))
|
|
79
|
+
except KeyError:
|
|
80
|
+
pass
|
|
81
|
+
script_dirs = []
|
|
82
|
+
for script_dir in candidate_dirs:
|
|
83
|
+
if script_dir and script_dir not in script_dirs:
|
|
84
|
+
script_dirs.append(script_dir)
|
|
85
|
+
return script_dirs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_launcher_filename(script_dirs: Sequence[str], platform: str, is_file: Callable[[str], bool] = os.path.isfile) -> str:
|
|
89
|
+
"""Return the first gitpane launcher found in ``script_dirs``, or an empty text when none of them holds one."""
|
|
90
|
+
basename = launcher_basename(platform)
|
|
91
|
+
for script_dir in script_dirs:
|
|
92
|
+
launcher_filename = os.path.join(script_dir, basename)
|
|
93
|
+
if is_file(launcher_filename):
|
|
94
|
+
return launcher_filename
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def windowless_python_filename(python_filename: str, platform: str, is_file: Callable[[str], bool] = os.path.isfile) -> str:
|
|
99
|
+
"""Return the Python a shortcut starts in place of ``python_filename``: on Windows the windowless pythonw.exe next to it when it is there.
|
|
100
|
+
|
|
101
|
+
A shortcut that started python.exe would open a console window beside GitPane. Elsewhere, and when pythonw.exe is
|
|
102
|
+
missing, ``python_filename`` is returned as it is: a venv's python is a link to the system's, so resolving it, or
|
|
103
|
+
making it absolute, would start GitPane outside its venv.
|
|
104
|
+
"""
|
|
105
|
+
if platform != WINDOWS_PLATFORM:
|
|
106
|
+
return python_filename
|
|
107
|
+
# This only matters on Windows; ntpath keeps the path the same when the tests run it on another system.
|
|
108
|
+
windowless_filename = ntpath.join(ntpath.dirname(python_filename), WINDOWLESS_INTERPRETER_BASENAME)
|
|
109
|
+
if is_file(windowless_filename):
|
|
110
|
+
return windowless_filename
|
|
111
|
+
return python_filename
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def start_command(script_dirs: Sequence[str], platform: str, python_filename: str, application_module_name: str,
|
|
115
|
+
is_file: Callable[[str], bool] = os.path.isfile) -> StartCommand | None:
|
|
116
|
+
"""Choose what a shortcut starts: the gitpane command of ``script_dirs``, or else ``python_filename`` with ``-P -m`` on the application module.
|
|
117
|
+
|
|
118
|
+
The gitpane command is started with no argument. None is returned when there is no gitpane command and the Python
|
|
119
|
+
or the module cannot be named; an unknown system raises OSError.
|
|
120
|
+
"""
|
|
121
|
+
launcher_filename = find_launcher_filename(script_dirs, platform, is_file)
|
|
122
|
+
if launcher_filename:
|
|
123
|
+
return StartCommand(launcher_filename)
|
|
124
|
+
if not python_filename or not application_module_name:
|
|
125
|
+
return None
|
|
126
|
+
return StartCommand(windowless_python_filename(python_filename, platform, is_file), (SAFE_PATH_OPTION, MODULE_OPTION, application_module_name))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def command_line(arguments: Sequence[str], platform: str) -> str:
|
|
130
|
+
"""Write ``arguments`` as one command line of ``platform``, each one quoted the way that system's shell reads it back."""
|
|
131
|
+
return COMMAND_LINE_JOIN_BY_PLATFORM.get(platform, DEFAULT_COMMAND_LINE_JOIN)(arguments)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def start_command_line(command: StartCommand, platform: str) -> str:
|
|
135
|
+
"""Write the program and the arguments of ``command`` as one command line of ``platform``, the way Settings shows it."""
|
|
136
|
+
return command_line((command.program_filename, *command.arguments), platform)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Tell which Linux distribution os-release describes, as GitPane groups them.
|
|
2
|
+
|
|
3
|
+
The family is the one of os-release's ID when the table knows it, otherwise the one of the first word of ID_LIKE the
|
|
4
|
+
table knows, read from left to right, otherwise UNKNOWN. The precise distribution comes from the exact ID alone, and only
|
|
5
|
+
names the few distributions whose install commands differ from the rest of their family; any other ID gives OTHER, so a
|
|
6
|
+
derivative known only through ID_LIKE keeps the line of its family. ID and ID_LIKE are compared exactly as os-release
|
|
7
|
+
writes them, without changing their case. Nothing read from os-release ever becomes a text shown to the user or a
|
|
8
|
+
command: only a member of ``DistributionFamily`` or ``DistributionRelease`` leaves this module, and the commands GitPane
|
|
9
|
+
shows come from its own tables.
|
|
10
|
+
Python's ``freedesktop_os_release`` keeps os-release for the whole process once it was read successfully, so asking the
|
|
11
|
+
family again then costs no file read; on a system without os-release, each call tries both files again and raises
|
|
12
|
+
``OSError``, which gives UNKNOWN and OTHER here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from platform import freedesktop_os_release
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
|
|
24
|
+
OS_RELEASE_ID_KEY = "ID"
|
|
25
|
+
OS_RELEASE_ID_LIKE_KEY = "ID_LIKE"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DistributionFamily(Enum):
|
|
29
|
+
"""A family of Linux distributions that install software the same way; UNKNOWN when os-release names none GitPane knows."""
|
|
30
|
+
|
|
31
|
+
DEBIAN = "debian"
|
|
32
|
+
"""Debian, Ubuntu and their derivatives, which install ``.deb`` packages through apt."""
|
|
33
|
+
FEDORA = "fedora"
|
|
34
|
+
"""Fedora and the distributions built on it, which install packages through dnf."""
|
|
35
|
+
RHEL = "rhel"
|
|
36
|
+
"""Red Hat Enterprise Linux and its rebuilds, kept apart from Fedora because some tools only reach them through EPEL."""
|
|
37
|
+
SUSE = "suse"
|
|
38
|
+
"""openSUSE and SUSE Linux Enterprise, which install packages through zypper."""
|
|
39
|
+
ARCH = "arch"
|
|
40
|
+
"""Arch Linux and its derivatives, which install packages through pacman."""
|
|
41
|
+
GENTOO = "gentoo"
|
|
42
|
+
"""Gentoo and Funtoo, which build packages through emerge."""
|
|
43
|
+
VOID = "void"
|
|
44
|
+
"""Void Linux, which installs packages through xbps, in a glibc and a musl variant under the same ID."""
|
|
45
|
+
ALPINE = "alpine"
|
|
46
|
+
"""Alpine Linux and its derivatives, built on the musl C library, which runs no program linked against glibc."""
|
|
47
|
+
NIXOS = "nixos"
|
|
48
|
+
"""NixOS and Guix System, neither of which has the dynamic loader or the libraries at the standard paths a prebuilt Linux program expects."""
|
|
49
|
+
SOLUS = "solus"
|
|
50
|
+
"""Solus, which installs packages through eopkg."""
|
|
51
|
+
UNKNOWN = "unknown"
|
|
52
|
+
"""A distribution os-release describes with no ID or ID_LIKE word GitPane knows, or a system without os-release."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# A key is an ID or a word of ID_LIKE, exactly as os-release writes it. Since ID is read first, a derivative is found by
|
|
56
|
+
# its own ID before any word of its ID_LIKE: Rocky Linux and AlmaLinux say ``ID_LIKE="rhel centos fedora"`` and find
|
|
57
|
+
# ``rhel`` first, while Nobara and Bazzite, Fedora derivatives whose ID_LIKE also starts with ``rhel``, stay Fedora
|
|
58
|
+
# through their own ID. Garuda and Artix write no ID_LIKE, so their own ID is the key.
|
|
59
|
+
FAMILY_BY_OS_RELEASE_ID: dict[str, DistributionFamily] = {
|
|
60
|
+
"debian": DistributionFamily.DEBIAN,
|
|
61
|
+
"ubuntu": DistributionFamily.DEBIAN,
|
|
62
|
+
"fedora": DistributionFamily.FEDORA,
|
|
63
|
+
"nobara": DistributionFamily.FEDORA,
|
|
64
|
+
"bazzite": DistributionFamily.FEDORA,
|
|
65
|
+
"rhel": DistributionFamily.RHEL,
|
|
66
|
+
"centos": DistributionFamily.RHEL,
|
|
67
|
+
"rocky": DistributionFamily.RHEL,
|
|
68
|
+
"almalinux": DistributionFamily.RHEL,
|
|
69
|
+
"ol": DistributionFamily.RHEL,
|
|
70
|
+
"opensuse": DistributionFamily.SUSE,
|
|
71
|
+
"suse": DistributionFamily.SUSE,
|
|
72
|
+
"arch": DistributionFamily.ARCH,
|
|
73
|
+
"garuda": DistributionFamily.ARCH,
|
|
74
|
+
"artix": DistributionFamily.ARCH,
|
|
75
|
+
"gentoo": DistributionFamily.GENTOO,
|
|
76
|
+
"funtoo": DistributionFamily.GENTOO,
|
|
77
|
+
"void": DistributionFamily.VOID,
|
|
78
|
+
"alpine": DistributionFamily.ALPINE,
|
|
79
|
+
"nixos": DistributionFamily.NIXOS,
|
|
80
|
+
"guix": DistributionFamily.NIXOS,
|
|
81
|
+
"solus": DistributionFamily.SOLUS,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def distribution_family(os_release_by_key: Mapping[str, str]) -> DistributionFamily:
|
|
86
|
+
"""Return the family os-release describes: the one of its ID first, then of each word of ID_LIKE in order, otherwise UNKNOWN.
|
|
87
|
+
|
|
88
|
+
Ubuntu says ``ID_LIKE=debian`` and Linux Mint ``ID_LIKE="ubuntu debian"``, so both are Debian; Rocky Linux says
|
|
89
|
+
``ID_LIKE="rhel centos fedora"`` and finds ``rhel`` before ``fedora``.
|
|
90
|
+
"""
|
|
91
|
+
distribution_ids = [os_release_by_key.get(OS_RELEASE_ID_KEY, ""), *os_release_by_key.get(OS_RELEASE_ID_LIKE_KEY, "").split()]
|
|
92
|
+
for distribution_id in distribution_ids:
|
|
93
|
+
family = FAMILY_BY_OS_RELEASE_ID.get(distribution_id)
|
|
94
|
+
if family is not None:
|
|
95
|
+
return family
|
|
96
|
+
return DistributionFamily.UNKNOWN
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_os_release_or_nothing(read_os_release: Callable[[], Mapping[str, str]]) -> dict[str, str]:
|
|
100
|
+
"""Read os-release, or nothing when the system has none, so that a missing file gives the unknown family instead of an error."""
|
|
101
|
+
try:
|
|
102
|
+
return dict(read_os_release())
|
|
103
|
+
except OSError:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def session_distribution_family(read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DistributionFamily:
|
|
108
|
+
"""Return the family of the running system; never raises, and gives UNKNOWN on a system without os-release."""
|
|
109
|
+
return distribution_family(read_os_release_or_nothing(read_os_release))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DistributionRelease(Enum):
|
|
113
|
+
"""A distribution whose install commands differ from the rest of its family; OTHER when the family alone says how to install."""
|
|
114
|
+
|
|
115
|
+
RHEL = "rhel"
|
|
116
|
+
"""Red Hat Enterprise Linux, whose extra build repository, CodeReady Builder, is turned on through subscription-manager."""
|
|
117
|
+
CENTOS_STREAM = "centos"
|
|
118
|
+
"""CentOS Stream, whose CRB repository is turned on through dnf config-manager."""
|
|
119
|
+
ROCKY_OR_ALMA = "rocky_or_alma"
|
|
120
|
+
"""Rocky Linux and AlmaLinux, whose CRB repository is turned on by the ``crb`` script that epel-release installs."""
|
|
121
|
+
ORACLE_LINUX = "ol"
|
|
122
|
+
"""Oracle Linux, which ships EPEL in Oracle's own repositories."""
|
|
123
|
+
GUIX = "guix"
|
|
124
|
+
"""Guix System, told apart from NixOS, with which it shares its family, because it installs software through guix."""
|
|
125
|
+
OTHER = "other"
|
|
126
|
+
"""Any other distribution, a derivative known only through ID_LIKE, or a system without os-release: the family alone says how to install."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# A key is an ID exactly as os-release writes it, never a word of ID_LIKE, so that a derivative that names ``rhel`` in
|
|
130
|
+
# its ID_LIKE without its own ID here keeps the line of its family instead of borrowing one of these.
|
|
131
|
+
RELEASE_BY_OS_RELEASE_ID: dict[str, DistributionRelease] = {
|
|
132
|
+
"rhel": DistributionRelease.RHEL,
|
|
133
|
+
"centos": DistributionRelease.CENTOS_STREAM,
|
|
134
|
+
"rocky": DistributionRelease.ROCKY_OR_ALMA,
|
|
135
|
+
"almalinux": DistributionRelease.ROCKY_OR_ALMA,
|
|
136
|
+
"ol": DistributionRelease.ORACLE_LINUX,
|
|
137
|
+
"guix": DistributionRelease.GUIX,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def distribution_release(os_release_by_key: Mapping[str, str]) -> DistributionRelease:
|
|
142
|
+
"""Return the precise distribution os-release's exact ID names, otherwise OTHER.
|
|
143
|
+
|
|
144
|
+
The ID is compared exactly as written: ``RHEL`` in capitals, or a derivative found only through ID_LIKE, gives OTHER.
|
|
145
|
+
"""
|
|
146
|
+
return RELEASE_BY_OS_RELEASE_ID.get(os_release_by_key.get(OS_RELEASE_ID_KEY, ""), DistributionRelease.OTHER)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def session_distribution_release(read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DistributionRelease:
|
|
150
|
+
"""Return the precise distribution of the running system; never raises, and gives OTHER on a system without os-release."""
|
|
151
|
+
return distribution_release(read_os_release_or_nothing(read_os_release))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tell whether a Windows folder lives on a network share, from the drive table alone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from gitpane.config import WINDOWS_PLATFORM
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
# GetDriveTypeW answers: DRIVE_UNKNOWN when the type cannot be told, DRIVE_REMOTE for a drive letter mapped to a network share.
|
|
16
|
+
DRIVE_UNKNOWN_TYPE = 0
|
|
17
|
+
DRIVE_REMOTE_TYPE = 4
|
|
18
|
+
# A folder written \\server\share\... or //server/share/... is on a network share whatever the drive table says.
|
|
19
|
+
UNC_PATH_PREFIXES = ("\\\\", "//")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def windows_drive_type(root_dir: str, platform: str = sys.platform) -> int:
|
|
23
|
+
"""Ask Windows what kind of drive a drive root is (a letter, a colon and a separator), from its own drive table, without contacting any server.
|
|
24
|
+
|
|
25
|
+
Other systems have no drive letters, so the type is unknown there.
|
|
26
|
+
"""
|
|
27
|
+
if platform != WINDOWS_PLATFORM:
|
|
28
|
+
return DRIVE_UNKNOWN_TYPE
|
|
29
|
+
get_drive_type = ctypes.WinDLL("kernel32").GetDriveTypeW
|
|
30
|
+
get_drive_type.argtypes = [ctypes.c_wchar_p]
|
|
31
|
+
get_drive_type.restype = ctypes.c_uint
|
|
32
|
+
return get_drive_type(root_dir)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def network_directory(directory: str, drive_type: Callable[[str], int] = windows_drive_type) -> bool:
|
|
36
|
+
"""Tell whether a Windows folder lives on a network share: a UNC path, or a drive letter mapped to a share.
|
|
37
|
+
|
|
38
|
+
Looking for a file in such a folder can freeze the window for seconds while Windows waits for a disconnected
|
|
39
|
+
share, so searches run from the window skip these folders. Only the path and the drive table are read.
|
|
40
|
+
"""
|
|
41
|
+
drive, _rest = os.path.splitdrive(directory)
|
|
42
|
+
if drive.startswith(UNC_PATH_PREFIXES):
|
|
43
|
+
return True
|
|
44
|
+
if not drive:
|
|
45
|
+
return False
|
|
46
|
+
return drive_type(drive + os.sep) == DRIVE_REMOTE_TYPE
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Identity of a path, decided in one place.
|
|
2
|
+
|
|
3
|
+
Two paths name the same folder or file when they have the same ``canonical_path``. On macOS ``realpath`` keeps the
|
|
4
|
+
letter case and the Unicode form that were typed, while a default macOS disk finds the same folder under another case
|
|
5
|
+
and another form (composed or decomposed accents): the identity there is the spelling the file system itself gives.
|
|
6
|
+
Windows and Linux keep ``normcase(realpath)``, so short names, network drives and links the user made are identified
|
|
7
|
+
exactly as before.
|
|
8
|
+
|
|
9
|
+
Never compare the bytes of a name read from the disk with the bytes of a path Git printed: compare them only after
|
|
10
|
+
both went through ``canonical_path``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from gitpane.config import MACOS_PLATFORM
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
# MAXPATHLEN of macOS: the longest path the kernel gives back for an open descriptor.
|
|
25
|
+
MACOS_MAXIMUM_PATH_BYTES = 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def macos_disk_spelling(path: str) -> str:
|
|
29
|
+
"""Return how an existing file or folder is spelled on a macOS disk, links resolved like realpath does.
|
|
30
|
+
|
|
31
|
+
No process is started: one open and one ``fcntl`` call, about the cost of a ``realpath``. Raises ``OSError`` when
|
|
32
|
+
the path does not exist or cannot be opened.
|
|
33
|
+
"""
|
|
34
|
+
# fcntl does not exist on Windows; this function is only called on macOS.
|
|
35
|
+
import fcntl
|
|
36
|
+
|
|
37
|
+
# Workaround: F_GETPATH needs an open descriptor. O_EVTONLY asks for an event-only descriptor, so no data is read:
|
|
38
|
+
# a file evicted to the cloud is not downloaded, and O_NONBLOCK keeps a named pipe from blocking the open.
|
|
39
|
+
descriptor = os.open(path, os.O_EVTONLY | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
40
|
+
try:
|
|
41
|
+
spelling = fcntl.fcntl(descriptor, fcntl.F_GETPATH, bytes(MACOS_MAXIMUM_PATH_BYTES))
|
|
42
|
+
finally:
|
|
43
|
+
os.close(descriptor)
|
|
44
|
+
return os.fsdecode(spelling.rstrip(b"\0"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _deepest_disk_spelling(resolved_path: str, disk_spelling: Callable[[str], str]) -> str:
|
|
48
|
+
"""Spell the deepest element of an absolute, link-free path that can be opened as on disk, and keep the rest as written.
|
|
49
|
+
|
|
50
|
+
When no element can be opened, the path is returned unchanged.
|
|
51
|
+
"""
|
|
52
|
+
existing_path = resolved_path
|
|
53
|
+
remaining_names: list[str] = []
|
|
54
|
+
while True:
|
|
55
|
+
try:
|
|
56
|
+
spelling = disk_spelling(existing_path)
|
|
57
|
+
except OSError:
|
|
58
|
+
parent_path = os.path.dirname(existing_path)
|
|
59
|
+
if parent_path == existing_path:
|
|
60
|
+
return resolved_path
|
|
61
|
+
remaining_names.append(os.path.basename(existing_path))
|
|
62
|
+
existing_path = parent_path
|
|
63
|
+
continue
|
|
64
|
+
for remaining_name in reversed(remaining_names):
|
|
65
|
+
spelling = os.path.join(spelling, remaining_name)
|
|
66
|
+
return spelling
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def on_disk_path(path: str, platform: str = sys.platform, disk_spelling: Callable[[str], str] = macos_disk_spelling) -> str:
|
|
70
|
+
"""Return the absolute path with links resolved, spelled as the disk spells it on macOS, as realpath spells it elsewhere.
|
|
71
|
+
|
|
72
|
+
On macOS a path whose end does not exist keeps that end as written after the disk spelling of its deepest existing
|
|
73
|
+
part; a part that cannot be read (permission refused) keeps its realpath spelling.
|
|
74
|
+
"""
|
|
75
|
+
if platform != MACOS_PLATFORM:
|
|
76
|
+
return os.path.realpath(path)
|
|
77
|
+
if path:
|
|
78
|
+
try:
|
|
79
|
+
return disk_spelling(path)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
return _deepest_disk_spelling(os.path.realpath(path), disk_spelling)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def canonical_path(path: str, platform: str = sys.platform, disk_spelling: Callable[[str], str] = macos_disk_spelling) -> str:
|
|
86
|
+
"""Return the key that identifies a folder or file, so separators, letter case, Unicode form and links cannot hide a difference."""
|
|
87
|
+
return os.path.normcase(on_disk_path(path, platform, disk_spelling))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def link_resolved_path(path: str) -> str:
|
|
91
|
+
"""Resolve links and keep the typed spelling, to tell whether a path goes through a link by comparing it with the typed path.
|
|
92
|
+
|
|
93
|
+
Only for that check: it is not an identity, since on macOS another letter case or Unicode form stays different.
|
|
94
|
+
"""
|
|
95
|
+
return os.path.normcase(os.path.realpath(path))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Remove a folder tree that GitPane created for its own private files, read-only entries included.
|
|
2
|
+
|
|
3
|
+
Git writes its object files read-only. On Linux and macOS that does not stop their removal, which only needs a
|
|
4
|
+
writable parent folder, but on Windows deleting a read-only file (or folder) fails with "Access is denied". This
|
|
5
|
+
module only changes how a removal already decided by its caller is carried out: it removes exactly what
|
|
6
|
+
``shutil.rmtree`` removes, keeps its handling of links and junctions (the link itself is removed, never what it
|
|
7
|
+
points to), and must never be given a user's worktree or submodule.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_link(information: os.stat_result) -> bool:
|
|
22
|
+
"""Tell whether an entry is a symbolic link or a Windows reparse point (junction), which is never made writable."""
|
|
23
|
+
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
24
|
+
return stat.S_ISLNK(information.st_mode) or bool(getattr(information, "st_file_attributes", 0) & reparse_point)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _retry_read_only_entry(function: Callable[[str], object], path: str, error: BaseException) -> None:
|
|
28
|
+
"""Make one read-only entry writable, then remove it again; any other failure is raised unchanged."""
|
|
29
|
+
if not isinstance(error, PermissionError) or function not in (os.unlink, os.remove, os.rmdir):
|
|
30
|
+
raise error
|
|
31
|
+
try:
|
|
32
|
+
information = os.lstat(path)
|
|
33
|
+
except OSError:
|
|
34
|
+
raise error from None
|
|
35
|
+
if _is_link(information) or information.st_mode & stat.S_IWRITE:
|
|
36
|
+
raise error
|
|
37
|
+
# Workaround: Windows refuses to delete an entry that has the read-only attribute, which Git puts on every
|
|
38
|
+
# object file it writes. Clearing that attribute on this one entry (never on a link, checked just above) lets
|
|
39
|
+
# the removal that rmtree already decided go through; on Linux and macOS this point is not reached for Git's
|
|
40
|
+
# read-only files, since their removal only depends on the parent folder.
|
|
41
|
+
os.chmod(path, stat.S_IMODE(information.st_mode) | stat.S_IWRITE)
|
|
42
|
+
function(path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def remove_private_tree(directory: str) -> None:
|
|
46
|
+
"""Remove a private folder of GitPane and everything inside it, including files Git made read-only."""
|
|
47
|
+
shutil.rmtree(directory, onexc=_retry_read_only_entry)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Name the program a process of this session runs, as /proc tells it.
|
|
2
|
+
|
|
3
|
+
The name is first the base name of the target of ``/proc/<pid>/exe``. When the executable file was removed while the
|
|
4
|
+
process runs, as a package update does, the kernel appends `` (deleted)`` to the link's target (proc(5)), and that
|
|
5
|
+
suffix is taken off before the base name. The kernel refuses to read that link for a process that is not "dumpable",
|
|
6
|
+
even one of the same user: a program started with file capabilities (seen with ``kwin_wayland``, which carries
|
|
7
|
+
``cap_sys_nice``) or one that calls ``prctl(PR_SET_DUMPABLE, 0)`` (KeePassXC). For such a refusal the name is read from ``/proc/<pid>/comm`` instead, which
|
|
8
|
+
the kernel lets any user read, even for a process of another user whose executable link it refuses; it is cut to 15
|
|
9
|
+
characters, so the table of known programs also holds the cut names. ``comm`` can be changed by the program itself,
|
|
10
|
+
but so can the owner of the Secret Service name, which any program of the user can take: the link gives no stronger
|
|
11
|
+
guarantee.
|
|
12
|
+
``cmdline`` is never read. A process that ended meanwhile, or a name that cannot be read at all, gives an empty name.
|
|
13
|
+
Only Linux has these files; elsewhere they are missing and the name is empty.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import posixpath
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from gitpane.config import PROC_DIR
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
PROC_EXECUTABLE_BASENAME = "exe"
|
|
28
|
+
# The file of /proc/<pid>/ that holds the program's command name, cut to 15 characters by the kernel.
|
|
29
|
+
PROC_COMMAND_NAME_BASENAME = "comm"
|
|
30
|
+
# What the kernel appends to the target of an executable link once the executable file was removed (proc(5)).
|
|
31
|
+
DELETED_EXECUTABLE_SUFFIX = " (deleted)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def program_name_from_executable_link(link_text: str) -> str:
|
|
35
|
+
"""Return the base name of the program an executable link names, without the suffix the kernel adds after a package update."""
|
|
36
|
+
return posixpath.basename(link_text.removesuffix(DELETED_EXECUTABLE_SUFFIX))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_command_name_file(filename: str) -> str:
|
|
40
|
+
"""Return the text of a /proc command name file; the kernel writes it as bytes, so a byte that is not UTF-8 is replaced rather than refused."""
|
|
41
|
+
with open(filename, encoding="utf-8", errors="replace") as command_name_file:
|
|
42
|
+
return command_name_file.read()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def program_name_of_process(pid: int, read_link: Callable[[str], str] = os.readlink, read_text: Callable[[str], str] = read_command_name_file) -> str:
|
|
46
|
+
"""Return the name of the program process ``pid`` runs, or an empty text when /proc does not tell it.
|
|
47
|
+
|
|
48
|
+
The executable link is read first; only a refusal to read it (a process that is not dumpable) falls back on the
|
|
49
|
+
command name, since any other error means the process is gone or /proc is missing. ``read_link`` and ``read_text``
|
|
50
|
+
read the files, so the tests can give any target, name or refusal without a real process.
|
|
51
|
+
"""
|
|
52
|
+
# The files are paths of Linux's /proc, written in POSIX form whatever system builds them, hence posixpath.
|
|
53
|
+
process_dir = posixpath.join(PROC_DIR, str(pid))
|
|
54
|
+
try:
|
|
55
|
+
return program_name_from_executable_link(read_link(posixpath.join(process_dir, PROC_EXECUTABLE_BASENAME)))
|
|
56
|
+
except PermissionError:
|
|
57
|
+
pass
|
|
58
|
+
except OSError:
|
|
59
|
+
return ""
|
|
60
|
+
try:
|
|
61
|
+
return read_text(posixpath.join(process_dir, PROC_COMMAND_NAME_BASENAME)).strip()
|
|
62
|
+
except OSError:
|
|
63
|
+
return ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Give a folder read from Qt the separator of the system it runs on.
|
|
2
|
+
|
|
3
|
+
Qt names the folders it knows (desktop, documents, application data) with ``/`` on every system, Windows included,
|
|
4
|
+
while Python's ``os.path.join`` adds the system's own separator: a path built from such a folder and shown to the user
|
|
5
|
+
would mix both on Windows. A folder read from Qt therefore goes through here where it enters the application, before
|
|
6
|
+
it is joined or shown.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ntpath
|
|
12
|
+
import posixpath
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QStandardPaths
|
|
15
|
+
|
|
16
|
+
from gitpane.config import WINDOWS_PLATFORM
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def native_folder_path(folder: str, platform: str) -> str:
|
|
20
|
+
"""Write ``folder`` with the separator ``platform`` writes its own paths with.
|
|
21
|
+
|
|
22
|
+
Qt names the folders it knows with ``/`` on every system, Windows included, while Windows writes its paths with
|
|
23
|
+
``\\``: the desktop folder as Qt gives it, joined to the shortcut's name, would show both separators in the line
|
|
24
|
+
Settings shows. Only the separators change, so the folder stays the same folder.
|
|
25
|
+
"""
|
|
26
|
+
if platform != WINDOWS_PLATFORM:
|
|
27
|
+
return folder
|
|
28
|
+
return folder.replace(posixpath.sep, ntpath.sep)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def writable_folder(location: QStandardPaths.StandardLocation, platform: str) -> str:
|
|
32
|
+
"""Answer the folder Qt names for ``location``, written with the separator ``platform`` writes its own paths with.
|
|
33
|
+
|
|
34
|
+
A path built on such a folder then shows one kind of separator: the diagnostic log path shown in the error dialogs
|
|
35
|
+
and in the terminal, the file name a save dialog suggests, and the private folders GitPane keeps its recovery data
|
|
36
|
+
in.
|
|
37
|
+
"""
|
|
38
|
+
return native_folder_path(QStandardPaths.writableLocation(location), platform)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Watch the ready channel of a GitPane started detached, so its standard streams leave the gitpane command's pipes as soon as that command is gone.
|
|
2
|
+
|
|
3
|
+
The gitpane command never writes on the channel: GitPane's end only becomes readable when the command's end closes,
|
|
4
|
+
which happens when the command ends before GitPane said it is ready (its wait ran out, Ctrl+C, the terminal was
|
|
5
|
+
closed). From then on nothing reads the pipes GitPane's standard streams point to, so they are moved to the null device
|
|
6
|
+
at once, without waiting for the first window.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QObject, QSocketNotifier
|
|
15
|
+
|
|
16
|
+
from gitpane.system.terminal_detach import notify_ready, redirect_standard_streams_to_null
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReadyChannelWatch(QObject):
|
|
23
|
+
"""Hold GitPane's end of the ready channel until GitPane is ready or the gitpane command is gone, whichever comes first.
|
|
24
|
+
|
|
25
|
+
``channel`` is the open descriptor while neither happened, then ``None``. The two functions it calls are given to
|
|
26
|
+
the constructor so tests can record them instead of touching the standard streams of their own process.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
channel: int,
|
|
32
|
+
parent: QObject | None = None,
|
|
33
|
+
*,
|
|
34
|
+
release_streams: Callable[[], None] = redirect_standard_streams_to_null,
|
|
35
|
+
send_ready_signal: Callable[[int, str], None] = notify_ready,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Start watching ``channel``, which becomes readable only when the command's end of it closes."""
|
|
38
|
+
super().__init__(parent)
|
|
39
|
+
self.channel: int | None = channel
|
|
40
|
+
self._release_streams = release_streams
|
|
41
|
+
self._send_ready_signal = send_ready_signal
|
|
42
|
+
self._notifier = QSocketNotifier(channel, QSocketNotifier.Type.Read, self)
|
|
43
|
+
self._notifier.activated.connect(self._release_after_command_left)
|
|
44
|
+
|
|
45
|
+
def _release_after_command_left(self, *_signal_arguments: object) -> None:
|
|
46
|
+
"""Move the standard streams off the command's pipes and close the channel, the command being gone."""
|
|
47
|
+
if self.channel is None:
|
|
48
|
+
return
|
|
49
|
+
self._notifier.setEnabled(False)
|
|
50
|
+
self._release_streams()
|
|
51
|
+
try:
|
|
52
|
+
os.close(self.channel)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
self.channel = None
|
|
56
|
+
|
|
57
|
+
def send_ready(self, log_filename: str) -> None:
|
|
58
|
+
"""Tell the waiting command that GitPane is ready, unless that command is already gone.
|
|
59
|
+
|
|
60
|
+
The notifier is turned off first, so it never watches a descriptor the ready signal has closed.
|
|
61
|
+
"""
|
|
62
|
+
if self.channel is None:
|
|
63
|
+
return
|
|
64
|
+
self._notifier.setEnabled(False)
|
|
65
|
+
channel = self.channel
|
|
66
|
+
self.channel = None
|
|
67
|
+
self._send_ready_signal(channel, log_filename)
|