mcpywrap-core 0.6.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.
- mcpywrap_core/__init__.py +2 -0
- mcpywrap_core/__main__.py +18 -0
- mcpywrap_core/_script_worker.py +278 -0
- mcpywrap_core/_version.py +1 -0
- mcpywrap_core/api.py +69 -0
- mcpywrap_core/builders/AddonsPack.py +310 -0
- mcpywrap_core/builders/MapPack.py +171 -0
- mcpywrap_core/builders/__init__.py +20 -0
- mcpywrap_core/builders/assembly.py +100 -0
- mcpywrap_core/builders/dependency_manager.py +239 -0
- mcpywrap_core/builders/file_merge.py +260 -0
- mcpywrap_core/builders/project_builder.py +182 -0
- mcpywrap_core/builders/watcher.py +162 -0
- mcpywrap_core/cli.py +75 -0
- mcpywrap_core/code_libraries.py +201 -0
- mcpywrap_core/command_context.py +242 -0
- mcpywrap_core/commands/__init__.py +1 -0
- mcpywrap_core/commands/add_cmd.py +76 -0
- mcpywrap_core/commands/build_cmd.py +15 -0
- mcpywrap_core/commands/connect_cmd.py +46 -0
- mcpywrap_core/commands/default_cmd.py +14 -0
- mcpywrap_core/commands/dependency_prompt.py +45 -0
- mcpywrap_core/commands/deployment_cmd.py +115 -0
- mcpywrap_core/commands/deployment_output.py +289 -0
- mcpywrap_core/commands/desktop_cmd.py +104 -0
- mcpywrap_core/commands/dev_cmd.py +108 -0
- mcpywrap_core/commands/doctor_cmd.py +32 -0
- mcpywrap_core/commands/edit_cmd.py +109 -0
- mcpywrap_core/commands/engine_cmd.py +90 -0
- mcpywrap_core/commands/init_cmd.py +48 -0
- mcpywrap_core/commands/mod_cmd.py +53 -0
- mcpywrap_core/commands/modsdk_cmd.py +41 -0
- mcpywrap_core/commands/package_cmd.py +16 -0
- mcpywrap_core/commands/publish_cmd.py +51 -0
- mcpywrap_core/commands/record_cmd.py +85 -0
- mcpywrap_core/commands/remove_cmd.py +62 -0
- mcpywrap_core/commands/run_cmd.py +169 -0
- mcpywrap_core/commands/runtime_cmd.py +150 -0
- mcpywrap_core/commands/runtime_input_cmd.py +86 -0
- mcpywrap_core/commands/runtime_player_cmd.py +192 -0
- mcpywrap_core/commands/runtime_ui_cmd.py +121 -0
- mcpywrap_core/commands/script_cmd.py +54 -0
- mcpywrap_core/commands/serve_cmd.py +39 -0
- mcpywrap_core/commands/session_cmd.py +95 -0
- mcpywrap_core/commands/sync_cmd.py +29 -0
- mcpywrap_core/commands/ui_cmd.py +36 -0
- mcpywrap_core/config.py +254 -0
- mcpywrap_core/consumer_prepare.py +471 -0
- mcpywrap_core/core-contract.json +6 -0
- mcpywrap_core/custom_packaging.py +41 -0
- mcpywrap_core/dependencies.py +326 -0
- mcpywrap_core/engines/__init__.py +1 -0
- mcpywrap_core/engines/android_icon.py +186 -0
- mcpywrap_core/engines/apk_profile.py +90 -0
- mcpywrap_core/engines/backend.py +134 -0
- mcpywrap_core/engines/host.py +32 -0
- mcpywrap_core/engines/install.py +665 -0
- mcpywrap_core/engines/macos.py +483 -0
- mcpywrap_core/engines/macos_icons.py +159 -0
- mcpywrap_core/engines/macos_network.py +58 -0
- mcpywrap_core/engines/official.py +129 -0
- mcpywrap_core/engines/preferences.py +146 -0
- mcpywrap_core/engines/tui.py +110 -0
- mcpywrap_core/engines/windows.py +144 -0
- mcpywrap_core/framework_presets.py +22 -0
- mcpywrap_core/frameworks.py +77 -0
- mcpywrap_core/git_cache.py +154 -0
- mcpywrap_core/git_projects.py +266 -0
- mcpywrap_core/host.py +93 -0
- mcpywrap_core/host_assets.py +14 -0
- mcpywrap_core/input_executor.py +246 -0
- mcpywrap_core/input_plan.py +257 -0
- mcpywrap_core/mcstudio/SimpleMonitor.py +51 -0
- mcpywrap_core/mcstudio/__init__.py +1 -0
- mcpywrap_core/mcstudio/auth_interaction.py +88 -0
- mcpywrap_core/mcstudio/bridge_assets.py +85 -0
- mcpywrap_core/mcstudio/deployment_links.py +670 -0
- mcpywrap_core/mcstudio/diagnostics.py +21 -0
- mcpywrap_core/mcstudio/discovery.py +337 -0
- mcpywrap_core/mcstudio/editor.py +81 -0
- mcpywrap_core/mcstudio/file_logs.py +66 -0
- mcpywrap_core/mcstudio/game.py +256 -0
- mcpywrap_core/mcstudio/host_input.py +252 -0
- mcpywrap_core/mcstudio/hot_reload.py +218 -0
- mcpywrap_core/mcstudio/image.py +20 -0
- mcpywrap_core/mcstudio/input_sequence.py +204 -0
- mcpywrap_core/mcstudio/launcher_control.py +56 -0
- mcpywrap_core/mcstudio/launcher_python.py +134 -0
- mcpywrap_core/mcstudio/log_colorizer.py +252 -0
- mcpywrap_core/mcstudio/log_protocol.py +74 -0
- mcpywrap_core/mcstudio/log_stream.py +136 -0
- mcpywrap_core/mcstudio/mcs.py +149 -0
- mcpywrap_core/mcstudio/mcs_auth.py +196 -0
- mcpywrap_core/mcstudio/network.py +187 -0
- mcpywrap_core/mcstudio/private_logs.py +70 -0
- mcpywrap_core/mcstudio/processes.py +41 -0
- mcpywrap_core/mcstudio/recording_worker.py +154 -0
- mcpywrap_core/mcstudio/recordings.py +351 -0
- mcpywrap_core/mcstudio/runtime_cppconfig.py +249 -0
- mcpywrap_core/mcstudio/runtime_debug.py +437 -0
- mcpywrap_core/mcstudio/runtime_input_payload.py +277 -0
- mcpywrap_core/mcstudio/runtime_key_timeline_payload.py +229 -0
- mcpywrap_core/mcstudio/runtime_player_payload.py +713 -0
- mcpywrap_core/mcstudio/runtime_ui.py +212 -0
- mcpywrap_core/mcstudio/runtime_ui_outline.py +424 -0
- mcpywrap_core/mcstudio/runtime_ui_payload.py +560 -0
- mcpywrap_core/mcstudio/screenshot.py +94 -0
- mcpywrap_core/mcstudio/session_capabilities.py +54 -0
- mcpywrap_core/mcstudio/session_worker.py +177 -0
- mcpywrap_core/mcstudio/sessions.py +285 -0
- mcpywrap_core/mcstudio/studio_server.py +363 -0
- mcpywrap_core/mcstudio/studio_server_ui.py +126 -0
- mcpywrap_core/mcstudio/symlink_helper_global.py +75 -0
- mcpywrap_core/mcstudio/symlink_helper_map.py +114 -0
- mcpywrap_core/mcstudio/symlinks.py +534 -0
- mcpywrap_core/mcstudio/unified_input.py +39 -0
- mcpywrap_core/mcstudio/window.py +755 -0
- mcpywrap_core/minecraft/__init__.py +1 -0
- mcpywrap_core/minecraft/addons.py +148 -0
- mcpywrap_core/minecraft/level_dat.py +958 -0
- mcpywrap_core/minecraft/map.py +85 -0
- mcpywrap_core/minecraft/modsdk_environment.py +197 -0
- mcpywrap_core/minecraft/netease_modsdk.py +269 -0
- mcpywrap_core/minecraft/template/generate_mod_files.py +116 -0
- mcpywrap_core/minecraft/template/mod_template.py +494 -0
- mcpywrap_core/operations/__init__.py +1 -0
- mcpywrap_core/operations/build.py +66 -0
- mcpywrap_core/operations/package.py +97 -0
- mcpywrap_core/operations/sync.py +82 -0
- mcpywrap_core/prepare_environment.py +149 -0
- mcpywrap_core/project_dependencies.py +164 -0
- mcpywrap_core/project_init.py +73 -0
- mcpywrap_core/project_scripts.py +369 -0
- mcpywrap_core/recording_io.py +122 -0
- mcpywrap_core/remote/__init__.py +1 -0
- mcpywrap_core/remote/client.py +271 -0
- mcpywrap_core/remote/http_server.py +205 -0
- mcpywrap_core/remote/protocol.py +22 -0
- mcpywrap_core/remote/service.py +317 -0
- mcpywrap_core/runtime/__init__.py +1 -0
- mcpywrap_core/runtime/deployment.py +501 -0
- mcpywrap_core/runtime/deployment_inspection.py +645 -0
- mcpywrap_core/runtime/deployment_service.py +688 -0
- mcpywrap_core/runtime/diagnostics.py +74 -0
- mcpywrap_core/runtime/groups.py +666 -0
- mcpywrap_core/runtime/instances.py +147 -0
- mcpywrap_core/runtime/native.py +64 -0
- mcpywrap_core/runtime/occupancy.py +78 -0
- mcpywrap_core/runtime/preparation.py +97 -0
- mcpywrap_core/runtime/profiles.py +183 -0
- mcpywrap_core/runtime/storage.py +42 -0
- mcpywrap_core/runtime/targets.py +24 -0
- mcpywrap_core/runtime/watch.py +158 -0
- mcpywrap_core/runtime/windows.py +184 -0
- mcpywrap_core/source_files.py +82 -0
- mcpywrap_core/timeline.py +26 -0
- mcpywrap_core/ui/__init__.py +5 -0
- mcpywrap_core/ui/application.py +58 -0
- mcpywrap_core/ui/assets/mcpy.icns +0 -0
- mcpywrap_core/ui/assets/mcpy.icon/Assets/01-cube.svg +5 -0
- mcpywrap_core/ui/assets/mcpy.icon/Assets/02-prompt.svg +4 -0
- mcpywrap_core/ui/assets/mcpy.icon/icon.json +56 -0
- mcpywrap_core/ui/assets/mcpy.png +0 -0
- mcpywrap_core/ui/engine_setup.py +119 -0
- mcpywrap_core/ui/log_view.py +201 -0
- mcpywrap_core/ui/log_window.py +147 -0
- mcpywrap_core/ui/project_ui.py +963 -0
- mcpywrap_core/ui/run_session.py +89 -0
- mcpywrap_core/ui/session_controller.py +416 -0
- mcpywrap_core/ui/session_tools.py +76 -0
- mcpywrap_core/ui/session_views.py +236 -0
- mcpywrap_core/ui/world_config.py +112 -0
- mcpywrap_core/utils/__init__.py +6 -0
- mcpywrap_core/utils/console.py +13 -0
- mcpywrap_core/utils/link_snapshot.py +131 -0
- mcpywrap_core/utils/locks.py +85 -0
- mcpywrap_core/utils/paths.py +18 -0
- mcpywrap_core/utils/pip_error_parser.py +184 -0
- mcpywrap_core/utils/print_guide.py +30 -0
- mcpywrap_core/utils/project_setup.py +148 -0
- mcpywrap_core/utils/utils.py +38 -0
- mcpywrap_core-0.6.0.dist-info/METADATA +33 -0
- mcpywrap_core-0.6.0.dist-info/RECORD +189 -0
- mcpywrap_core-0.6.0.dist-info/WHEEL +5 -0
- mcpywrap_core-0.6.0.dist-info/licenses/LICENSE +21 -0
- mcpywrap_core-0.6.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +35 -0
- mcpywrap_core-0.6.0.dist-info/top_level.txt +2 -0
- mcpywrap_host/__init__.py +9 -0
- mcpywrap_host/store.py +276 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
mcpywrap 命令行工具的入口点
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .cli import cli
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
"""主函数,作为 CLI 入口点"""
|
|
10
|
+
import sys
|
|
11
|
+
# Keep the caller's text encoding; unsupported symbols must not abort an operation.
|
|
12
|
+
for stream in (sys.stdout, sys.stderr):
|
|
13
|
+
if hasattr(stream, 'reconfigure'):
|
|
14
|
+
stream.reconfigure(errors='backslashreplace')
|
|
15
|
+
cli()
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Private finite-script supervisor. Invoked by absolute path; never a public CLI."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def identity(pid):
|
|
14
|
+
p = psutil.Process(pid)
|
|
15
|
+
return {'pid': pid, 'created_at': p.create_time(), 'executable': p.exe()}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def alive(record):
|
|
19
|
+
try:
|
|
20
|
+
p = psutil.Process(record['pid'])
|
|
21
|
+
return abs(p.create_time() - record['created_at']) < .01 and p.status() != psutil.STATUS_ZOMBIE
|
|
22
|
+
except psutil.NoSuchProcess:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save(path, data):
|
|
27
|
+
temporary = path.with_suffix('.writing')
|
|
28
|
+
temporary.write_text(json.dumps(data, ensure_ascii=True), encoding='utf-8')
|
|
29
|
+
for attempt in range(10):
|
|
30
|
+
try:
|
|
31
|
+
temporary.replace(path)
|
|
32
|
+
return
|
|
33
|
+
except PermissionError:
|
|
34
|
+
if os.name != 'nt' or attempt == 9: raise
|
|
35
|
+
time.sleep(.02)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WindowsJob:
|
|
39
|
+
"""Closing the supervisor's handle terminates its assigned command tree."""
|
|
40
|
+
def __init__(self):
|
|
41
|
+
import ctypes as c
|
|
42
|
+
from ctypes import wintypes as w
|
|
43
|
+
class Basic(c.Structure):
|
|
44
|
+
_fields_ = [('PerProcessUserTimeLimit', c.c_longlong), ('PerJobUserTimeLimit', c.c_longlong),
|
|
45
|
+
('LimitFlags', w.DWORD), ('MinimumWorkingSetSize', c.c_size_t),
|
|
46
|
+
('MaximumWorkingSetSize', c.c_size_t), ('ActiveProcessLimit', w.DWORD),
|
|
47
|
+
('Affinity', c.c_size_t), ('PriorityClass', w.DWORD), ('SchedulingClass', w.DWORD)]
|
|
48
|
+
class IO(c.Structure):
|
|
49
|
+
_fields_ = [(name, c.c_ulonglong) for name in ('ReadOperationCount', 'WriteOperationCount',
|
|
50
|
+
'OtherOperationCount', 'ReadTransferCount', 'WriteTransferCount', 'OtherTransferCount')]
|
|
51
|
+
class Extended(c.Structure):
|
|
52
|
+
_fields_ = [('BasicLimitInformation', Basic), ('IoInfo', IO),
|
|
53
|
+
('ProcessMemoryLimit', c.c_size_t), ('JobMemoryLimit', c.c_size_t),
|
|
54
|
+
('PeakProcessMemoryUsed', c.c_size_t), ('PeakJobMemoryUsed', c.c_size_t)]
|
|
55
|
+
self.kernel = c.WinDLL('kernel32', use_last_error=True)
|
|
56
|
+
self.kernel.CreateJobObjectW.argtypes = [c.c_void_p, w.LPCWSTR]
|
|
57
|
+
self.kernel.CreateJobObjectW.restype = w.HANDLE
|
|
58
|
+
self.kernel.SetInformationJobObject.argtypes = [w.HANDLE, c.c_int, c.c_void_p, w.DWORD]
|
|
59
|
+
self.kernel.AssignProcessToJobObject.argtypes = [w.HANDLE, w.HANDLE]
|
|
60
|
+
self.kernel.QueryInformationJobObject.argtypes = [w.HANDLE, c.c_int, c.c_void_p, w.DWORD, c.c_void_p]
|
|
61
|
+
self.kernel.TerminateJobObject.argtypes = [w.HANDLE, w.UINT]
|
|
62
|
+
self.kernel.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]
|
|
63
|
+
self.kernel.OpenProcess.restype = w.HANDLE
|
|
64
|
+
self.kernel.IsProcessInJob.argtypes = [w.HANDLE, w.HANDLE, c.POINTER(w.BOOL)]
|
|
65
|
+
self.kernel.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]
|
|
66
|
+
self.kernel.WaitForSingleObject.restype = w.DWORD
|
|
67
|
+
self.kernel.CloseHandle.argtypes = [w.HANDLE]
|
|
68
|
+
self.kernel.OpenThread.argtypes = [w.DWORD, w.BOOL, w.DWORD]
|
|
69
|
+
self.kernel.OpenThread.restype = w.HANDLE
|
|
70
|
+
self.kernel.ResumeThread.argtypes = [w.HANDLE]
|
|
71
|
+
self.kernel.ResumeThread.restype = w.DWORD
|
|
72
|
+
self.handle = self.kernel.CreateJobObjectW(None, None)
|
|
73
|
+
if not self.handle: raise c.WinError(c.get_last_error())
|
|
74
|
+
info = Extended(); info.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
|
|
75
|
+
if not self.kernel.SetInformationJobObject(self.handle, 9, c.byref(info), c.sizeof(info)):
|
|
76
|
+
error = c.WinError(c.get_last_error()); self.close(); raise error
|
|
77
|
+
|
|
78
|
+
def assign(self, process):
|
|
79
|
+
import ctypes as c
|
|
80
|
+
if not self.kernel.AssignProcessToJobObject(self.handle, int(process._handle)):
|
|
81
|
+
raise c.WinError(c.get_last_error())
|
|
82
|
+
# The command was created suspended: no child can escape before job assignment.
|
|
83
|
+
threads = psutil.Process(process.pid).threads()
|
|
84
|
+
if not threads:
|
|
85
|
+
raise RuntimeError('脚本主线程不存在')
|
|
86
|
+
thread = self.kernel.OpenThread(0x0002, False, threads[0].id)
|
|
87
|
+
if not thread:
|
|
88
|
+
raise c.WinError(c.get_last_error())
|
|
89
|
+
try:
|
|
90
|
+
if self.kernel.ResumeThread(thread) == 0xffffffff:
|
|
91
|
+
raise c.WinError(c.get_last_error())
|
|
92
|
+
finally:
|
|
93
|
+
self.kernel.CloseHandle(thread)
|
|
94
|
+
|
|
95
|
+
def close(self):
|
|
96
|
+
if self.handle:
|
|
97
|
+
self.kernel.CloseHandle(self.handle)
|
|
98
|
+
self.handle = None
|
|
99
|
+
|
|
100
|
+
def process_ids(self):
|
|
101
|
+
import ctypes as c
|
|
102
|
+
from ctypes import wintypes as w
|
|
103
|
+
capacity = 16
|
|
104
|
+
for _ in range(8):
|
|
105
|
+
class ProcessIds(c.Structure):
|
|
106
|
+
_fields_ = [('assigned', w.DWORD), ('listed', w.DWORD),
|
|
107
|
+
('ids', c.c_size_t * capacity)]
|
|
108
|
+
info = ProcessIds()
|
|
109
|
+
# JobObjectBasicProcessIdList retains descendants after their
|
|
110
|
+
# parent exits. A concurrent spawn may require a larger buffer.
|
|
111
|
+
if self.kernel.QueryInformationJobObject(self.handle, 3, c.byref(info), c.sizeof(info), None):
|
|
112
|
+
return list(info.ids[:info.listed])
|
|
113
|
+
error = c.get_last_error()
|
|
114
|
+
if error != 234: # ERROR_MORE_DATA
|
|
115
|
+
raise c.WinError(error)
|
|
116
|
+
capacity = max(capacity * 2, info.assigned)
|
|
117
|
+
raise RuntimeError('脚本进程树持续增长,无法确认成员')
|
|
118
|
+
|
|
119
|
+
def active_count(self):
|
|
120
|
+
import ctypes as c
|
|
121
|
+
from ctypes import wintypes as w
|
|
122
|
+
count = 0
|
|
123
|
+
for pid in self.process_ids():
|
|
124
|
+
handle = self.kernel.OpenProcess(0x00100000 | 0x1000, False, pid) # SYNCHRONIZE | QUERY_LIMITED_INFORMATION
|
|
125
|
+
if not handle:
|
|
126
|
+
error = c.get_last_error()
|
|
127
|
+
if error == 87: # ERROR_INVALID_PARAMETER: process already gone
|
|
128
|
+
continue
|
|
129
|
+
raise c.WinError(error)
|
|
130
|
+
try:
|
|
131
|
+
belongs = w.BOOL()
|
|
132
|
+
if not self.kernel.IsProcessInJob(handle, self.handle, c.byref(belongs)):
|
|
133
|
+
raise c.WinError(c.get_last_error())
|
|
134
|
+
if not belongs.value:
|
|
135
|
+
continue # The PID may have been reused outside this job.
|
|
136
|
+
state = self.kernel.WaitForSingleObject(handle, 0)
|
|
137
|
+
if state == 258: # WAIT_TIMEOUT: process is still running
|
|
138
|
+
count += 1
|
|
139
|
+
elif state != 0: # WAIT_OBJECT_0: process has exited
|
|
140
|
+
raise c.WinError(c.get_last_error())
|
|
141
|
+
finally:
|
|
142
|
+
self.kernel.CloseHandle(handle)
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
def terminate(self):
|
|
146
|
+
import ctypes as c
|
|
147
|
+
if not self.kernel.TerminateJobObject(self.handle, 1):
|
|
148
|
+
raise c.WinError(c.get_last_error())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def members(process, known):
|
|
152
|
+
try:
|
|
153
|
+
for p in psutil.Process(process.pid).children(recursive=True):
|
|
154
|
+
known[p.pid] = identity(p.pid)
|
|
155
|
+
except psutil.NoSuchProcess:
|
|
156
|
+
pass
|
|
157
|
+
if os.name != 'nt':
|
|
158
|
+
for p in psutil.process_iter(['pid', 'status']):
|
|
159
|
+
try:
|
|
160
|
+
if p.info['status'] != psutil.STATUS_ZOMBIE and os.getpgid(p.pid) == process.pid:
|
|
161
|
+
known[p.pid] = identity(p.pid)
|
|
162
|
+
except (ProcessLookupError, PermissionError, psutil.NoSuchProcess):
|
|
163
|
+
pass
|
|
164
|
+
return [r for r in known.values() if alive(r)]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def finish_tree(process, known, job):
|
|
168
|
+
if job:
|
|
169
|
+
# Keep the job handle until exit is verified, including descendants
|
|
170
|
+
# never observed by psutil. Closing it alone only requests termination.
|
|
171
|
+
job.terminate()
|
|
172
|
+
if process.poll() is None:
|
|
173
|
+
process.kill() # Also handles failure to assign a suspended child.
|
|
174
|
+
process.wait(timeout=3)
|
|
175
|
+
deadline = time.monotonic() + 2
|
|
176
|
+
while job.active_count():
|
|
177
|
+
if time.monotonic() >= deadline:
|
|
178
|
+
raise RuntimeError('脚本后代进程尚未确认退出')
|
|
179
|
+
time.sleep(.05)
|
|
180
|
+
return
|
|
181
|
+
if os.name != 'nt':
|
|
182
|
+
try: os.killpg(process.pid, signal.SIGTERM)
|
|
183
|
+
except ProcessLookupError: pass
|
|
184
|
+
pending = members(process, known)
|
|
185
|
+
for record in pending:
|
|
186
|
+
try: psutil.Process(record['pid']).terminate()
|
|
187
|
+
except psutil.NoSuchProcess: pass
|
|
188
|
+
deadline = time.monotonic() + 2
|
|
189
|
+
while time.monotonic() < deadline and any(alive(r) for r in pending):
|
|
190
|
+
process.poll()
|
|
191
|
+
time.sleep(.05)
|
|
192
|
+
remaining = members(process, known)
|
|
193
|
+
if not remaining:
|
|
194
|
+
process.wait(timeout=3)
|
|
195
|
+
return
|
|
196
|
+
if os.name != 'nt':
|
|
197
|
+
try: os.killpg(process.pid, signal.SIGKILL)
|
|
198
|
+
except ProcessLookupError: pass
|
|
199
|
+
for record in remaining:
|
|
200
|
+
if alive(record):
|
|
201
|
+
try: psutil.Process(record['pid']).kill()
|
|
202
|
+
except psutil.NoSuchProcess: pass
|
|
203
|
+
process.wait(timeout=3)
|
|
204
|
+
deadline = time.monotonic() + 2
|
|
205
|
+
while time.monotonic() < deadline:
|
|
206
|
+
if not any(alive(r) for r in known.values()): return
|
|
207
|
+
time.sleep(.05)
|
|
208
|
+
raise RuntimeError('脚本后代进程尚未确认退出')
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
spec = json.loads(sys.stdin.buffer.read())
|
|
213
|
+
path = Path(spec.pop('result'))
|
|
214
|
+
command, timeout = spec.pop('command'), spec.pop('timeout')
|
|
215
|
+
cancel, parent = Path(spec.pop('cancel')), spec.pop('parent')
|
|
216
|
+
state = dict(spec, supervisor=identity(os.getpid()), state='starting')
|
|
217
|
+
process, job, known = None, None, {}
|
|
218
|
+
started = time.monotonic()
|
|
219
|
+
try:
|
|
220
|
+
save(path, state)
|
|
221
|
+
if not alive(parent) or cancel.exists():
|
|
222
|
+
state.update(state='cancelled', code='script_cancelled')
|
|
223
|
+
return
|
|
224
|
+
if os.name == 'nt': job = WindowsJob()
|
|
225
|
+
state['state'] = 'spawning'; save(path, state)
|
|
226
|
+
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
227
|
+
**({'creationflags': subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000004}
|
|
228
|
+
if os.name == 'nt' else {'start_new_session': True}))
|
|
229
|
+
try:
|
|
230
|
+
child = identity(process.pid)
|
|
231
|
+
except psutil.ZombieProcess:
|
|
232
|
+
child = {'pid': process.pid, 'created_at': psutil.Process(process.pid).create_time(), 'executable': command[0]}
|
|
233
|
+
state.update(child=child, group=process.pid if os.name != 'nt' else None, state='running')
|
|
234
|
+
known[process.pid] = child
|
|
235
|
+
save(path, state)
|
|
236
|
+
if job: job.assign(process)
|
|
237
|
+
while process.poll() is None:
|
|
238
|
+
if not job:
|
|
239
|
+
members(process, known)
|
|
240
|
+
if cancel.exists() or not alive(parent):
|
|
241
|
+
state.update(state='cancelled', code='script_cancelled', error='操作已取消或调用进程已退出')
|
|
242
|
+
break
|
|
243
|
+
if time.monotonic() - started >= timeout:
|
|
244
|
+
state.update(state='timed_out', code='script_timeout', error='执行超时')
|
|
245
|
+
break
|
|
246
|
+
time.sleep(.05)
|
|
247
|
+
else:
|
|
248
|
+
# Windows 的 Job 是所有权依据。父进程退出后再按 PID 查询树,
|
|
249
|
+
# 可能观察到复用该 PID 的无关进程;同时给短暂的退出收尾留出时间。
|
|
250
|
+
descendants = [] if job else [r for r in members(process, known) if r['pid'] != process.pid]
|
|
251
|
+
remaining = job.active_count() if job else 0
|
|
252
|
+
settle_deadline = time.monotonic() + .2
|
|
253
|
+
while remaining and time.monotonic() < settle_deadline:
|
|
254
|
+
time.sleep(.01)
|
|
255
|
+
remaining = job.active_count()
|
|
256
|
+
state.update(state='completed' if process.returncode == 0 else 'failed',
|
|
257
|
+
code=None if process.returncode == 0 else 'script_failed', exit_code=process.returncode)
|
|
258
|
+
if descendants or remaining:
|
|
259
|
+
state.update(state='failed', code='script_background_process', error='有限任务不能遗留后台进程')
|
|
260
|
+
except BaseException as exc:
|
|
261
|
+
state.update(state='failed', code='script_start_failed', error=str(exc))
|
|
262
|
+
finally:
|
|
263
|
+
if process:
|
|
264
|
+
try:
|
|
265
|
+
finish_tree(process, known, job)
|
|
266
|
+
except BaseException as exc:
|
|
267
|
+
import traceback
|
|
268
|
+
traceback.print_exc()
|
|
269
|
+
state.update(state='recovery_required', code='script_recovery_required', error=str(exc))
|
|
270
|
+
state.setdefault('exit_code', process.returncode)
|
|
271
|
+
if job: job.close()
|
|
272
|
+
state['duration'] = time.monotonic() - started
|
|
273
|
+
state['finished_at'] = time.time()
|
|
274
|
+
save(path, state)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == '__main__':
|
|
278
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
mcpywrap_core/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Versioned engineering API. All frontends call the same operation functions."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .host import HOST_API, HostServices, host_scope, current_host
|
|
6
|
+
|
|
7
|
+
CORE_API = 1
|
|
8
|
+
OPERATIONS = ('resolve', 'build', 'package', 'sync')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Core:
|
|
12
|
+
def __init__(self, host=None):
|
|
13
|
+
self.host = host or current_host()
|
|
14
|
+
|
|
15
|
+
def describe(self):
|
|
16
|
+
return {'core_api': CORE_API, 'host_api': HOST_API, 'version': __version__,
|
|
17
|
+
'operations': list(OPERATIONS), 'capabilities': self.host.capabilities()}
|
|
18
|
+
|
|
19
|
+
def execute(self, operation, project, **options):
|
|
20
|
+
"""Execute or raise the original structured error; never invoke another core."""
|
|
21
|
+
if operation not in OPERATIONS:
|
|
22
|
+
raise ValueError('未知工程操作:' + str(operation))
|
|
23
|
+
allowed = {'resolve': set(), 'build': {'merge'}, 'package': {'merge'},
|
|
24
|
+
'sync': {'install', 'migrate_windows_lock'}}[operation]
|
|
25
|
+
if set(options) - allowed or any(type(value) is not bool for value in options.values()):
|
|
26
|
+
raise ValueError('无效的工程操作参数')
|
|
27
|
+
project = Path(project).expanduser().resolve()
|
|
28
|
+
if not project.is_dir() or not (project/'pyproject.toml').is_file():
|
|
29
|
+
raise ValueError('未找到项目 pyproject.toml:' + str(project))
|
|
30
|
+
from .command_context import project_scope
|
|
31
|
+
with host_scope(self.host), project_scope(project) as context:
|
|
32
|
+
if operation == 'resolve':
|
|
33
|
+
from .dependencies import DependencyService
|
|
34
|
+
manager = DependencyService(project).resolve()
|
|
35
|
+
result = {'project': str(project), 'dependencies': [
|
|
36
|
+
{'name': pack.pkg_name, 'path': str(pack.path)}
|
|
37
|
+
for pack in manager.get_all_dependencies().values()], 'warnings': manager.warnings}
|
|
38
|
+
elif operation == 'build':
|
|
39
|
+
from .operations.build import build_project
|
|
40
|
+
result = build_project(project, **options)
|
|
41
|
+
elif operation == 'package':
|
|
42
|
+
from .operations.package import package_project
|
|
43
|
+
result = package_project(project, **options)
|
|
44
|
+
else:
|
|
45
|
+
from .operations.sync import sync_project
|
|
46
|
+
result = sync_project(project, **options)
|
|
47
|
+
if context.warnings:
|
|
48
|
+
result['warnings'] = list(dict.fromkeys([*result.get('warnings', []), *context.warnings]))
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def request(self, request):
|
|
52
|
+
"""Transport-neutral envelope. Authentication/path grants belong to the host.
|
|
53
|
+
|
|
54
|
+
The host resolves an authorized project ID to a path before calling this
|
|
55
|
+
method. Do not expose raw caller-supplied paths on an unauthenticated port.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
if not isinstance(request, dict) or request.get('core_api') != CORE_API:
|
|
59
|
+
raise ValueError('不兼容的核心协议')
|
|
60
|
+
if request.get('operation') == 'describe':
|
|
61
|
+
return {'ok': True, 'result': self.describe()}
|
|
62
|
+
options = request.get('options', {})
|
|
63
|
+
if not isinstance(options, dict) or not isinstance(request.get('project'), str):
|
|
64
|
+
raise ValueError('无效的工程请求')
|
|
65
|
+
result = self.execute(request.get('operation'), request['project'], **options)
|
|
66
|
+
return {'ok': True, 'result': result, 'core': self.describe()}
|
|
67
|
+
except (ValueError, OSError) as error:
|
|
68
|
+
return {'ok': False, 'error': str(error), 'code': getattr(error, 'code', 'operation_failed'),
|
|
69
|
+
'hint': getattr(error, 'hint', None), 'details': getattr(error, 'details', {})}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..utils.utils import ensure_dir
|
|
8
|
+
from .file_merge import try_merge_file
|
|
9
|
+
|
|
10
|
+
# Python 包管理和其他应该忽略的文件和目录
|
|
11
|
+
EXCLUDED_PATTERNS = [
|
|
12
|
+
# Python 包管理
|
|
13
|
+
".egg-info",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".pyc",
|
|
16
|
+
".pyo",
|
|
17
|
+
".pyd",
|
|
18
|
+
".so",
|
|
19
|
+
".dll",
|
|
20
|
+
".eggs",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".tox",
|
|
23
|
+
".coverage",
|
|
24
|
+
".coverage.*",
|
|
25
|
+
"htmlcov",
|
|
26
|
+
# 版本控制
|
|
27
|
+
".git",
|
|
28
|
+
".hg",
|
|
29
|
+
".svn",
|
|
30
|
+
".bzr",
|
|
31
|
+
# 其他临时文件
|
|
32
|
+
".DS_Store",
|
|
33
|
+
"Thumbs.db"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
MANIFEST_FILES = [
|
|
37
|
+
"manifest.json",
|
|
38
|
+
"pack_manifest.json"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AddonsPack(object):
|
|
43
|
+
|
|
44
|
+
pkg_name: str
|
|
45
|
+
path: str
|
|
46
|
+
is_origin: bool
|
|
47
|
+
behavior_pack_dir: str
|
|
48
|
+
resource_pack_dir: str
|
|
49
|
+
|
|
50
|
+
def __init__(self, pkg_name, path, is_origin=False):
|
|
51
|
+
self.pkg_name = pkg_name
|
|
52
|
+
self.path = os.path.abspath(path)
|
|
53
|
+
self.is_origin = is_origin
|
|
54
|
+
self.behavior_pack_dir = None
|
|
55
|
+
self.resource_pack_dir = None
|
|
56
|
+
# 进入此目录,查找内部的行为包和资源包的路径
|
|
57
|
+
for item in os.listdir(self.path):
|
|
58
|
+
item_path = os.path.join(self.path, item)
|
|
59
|
+
if os.path.isdir(item_path):
|
|
60
|
+
if item.startswith("behavior_pack") or item.startswith("BehaviorPack"):
|
|
61
|
+
self.behavior_pack_dir = item_path
|
|
62
|
+
elif item.startswith("resource_pack") or item.startswith("ResourcePack"):
|
|
63
|
+
self.resource_pack_dir = item_path
|
|
64
|
+
if not self.behavior_pack_dir:
|
|
65
|
+
self.behavior_pack_dir = os.path.join(self.path, "behavior_pack")
|
|
66
|
+
if not self.resource_pack_dir:
|
|
67
|
+
self.resource_pack_dir = os.path.join(self.path, "resource_pack")
|
|
68
|
+
|
|
69
|
+
def should_exclude(self, path):
|
|
70
|
+
"""判断文件或目录是否应该被排除"""
|
|
71
|
+
# Only inspect components inside this pack. A checkout named demo.git-copy
|
|
72
|
+
# or a perfectly valid terrain.soil.json is not an excluded artifact.
|
|
73
|
+
from pathlib import Path
|
|
74
|
+
if os.name == 'nt':
|
|
75
|
+
# Python 3.9 treats C: and \\?\C: as different relpath mounts.
|
|
76
|
+
# Cache paths use the extended spelling; resolved files may not.
|
|
77
|
+
from ..source_files import long_path
|
|
78
|
+
relative = os.path.relpath(long_path(path), long_path(self.path))
|
|
79
|
+
else:
|
|
80
|
+
relative = os.path.relpath(path, self.path)
|
|
81
|
+
parts = Path(relative).parts
|
|
82
|
+
directories = {'.git', '.hg', '.svn', '.eggs', '.pytest_cache', '.tox',
|
|
83
|
+
'.bzr', '__pycache__', 'htmlcov'}
|
|
84
|
+
if any(part in directories or part.endswith('.egg-info') for part in parts):
|
|
85
|
+
return True
|
|
86
|
+
name = parts[-1] if parts else ''
|
|
87
|
+
if (name in ('.DS_Store', 'Thumbs.db', '.coverage') or name.startswith('.coverage.')
|
|
88
|
+
or Path(name).suffix.lower() in ('.pyc', '.pyo', '.pyd', '.so', '.dll')):
|
|
89
|
+
return True
|
|
90
|
+
# 得到文件名
|
|
91
|
+
filename = os.path.basename(path)
|
|
92
|
+
if not self.is_origin and filename in MANIFEST_FILES:
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def copy_behavior_to(self, target_dir: str, rename: str = None):
|
|
97
|
+
"""
|
|
98
|
+
复制行为包和资源包到目标目录
|
|
99
|
+
target_dir为父目录
|
|
100
|
+
"""
|
|
101
|
+
if self.behavior_pack_dir:
|
|
102
|
+
target_path = os.path.join(target_dir, os.path.basename(self.behavior_pack_dir) if not rename else rename)
|
|
103
|
+
os.makedirs(target_path, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
# 使用自定义复制函数而不是shutil.copytree
|
|
106
|
+
for root, dirs, files in os.walk(self.behavior_pack_dir):
|
|
107
|
+
# 过滤掉应该排除的目录
|
|
108
|
+
dirs[:] = [d for d in dirs if not self.should_exclude(os.path.join(root, d))]
|
|
109
|
+
|
|
110
|
+
# 计算相对路径
|
|
111
|
+
rel_path = os.path.relpath(root, self.behavior_pack_dir)
|
|
112
|
+
# 计算目标目录
|
|
113
|
+
target_root = os.path.join(target_path, rel_path) if rel_path != '.' else target_path
|
|
114
|
+
ensure_dir(target_root)
|
|
115
|
+
|
|
116
|
+
# 复制文件
|
|
117
|
+
for file in files:
|
|
118
|
+
src_file = os.path.join(root, file)
|
|
119
|
+
if not self.should_exclude(src_file):
|
|
120
|
+
dest_file = os.path.join(target_root, file)
|
|
121
|
+
# 如果是Python文件,检查并添加编码声明
|
|
122
|
+
if file.endswith('.py'):
|
|
123
|
+
self._copy_with_encoding_check(src_file, dest_file)
|
|
124
|
+
else:
|
|
125
|
+
shutil.copy2(src_file, dest_file)
|
|
126
|
+
|
|
127
|
+
def copy_resource_to(self, target_dir: str, rename: str = None):
|
|
128
|
+
"""
|
|
129
|
+
复制资源包到目标目录
|
|
130
|
+
target_dir为父目录
|
|
131
|
+
"""
|
|
132
|
+
if self.resource_pack_dir:
|
|
133
|
+
target_path = os.path.join(target_dir, os.path.basename(self.resource_pack_dir) if not rename else rename)
|
|
134
|
+
os.makedirs(target_path, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
# 使用自定义复制函数而不是shutil.copytree
|
|
137
|
+
for root, dirs, files in os.walk(self.resource_pack_dir):
|
|
138
|
+
# 过滤掉应该排除的目录
|
|
139
|
+
dirs[:] = [d for d in dirs if not self.should_exclude(os.path.join(root, d))]
|
|
140
|
+
|
|
141
|
+
# 计算相对路径
|
|
142
|
+
rel_path = os.path.relpath(root, self.resource_pack_dir)
|
|
143
|
+
# 计算目标目录
|
|
144
|
+
target_root = os.path.join(target_path, rel_path) if rel_path != '.' else target_path
|
|
145
|
+
ensure_dir(target_root)
|
|
146
|
+
|
|
147
|
+
# 复制文件
|
|
148
|
+
for file in files:
|
|
149
|
+
src_file = os.path.join(root, file)
|
|
150
|
+
if not self.should_exclude(src_file):
|
|
151
|
+
dest_file = os.path.join(target_root, file)
|
|
152
|
+
# 如果是Python文件,检查并添加编码声明
|
|
153
|
+
if file.endswith('.py'):
|
|
154
|
+
self._copy_with_encoding_check(src_file, dest_file)
|
|
155
|
+
else:
|
|
156
|
+
shutil.copy2(src_file, dest_file)
|
|
157
|
+
|
|
158
|
+
def merge_behavior_into(self, target_behavior_dir: str):
|
|
159
|
+
"""合并行为包到目标行为包目录"""
|
|
160
|
+
if self.behavior_pack_dir:
|
|
161
|
+
for root, dirs, files in os.walk(self.behavior_pack_dir):
|
|
162
|
+
# 过滤掉应该排除的目录
|
|
163
|
+
dirs[:] = [d for d in dirs if not self.should_exclude(os.path.join(root, d))]
|
|
164
|
+
|
|
165
|
+
# 计算相对路径
|
|
166
|
+
rel_path = os.path.relpath(root, self.behavior_pack_dir)
|
|
167
|
+
# 计算目标目录
|
|
168
|
+
target_root = os.path.join(target_behavior_dir, rel_path) if rel_path != '.' else target_behavior_dir
|
|
169
|
+
ensure_dir(target_root)
|
|
170
|
+
|
|
171
|
+
# 复制文件
|
|
172
|
+
for file in files:
|
|
173
|
+
src_file = os.path.join(root, file)
|
|
174
|
+
if not self.should_exclude(src_file):
|
|
175
|
+
# 计算源文件的相对路径
|
|
176
|
+
src_rel_path = os.path.relpath(src_file, self.behavior_pack_dir)
|
|
177
|
+
dest_file = os.path.join(target_root, file)
|
|
178
|
+
self.merge_behavior_single_file_to(src_rel_path, dest_file)
|
|
179
|
+
|
|
180
|
+
def merge_resource_into(self, target_resource_dir: str):
|
|
181
|
+
"""合并资源包到目标资源包目录"""
|
|
182
|
+
if self.resource_pack_dir:
|
|
183
|
+
for root, dirs, files in os.walk(self.resource_pack_dir):
|
|
184
|
+
# 过滤掉应该排除的目录
|
|
185
|
+
dirs[:] = [d for d in dirs if not self.should_exclude(os.path.join(root, d))]
|
|
186
|
+
|
|
187
|
+
# 计算相对路径
|
|
188
|
+
rel_path = os.path.relpath(root, self.resource_pack_dir)
|
|
189
|
+
# 计算目标目录
|
|
190
|
+
target_root = os.path.join(target_resource_dir, rel_path) if rel_path != '.' else target_resource_dir
|
|
191
|
+
ensure_dir(target_root)
|
|
192
|
+
|
|
193
|
+
# 复制文件
|
|
194
|
+
for file in files:
|
|
195
|
+
src_file = os.path.join(root, file)
|
|
196
|
+
if not self.should_exclude(src_file):
|
|
197
|
+
# 计算源文件的相对路径
|
|
198
|
+
src_rel_path = os.path.relpath(src_file, self.resource_pack_dir)
|
|
199
|
+
dest_file = os.path.join(target_root, file)
|
|
200
|
+
self.merge_resource_single_file_to(src_rel_path, dest_file)
|
|
201
|
+
|
|
202
|
+
def merge_behavior_single_file_to(self, src_rel_path: str, dest_abs_path: str):
|
|
203
|
+
"""合并行为包单个文件,src为相对路径,dest为绝对路径"""
|
|
204
|
+
if self.behavior_pack_dir:
|
|
205
|
+
src_file = os.path.join(self.behavior_pack_dir, src_rel_path)
|
|
206
|
+
if os.path.exists(src_file):
|
|
207
|
+
self._merge_single_file(src_file, dest_abs_path)
|
|
208
|
+
return True
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def merge_resource_single_file_to(self, src_rel_path: str, dest_abs_path: str):
|
|
212
|
+
"""合并资源包单个文件,src为相对路径,dest为绝对路径"""
|
|
213
|
+
if self.resource_pack_dir:
|
|
214
|
+
src_file = os.path.join(self.resource_pack_dir, src_rel_path)
|
|
215
|
+
if os.path.exists(src_file):
|
|
216
|
+
self._merge_single_file(src_file, dest_abs_path)
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def _merge_single_file(self, src_file: str, dest_file: str):
|
|
221
|
+
"""合并单个文件"""
|
|
222
|
+
if os.path.exists(dest_file):
|
|
223
|
+
if self.should_exclude(dest_file):
|
|
224
|
+
return
|
|
225
|
+
# 处理文件冲突
|
|
226
|
+
if src_file.endswith('.py'):
|
|
227
|
+
self._copy_with_encoding_check(src_file, dest_file)
|
|
228
|
+
elif os.path.exists(dest_file):
|
|
229
|
+
suc, reason = try_merge_file(src_file, dest_file)
|
|
230
|
+
if not suc:
|
|
231
|
+
click.secho(f"⚠️ 警告: 文件合并异常 {src_file} -> {dest_file} {reason}", fg="yellow")
|
|
232
|
+
else:
|
|
233
|
+
shutil.copy2(src_file, dest_file)
|
|
234
|
+
|
|
235
|
+
def _copy_with_encoding_check(self, src_file, dest_file):
|
|
236
|
+
"""复制Python文件,并检查添加编码声明"""
|
|
237
|
+
try:
|
|
238
|
+
import re
|
|
239
|
+
from pathlib import Path
|
|
240
|
+
content = Path(src_file).read_bytes()
|
|
241
|
+
# Preserve ASCII, BOM and valid PEP 263 declarations byte-for-byte.
|
|
242
|
+
# Only undeclared non-ASCII UTF-8 needs Python 2's encoding header.
|
|
243
|
+
declared = any(re.search(br'^[ \t\f]*#.*?coding[:=][ \t]*[-\w.]+', line)
|
|
244
|
+
for line in content.splitlines()[:2])
|
|
245
|
+
if not declared and not content.startswith(b'\xef\xbb\xbf') and any(b >= 128 for b in content):
|
|
246
|
+
content.decode('utf-8')
|
|
247
|
+
newline = b'\r\n' if b'\r\n' in content else b'\n'
|
|
248
|
+
header = b'# -*- coding: utf-8 -*-' + newline
|
|
249
|
+
if content.startswith(b'#!') and b'\n' in content:
|
|
250
|
+
first, rest = content.split(b'\n', 1)
|
|
251
|
+
content = first + b'\n' + header + rest
|
|
252
|
+
else:
|
|
253
|
+
content = header + content
|
|
254
|
+
Path(dest_file).write_bytes(content)
|
|
255
|
+
|
|
256
|
+
# 复制文件元数据
|
|
257
|
+
shutil.copystat(src_file, dest_file)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
click.secho(f"⚠️ 添加编码声明时出错: {src_file} -> {dest_file}: {str(e)}", fg="yellow")
|
|
260
|
+
# 如果出错,则直接复制
|
|
261
|
+
shutil.copy2(src_file, dest_file)
|
|
262
|
+
|
|
263
|
+
def is_behavior_file(self, file_path):
|
|
264
|
+
"""
|
|
265
|
+
判断文件是否为行为包文件
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
file_path: 文件路径
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
bool: 如果是行为包文件返回True
|
|
272
|
+
"""
|
|
273
|
+
return "behavior_pack" in file_path.lower() or "behaviorpack" in file_path.lower()
|
|
274
|
+
|
|
275
|
+
def is_resource_file(self, file_path):
|
|
276
|
+
"""
|
|
277
|
+
判断文件是否为资源包文件
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
file_path: 文件路径
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
bool: 如果是资源包文件返回True
|
|
284
|
+
"""
|
|
285
|
+
return "resource_pack" in file_path.lower() or "resourcepack" in file_path.lower()
|
|
286
|
+
|
|
287
|
+
def get_relative_path_in_pack(self, file_path):
|
|
288
|
+
"""
|
|
289
|
+
获取文件在包中的相对路径
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
file_path: 文件的绝对路径
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
tuple: (是否为包文件, 包类型, 相对路径)
|
|
296
|
+
包类型: "behavior" 或 "resource"
|
|
297
|
+
相对路径: 在包中的相对路径,如果不在包中则为None
|
|
298
|
+
"""
|
|
299
|
+
# Watchdog 可能返回短路径或目录链接路径;保留相对文件名大小写。
|
|
300
|
+
absolute = os.path.realpath(file_path)
|
|
301
|
+
for kind, folder in (("behavior", self.behavior_pack_dir), ("resource", self.resource_pack_dir)):
|
|
302
|
+
if not folder:
|
|
303
|
+
continue
|
|
304
|
+
folder = os.path.realpath(folder)
|
|
305
|
+
try:
|
|
306
|
+
if os.path.commonpath([os.path.normcase(absolute), os.path.normcase(folder)]) == os.path.normcase(folder):
|
|
307
|
+
return True, kind, os.path.relpath(absolute, folder)
|
|
308
|
+
except ValueError:
|
|
309
|
+
continue
|
|
310
|
+
return False, None, None
|