mdify-cli 2.11.8__py3-none-any.whl → 2.11.10__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.
- mdify/__init__.py +1 -1
- mdify/cli.py +580 -4
- mdify/container.py +0 -4
- mdify/ssh/__init__.py +11 -0
- mdify/ssh/client.py +408 -0
- mdify/ssh/models.py +470 -0
- mdify/ssh/remote_container.py +237 -0
- mdify/ssh/transfer.py +297 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/METADATA +192 -4
- mdify_cli-2.11.10.dist-info/RECORD +17 -0
- mdify_cli-2.11.8.dist-info/RECORD +0 -12
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/WHEEL +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/entry_points.txt +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/licenses/LICENSE +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/top_level.txt +0 -0
mdify/__init__.py
CHANGED
mdify/cli.py
CHANGED
|
@@ -116,11 +116,17 @@ def parse_memory_string(mem_str: str) -> float:
|
|
|
116
116
|
raise ValueError(f"Invalid memory format: {mem_str}")
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def validate_memory_availability(
|
|
119
|
+
def validate_memory_availability(
|
|
120
|
+
required_gb: float,
|
|
121
|
+
profile_name: str = "default",
|
|
122
|
+
suggest_profile: Optional[str] = None,
|
|
123
|
+
) -> tuple[bool, str]:
|
|
120
124
|
"""Check if system has sufficient available memory.
|
|
121
125
|
|
|
122
126
|
Args:
|
|
123
127
|
required_gb: Required memory in GB
|
|
128
|
+
profile_name: Name of current profile being used
|
|
129
|
+
suggest_profile: Name of smaller profile to suggest (auto-detected if None)
|
|
124
130
|
|
|
125
131
|
Returns:
|
|
126
132
|
Tuple of (is_sufficient, error_message)
|
|
@@ -132,13 +138,39 @@ def validate_memory_availability(required_gb: float) -> tuple[bool, str]:
|
|
|
132
138
|
return True, ""
|
|
133
139
|
|
|
134
140
|
if available_gb < required_gb:
|
|
141
|
+
# Determine which smaller profile to suggest
|
|
142
|
+
if suggest_profile is None:
|
|
143
|
+
if profile_name == "heavy":
|
|
144
|
+
suggest_profile = "default"
|
|
145
|
+
elif profile_name == "default":
|
|
146
|
+
suggest_profile = "minimal"
|
|
147
|
+
else:
|
|
148
|
+
suggest_profile = None # Already on minimal
|
|
149
|
+
|
|
135
150
|
error = (
|
|
136
151
|
f"Insufficient memory available for container startup.\n"
|
|
152
|
+
f" Current profile: {profile_name}\n"
|
|
137
153
|
f" Required: {required_gb:.1f} GB\n"
|
|
138
154
|
f" Available: {available_gb:.1f} GB\n"
|
|
139
155
|
f" Short by: {required_gb - available_gb:.1f} GB\n\n"
|
|
140
|
-
f"Please close other applications or use a smaller profile (--profile minimal)"
|
|
141
156
|
)
|
|
157
|
+
|
|
158
|
+
if suggest_profile:
|
|
159
|
+
suggested = RESOURCE_PROFILES[suggest_profile]
|
|
160
|
+
error += (
|
|
161
|
+
f"Suggested solutions:\n"
|
|
162
|
+
f" 1. Close other applications to free up memory\n"
|
|
163
|
+
f" 2. Use a smaller profile: --profile {suggest_profile} "
|
|
164
|
+
f"({suggested['cpus']} CPUs, {suggested['memory']} memory)\n"
|
|
165
|
+
f" 3. Skip memory check: --skip-memory-check (not recommended)"
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
error += (
|
|
169
|
+
f"Suggested solutions:\n"
|
|
170
|
+
f" 1. Close other applications to free up memory\n"
|
|
171
|
+
f" 2. Skip memory check: --skip-memory-check (not recommended)"
|
|
172
|
+
)
|
|
173
|
+
|
|
142
174
|
return False, error
|
|
143
175
|
|
|
144
176
|
return True, ""
|
|
@@ -882,6 +914,97 @@ Examples:
|
|
|
882
914
|
help="Skip memory availability validation (not recommended)",
|
|
883
915
|
)
|
|
884
916
|
|
|
917
|
+
# SSH/Remote server options
|
|
918
|
+
ssh_group = parser.add_argument_group("Remote SSH Server", "Execute conversion on remote server via SSH")
|
|
919
|
+
|
|
920
|
+
ssh_group.add_argument(
|
|
921
|
+
"--remote-host",
|
|
922
|
+
type=str,
|
|
923
|
+
default=None,
|
|
924
|
+
help="SSH host or alias (e.g., tsrv, 192.168.1.200, or SSH config alias)",
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
ssh_group.add_argument(
|
|
928
|
+
"--remote-port",
|
|
929
|
+
type=int,
|
|
930
|
+
default=None,
|
|
931
|
+
help="SSH port (default: 22 or from SSH config)",
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
ssh_group.add_argument(
|
|
935
|
+
"--remote-user",
|
|
936
|
+
type=str,
|
|
937
|
+
default=None,
|
|
938
|
+
help="SSH username (default: from SSH config or system user)",
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
ssh_group.add_argument(
|
|
942
|
+
"--remote-key",
|
|
943
|
+
type=str,
|
|
944
|
+
default=None,
|
|
945
|
+
help="SSH private key path (default: ~/.ssh/id_rsa or from SSH config)",
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
ssh_group.add_argument(
|
|
949
|
+
"--remote-key-passphrase",
|
|
950
|
+
type=str,
|
|
951
|
+
default=None,
|
|
952
|
+
help="SSH key passphrase (not recommended; use SSH agent)",
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
ssh_group.add_argument(
|
|
956
|
+
"--remote-timeout",
|
|
957
|
+
type=int,
|
|
958
|
+
default=30,
|
|
959
|
+
help="SSH connection timeout in seconds (default: 30)",
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
ssh_group.add_argument(
|
|
963
|
+
"--remote-work-dir",
|
|
964
|
+
type=str,
|
|
965
|
+
default="/tmp/mdify-remote",
|
|
966
|
+
help="Work directory on remote server (default: /tmp/mdify-remote)",
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
ssh_group.add_argument(
|
|
970
|
+
"--remote-runtime",
|
|
971
|
+
type=str,
|
|
972
|
+
choices=("docker", "podman"),
|
|
973
|
+
default=None,
|
|
974
|
+
help="Container runtime on remote (docker or podman; auto-detect if not specified)",
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
ssh_group.add_argument(
|
|
978
|
+
"--remote-config",
|
|
979
|
+
type=str,
|
|
980
|
+
default=None,
|
|
981
|
+
help="Path to mdify remote config file (YAML format, default: ~/.mdify/remote.conf)",
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
ssh_group.add_argument(
|
|
985
|
+
"--remote-skip-ssh-config",
|
|
986
|
+
action="store_true",
|
|
987
|
+
help="Skip loading SSH config (use CLI arguments only)",
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
ssh_group.add_argument(
|
|
991
|
+
"--remote-skip-validation",
|
|
992
|
+
action="store_true",
|
|
993
|
+
help="Skip remote resource validation (not recommended)",
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
ssh_group.add_argument(
|
|
997
|
+
"--remote-validate-only",
|
|
998
|
+
action="store_true",
|
|
999
|
+
help="Validate remote connection and resources, then exit",
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
ssh_group.add_argument(
|
|
1003
|
+
"--remote-debug",
|
|
1004
|
+
action="store_true",
|
|
1005
|
+
help="Enable debug logging for remote SSH operations",
|
|
1006
|
+
)
|
|
1007
|
+
|
|
885
1008
|
# Utility options
|
|
886
1009
|
parser.add_argument(
|
|
887
1010
|
"--check-update",
|
|
@@ -898,6 +1021,442 @@ Examples:
|
|
|
898
1021
|
return parser.parse_args()
|
|
899
1022
|
|
|
900
1023
|
|
|
1024
|
+
# =============================================================================
|
|
1025
|
+
# Remote SSH execution support
|
|
1026
|
+
# =============================================================================
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def main_async_remote(args) -> int:
|
|
1030
|
+
"""Execute conversion on remote server via SSH.
|
|
1031
|
+
|
|
1032
|
+
This function handles:
|
|
1033
|
+
1. Loading and merging SSH configuration
|
|
1034
|
+
2. Establishing remote connection
|
|
1035
|
+
3. Uploading input files
|
|
1036
|
+
4. Executing remote conversion
|
|
1037
|
+
5. Downloading output files
|
|
1038
|
+
6. Cleanup on success or failure
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
args: Parsed command-line arguments with remote_* options
|
|
1042
|
+
|
|
1043
|
+
Returns:
|
|
1044
|
+
Exit code (0 for success, non-zero for errors)
|
|
1045
|
+
"""
|
|
1046
|
+
import asyncio
|
|
1047
|
+
from pathlib import Path
|
|
1048
|
+
from mdify.ssh import SSHConfig, AsyncSSHClient
|
|
1049
|
+
from mdify.ssh.models import SSHConnectionError, SSHAuthError, ConfigError, ValidationError
|
|
1050
|
+
|
|
1051
|
+
async def async_main() -> int:
|
|
1052
|
+
"""Async implementation of remote conversion."""
|
|
1053
|
+
|
|
1054
|
+
# Resolve timeout value: CLI > env > default 1200
|
|
1055
|
+
timeout = args.timeout or int(os.environ.get("MDIFY_TIMEOUT", 1200))
|
|
1056
|
+
|
|
1057
|
+
# Build SSH config from CLI arguments and SSH config files
|
|
1058
|
+
try:
|
|
1059
|
+
# Build config with proper precedence (lowest to highest):
|
|
1060
|
+
# SSH config -> mdify remote.conf -> CLI args
|
|
1061
|
+
ssh_config = None
|
|
1062
|
+
|
|
1063
|
+
if not args.remote_skip_ssh_config:
|
|
1064
|
+
# Load from SSH config if host looks like an alias
|
|
1065
|
+
if not args.remote_host.replace('.', '').replace('-', '').isdigit():
|
|
1066
|
+
try:
|
|
1067
|
+
ssh_config = SSHConfig.from_ssh_config(args.remote_host)
|
|
1068
|
+
except Exception as e:
|
|
1069
|
+
if not args.quiet:
|
|
1070
|
+
print(f"Warning: Could not load SSH config for {args.remote_host}: {e}", file=sys.stderr)
|
|
1071
|
+
|
|
1072
|
+
# Load from mdify remote.conf if it exists
|
|
1073
|
+
mdify_remote_conf = args.remote_config or (Path.home() / ".mdify" / "remote.conf")
|
|
1074
|
+
if mdify_remote_conf and Path(mdify_remote_conf).exists():
|
|
1075
|
+
try:
|
|
1076
|
+
ssh_from_mdify = SSHConfig.from_remote_conf(str(mdify_remote_conf))
|
|
1077
|
+
if ssh_config:
|
|
1078
|
+
ssh_config = ssh_config.merge(ssh_from_mdify)
|
|
1079
|
+
else:
|
|
1080
|
+
ssh_config = ssh_from_mdify
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
if not args.quiet:
|
|
1083
|
+
print(f"Warning: Could not load mdify remote config: {e}", file=sys.stderr)
|
|
1084
|
+
|
|
1085
|
+
# Start with minimal defaults if no config loaded
|
|
1086
|
+
if ssh_config is None:
|
|
1087
|
+
ssh_config = SSHConfig(host=args.remote_host, port=22, username=None)
|
|
1088
|
+
|
|
1089
|
+
# Apply CLI arguments with highest precedence
|
|
1090
|
+
cli_config = SSHConfig(
|
|
1091
|
+
host=args.remote_host,
|
|
1092
|
+
port=args.remote_port,
|
|
1093
|
+
username=args.remote_user,
|
|
1094
|
+
key_file=args.remote_key,
|
|
1095
|
+
key_passphrase=args.remote_key_passphrase,
|
|
1096
|
+
timeout=args.remote_timeout,
|
|
1097
|
+
work_dir=args.remote_work_dir,
|
|
1098
|
+
container_runtime=args.remote_runtime,
|
|
1099
|
+
)
|
|
1100
|
+
ssh_config = ssh_config.merge(cli_config)
|
|
1101
|
+
|
|
1102
|
+
# Create SSH client
|
|
1103
|
+
ssh_client = AsyncSSHClient(ssh_config)
|
|
1104
|
+
|
|
1105
|
+
# Connect to remote server
|
|
1106
|
+
if not args.quiet:
|
|
1107
|
+
print(f"Connecting to {ssh_config.host}:{ssh_config.port}...", file=sys.stderr)
|
|
1108
|
+
|
|
1109
|
+
await ssh_client.connect()
|
|
1110
|
+
|
|
1111
|
+
if not args.quiet:
|
|
1112
|
+
print(f"✓ Connected to {ssh_config.host}", file=sys.stderr)
|
|
1113
|
+
|
|
1114
|
+
# Validate remote resources if not skipped
|
|
1115
|
+
if not args.remote_skip_validation:
|
|
1116
|
+
if not args.quiet:
|
|
1117
|
+
print("Validating remote resources...", file=sys.stderr)
|
|
1118
|
+
|
|
1119
|
+
validation_result = await ssh_client.validate_remote_resources()
|
|
1120
|
+
|
|
1121
|
+
if not validation_result.get("can_connect"):
|
|
1122
|
+
await ssh_client.disconnect()
|
|
1123
|
+
print("Error: Cannot connect to remote server", file=sys.stderr)
|
|
1124
|
+
return 1
|
|
1125
|
+
|
|
1126
|
+
if not validation_result.get("work_dir_writable"):
|
|
1127
|
+
await ssh_client.disconnect()
|
|
1128
|
+
print(f"Error: Work directory not writable: {ssh_config.work_dir}", file=sys.stderr)
|
|
1129
|
+
return 1
|
|
1130
|
+
|
|
1131
|
+
if not validation_result.get("container_runtime_available"):
|
|
1132
|
+
await ssh_client.disconnect()
|
|
1133
|
+
runtime_str = ssh_config.container_runtime or "docker/podman"
|
|
1134
|
+
print(f"Error: Container runtime not available: {runtime_str}", file=sys.stderr)
|
|
1135
|
+
return 1
|
|
1136
|
+
|
|
1137
|
+
if not validation_result.get("disk_space_min_5gb"):
|
|
1138
|
+
print(f"Warning: Less than 5GB available on remote", file=sys.stderr)
|
|
1139
|
+
if not args.yes and sys.stdin.isatty():
|
|
1140
|
+
if not confirm_proceed("Continue anyway?"):
|
|
1141
|
+
await ssh_client.disconnect()
|
|
1142
|
+
return 130
|
|
1143
|
+
|
|
1144
|
+
if not validation_result.get("memory_min_2gb"):
|
|
1145
|
+
print(f"Warning: Less than 2GB available memory on remote", file=sys.stderr)
|
|
1146
|
+
if not args.yes and sys.stdin.isatty():
|
|
1147
|
+
if not confirm_proceed("Continue anyway?"):
|
|
1148
|
+
await ssh_client.disconnect()
|
|
1149
|
+
return 130
|
|
1150
|
+
|
|
1151
|
+
if not args.quiet:
|
|
1152
|
+
print("✓ All remote resources validated", file=sys.stderr)
|
|
1153
|
+
|
|
1154
|
+
# If --remote-validate-only, exit here
|
|
1155
|
+
if args.remote_validate_only:
|
|
1156
|
+
await ssh_client.disconnect()
|
|
1157
|
+
print("Remote validation successful", file=sys.stderr)
|
|
1158
|
+
return 0
|
|
1159
|
+
|
|
1160
|
+
# Phase 2.4.2: File upload, remote conversion, and download
|
|
1161
|
+
|
|
1162
|
+
# Build file list
|
|
1163
|
+
input_path = Path(args.input)
|
|
1164
|
+
if not input_path.exists():
|
|
1165
|
+
await ssh_client.disconnect()
|
|
1166
|
+
print(f"Error: Input file or directory not found: {args.input}", file=sys.stderr)
|
|
1167
|
+
return 1
|
|
1168
|
+
|
|
1169
|
+
files_to_convert = get_files_to_convert(input_path.resolve(), args.glob, args.recursive)
|
|
1170
|
+
|
|
1171
|
+
if not files_to_convert:
|
|
1172
|
+
await ssh_client.disconnect()
|
|
1173
|
+
print(f"Error: No supported files found in {args.input}", file=sys.stderr)
|
|
1174
|
+
print(f" Supported formats: {', '.join(sorted(SUPPORTED_EXTENSIONS))}", file=sys.stderr)
|
|
1175
|
+
return 1
|
|
1176
|
+
|
|
1177
|
+
if not args.quiet:
|
|
1178
|
+
print(f"\nFound {len(files_to_convert)} file(s) to convert", file=sys.stderr)
|
|
1179
|
+
|
|
1180
|
+
# Import remote container and transfer manager
|
|
1181
|
+
from mdify.ssh.transfer import FileTransferManager
|
|
1182
|
+
from mdify.ssh.remote_container import RemoteContainer
|
|
1183
|
+
|
|
1184
|
+
# Determine container runtime and image
|
|
1185
|
+
runtime = ssh_config.container_runtime
|
|
1186
|
+
if not runtime:
|
|
1187
|
+
runtime = await ssh_client.check_container_runtime()
|
|
1188
|
+
if not runtime:
|
|
1189
|
+
await ssh_client.disconnect()
|
|
1190
|
+
print("Error: No container runtime found on remote (docker/podman)", file=sys.stderr)
|
|
1191
|
+
return 1
|
|
1192
|
+
|
|
1193
|
+
if args.gpu:
|
|
1194
|
+
image = GPU_IMAGE
|
|
1195
|
+
elif args.image:
|
|
1196
|
+
image = args.image
|
|
1197
|
+
else:
|
|
1198
|
+
image = DEFAULT_IMAGE
|
|
1199
|
+
|
|
1200
|
+
# Create remote container
|
|
1201
|
+
remote_container = RemoteContainer(
|
|
1202
|
+
ssh_client=ssh_client,
|
|
1203
|
+
image=image,
|
|
1204
|
+
port=args.port,
|
|
1205
|
+
runtime=runtime,
|
|
1206
|
+
name=f"mdify-remote-{int(time.time())}",
|
|
1207
|
+
timeout=timeout,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
# Create file transfer manager
|
|
1211
|
+
transfer_manager = FileTransferManager(ssh_client)
|
|
1212
|
+
|
|
1213
|
+
# Create remote work directory
|
|
1214
|
+
work_dir = ssh_config.work_dir or "/tmp/mdify-remote"
|
|
1215
|
+
stdout, stderr, code = await ssh_client.run_command(f"mkdir -p {work_dir}")
|
|
1216
|
+
if code != 0:
|
|
1217
|
+
await ssh_client.disconnect()
|
|
1218
|
+
print(f"Error: Failed to create remote work directory: {work_dir}", file=sys.stderr)
|
|
1219
|
+
return 1
|
|
1220
|
+
|
|
1221
|
+
# Start remote container
|
|
1222
|
+
if not args.quiet:
|
|
1223
|
+
print(f"\nStarting remote container ({image})...", file=sys.stderr)
|
|
1224
|
+
|
|
1225
|
+
try:
|
|
1226
|
+
await remote_container.start()
|
|
1227
|
+
if not args.quiet:
|
|
1228
|
+
print(f"✓ Container started: {remote_container.state.container_name}", file=sys.stderr)
|
|
1229
|
+
except Exception as e:
|
|
1230
|
+
await ssh_client.disconnect()
|
|
1231
|
+
print(f"Error: Failed to start remote container: {e}", file=sys.stderr)
|
|
1232
|
+
return 1
|
|
1233
|
+
|
|
1234
|
+
# Process files
|
|
1235
|
+
successful = 0
|
|
1236
|
+
failed = 0
|
|
1237
|
+
|
|
1238
|
+
try:
|
|
1239
|
+
for idx, input_file in enumerate(files_to_convert, 1):
|
|
1240
|
+
if not args.quiet:
|
|
1241
|
+
print(f"\n[{idx}/{len(files_to_convert)}] Processing: {input_file.name}", file=sys.stderr)
|
|
1242
|
+
|
|
1243
|
+
try:
|
|
1244
|
+
# Upload file
|
|
1245
|
+
remote_file_path = f"{work_dir}/{input_file.name}"
|
|
1246
|
+
|
|
1247
|
+
if not args.quiet:
|
|
1248
|
+
print(f" Uploading to {remote_file_path}...", file=sys.stderr)
|
|
1249
|
+
|
|
1250
|
+
await transfer_manager.upload_file(
|
|
1251
|
+
local_path=str(input_file),
|
|
1252
|
+
remote_path=remote_file_path,
|
|
1253
|
+
overwrite=True,
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
if not args.quiet:
|
|
1257
|
+
print(f" ✓ Upload complete", file=sys.stderr)
|
|
1258
|
+
|
|
1259
|
+
# Convert via remote container
|
|
1260
|
+
if not args.quiet:
|
|
1261
|
+
print(f" Converting via remote container...", file=sys.stderr)
|
|
1262
|
+
|
|
1263
|
+
# Determine output path
|
|
1264
|
+
output_dir = Path(args.out_dir)
|
|
1265
|
+
|
|
1266
|
+
# Preserve directory structure if not flat
|
|
1267
|
+
if not args.flat and input_path.is_dir():
|
|
1268
|
+
try:
|
|
1269
|
+
rel_path = input_file.relative_to(input_path)
|
|
1270
|
+
output_subdir = output_dir / rel_path.parent
|
|
1271
|
+
except ValueError:
|
|
1272
|
+
output_subdir = output_dir
|
|
1273
|
+
else:
|
|
1274
|
+
output_subdir = output_dir
|
|
1275
|
+
|
|
1276
|
+
output_subdir.mkdir(parents=True, exist_ok=True)
|
|
1277
|
+
output_file = output_subdir / f"{input_file.stem}.md"
|
|
1278
|
+
|
|
1279
|
+
# Check if output exists and skip if not overwrite
|
|
1280
|
+
if output_file.exists() and not args.overwrite:
|
|
1281
|
+
if not args.quiet:
|
|
1282
|
+
print(f" ⊘ Skipped: {output_file} already exists (use --overwrite to replace)", file=sys.stderr)
|
|
1283
|
+
continue
|
|
1284
|
+
|
|
1285
|
+
# Convert using remote container's HTTP API
|
|
1286
|
+
# The docling-serve API expects:
|
|
1287
|
+
# - Endpoint: /v1/convert/file
|
|
1288
|
+
# - Method: POST with multipart/form-data
|
|
1289
|
+
# - File field: "files" (note the plural)
|
|
1290
|
+
# - Additional fields: to_formats=md, do_ocr=true
|
|
1291
|
+
remote_output_path = f"{work_dir}/{input_file.stem}.md"
|
|
1292
|
+
|
|
1293
|
+
# Build conversion command on remote - use -F for multipart form data
|
|
1294
|
+
convert_cmd = (
|
|
1295
|
+
f"curl -X POST "
|
|
1296
|
+
f"-F 'files=@{remote_file_path}' "
|
|
1297
|
+
f"-F 'to_formats=md' "
|
|
1298
|
+
f"-F 'do_ocr=true' "
|
|
1299
|
+
)
|
|
1300
|
+
if args.mask:
|
|
1301
|
+
convert_cmd += f"-F 'mask=true' "
|
|
1302
|
+
convert_cmd += f"http://localhost:{args.port}/v1/convert/file"
|
|
1303
|
+
|
|
1304
|
+
stdout, stderr, code = await ssh_client.run_command(convert_cmd, timeout=timeout)
|
|
1305
|
+
|
|
1306
|
+
if code != 0:
|
|
1307
|
+
print(f" ✗ Conversion failed (curl error code {code}): {stderr}", file=sys.stderr)
|
|
1308
|
+
failed += 1
|
|
1309
|
+
continue
|
|
1310
|
+
|
|
1311
|
+
# Parse JSON response to extract markdown content
|
|
1312
|
+
try:
|
|
1313
|
+
response_data = json.loads(stdout)
|
|
1314
|
+
|
|
1315
|
+
# Extract content from response structure
|
|
1316
|
+
# Actual format: {"document": {"md_content": "..."}, "status": "success"}
|
|
1317
|
+
if "document" in response_data:
|
|
1318
|
+
document = response_data["document"]
|
|
1319
|
+
if "md_content" in document and document["md_content"]:
|
|
1320
|
+
markdown_content = document["md_content"]
|
|
1321
|
+
elif "text_content" in document and document["text_content"]:
|
|
1322
|
+
markdown_content = document["text_content"]
|
|
1323
|
+
else:
|
|
1324
|
+
# Fallback - use whole document
|
|
1325
|
+
markdown_content = json.dumps(document, indent=2)
|
|
1326
|
+
else:
|
|
1327
|
+
# Legacy format fallback
|
|
1328
|
+
if "results" in response_data and response_data["results"]:
|
|
1329
|
+
result = response_data["results"][0]
|
|
1330
|
+
if "content" in result:
|
|
1331
|
+
content = result["content"]
|
|
1332
|
+
if isinstance(content, dict) and "markdown" in content:
|
|
1333
|
+
markdown_content = content["markdown"]
|
|
1334
|
+
elif isinstance(content, str):
|
|
1335
|
+
markdown_content = content
|
|
1336
|
+
else:
|
|
1337
|
+
markdown_content = str(content)
|
|
1338
|
+
else:
|
|
1339
|
+
markdown_content = str(result)
|
|
1340
|
+
else:
|
|
1341
|
+
# Ultimate fallback
|
|
1342
|
+
markdown_content = stdout
|
|
1343
|
+
|
|
1344
|
+
# Write markdown content to remote file
|
|
1345
|
+
write_cmd = f"cat > {remote_output_path} << 'MDIFY_EOF'\n{markdown_content}\nMDIFY_EOF"
|
|
1346
|
+
_, _, write_code = await ssh_client.run_command(write_cmd, timeout=30)
|
|
1347
|
+
|
|
1348
|
+
if write_code != 0:
|
|
1349
|
+
print(f" ✗ Failed to write markdown output", file=sys.stderr)
|
|
1350
|
+
failed += 1
|
|
1351
|
+
continue
|
|
1352
|
+
|
|
1353
|
+
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
1354
|
+
print(f" ✗ Failed to parse conversion response: {e}", file=sys.stderr)
|
|
1355
|
+
if DEBUG:
|
|
1356
|
+
print(f" Response: {stdout[:500]}", file=sys.stderr)
|
|
1357
|
+
failed += 1
|
|
1358
|
+
continue
|
|
1359
|
+
|
|
1360
|
+
if not args.quiet:
|
|
1361
|
+
print(f" ✓ Conversion complete", file=sys.stderr)
|
|
1362
|
+
|
|
1363
|
+
# Download result
|
|
1364
|
+
if not args.quiet:
|
|
1365
|
+
print(f" Downloading result to {output_file}...", file=sys.stderr)
|
|
1366
|
+
|
|
1367
|
+
await transfer_manager.download_file(
|
|
1368
|
+
remote_path=remote_output_path,
|
|
1369
|
+
local_path=str(output_file),
|
|
1370
|
+
overwrite=True,
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
if not args.quiet:
|
|
1374
|
+
print(f" ✓ Download complete: {output_file}", file=sys.stderr)
|
|
1375
|
+
|
|
1376
|
+
successful += 1
|
|
1377
|
+
|
|
1378
|
+
# Cleanup remote files
|
|
1379
|
+
await ssh_client.run_command(f"rm -f {remote_file_path} {remote_output_path}")
|
|
1380
|
+
|
|
1381
|
+
except Exception as e:
|
|
1382
|
+
print(f" ✗ Failed: {e}", file=sys.stderr)
|
|
1383
|
+
if DEBUG:
|
|
1384
|
+
import traceback
|
|
1385
|
+
traceback.print_exc(file=sys.stderr)
|
|
1386
|
+
failed += 1
|
|
1387
|
+
continue
|
|
1388
|
+
|
|
1389
|
+
finally:
|
|
1390
|
+
# Stop and remove container
|
|
1391
|
+
if not args.quiet:
|
|
1392
|
+
print(f"\nStopping remote container...", file=sys.stderr)
|
|
1393
|
+
|
|
1394
|
+
try:
|
|
1395
|
+
await remote_container.stop(force=False)
|
|
1396
|
+
if not args.quiet:
|
|
1397
|
+
print(f"✓ Container stopped", file=sys.stderr)
|
|
1398
|
+
except Exception as e:
|
|
1399
|
+
if not args.quiet:
|
|
1400
|
+
print(f"Warning: Failed to stop container: {e}", file=sys.stderr)
|
|
1401
|
+
|
|
1402
|
+
# Cleanup remote work directory
|
|
1403
|
+
try:
|
|
1404
|
+
await ssh_client.run_command(f"rm -rf {work_dir}")
|
|
1405
|
+
if not args.quiet:
|
|
1406
|
+
print(f"✓ Cleaned up remote directory", file=sys.stderr)
|
|
1407
|
+
except Exception as e:
|
|
1408
|
+
if not args.quiet:
|
|
1409
|
+
print(f"Warning: Failed to cleanup remote directory: {e}", file=sys.stderr)
|
|
1410
|
+
|
|
1411
|
+
# Disconnect
|
|
1412
|
+
await ssh_client.disconnect()
|
|
1413
|
+
|
|
1414
|
+
# Print summary
|
|
1415
|
+
print(f"\n{'='*60}", file=sys.stderr)
|
|
1416
|
+
print(f"Remote conversion complete:", file=sys.stderr)
|
|
1417
|
+
print(f" Successful: {successful}", file=sys.stderr)
|
|
1418
|
+
print(f" Failed: {failed}", file=sys.stderr)
|
|
1419
|
+
print(f" Total: {len(files_to_convert)}", file=sys.stderr)
|
|
1420
|
+
print(f"{'='*60}", file=sys.stderr)
|
|
1421
|
+
|
|
1422
|
+
return 0 if failed == 0 else 1
|
|
1423
|
+
|
|
1424
|
+
except SSHAuthError as e:
|
|
1425
|
+
print(f"Error: SSH authentication failed: {e}", file=sys.stderr)
|
|
1426
|
+
print(" Check your SSH key, passphrase, or username", file=sys.stderr)
|
|
1427
|
+
return 1
|
|
1428
|
+
except SSHConnectionError as e:
|
|
1429
|
+
print(f"Error: SSH connection failed: {e}", file=sys.stderr)
|
|
1430
|
+
if hasattr(e, 'host') and hasattr(e, 'port'):
|
|
1431
|
+
print(f" Host: {e.host}:{e.port}", file=sys.stderr)
|
|
1432
|
+
return 1
|
|
1433
|
+
except ConfigError as e:
|
|
1434
|
+
print(f"Error: Configuration error: {e}", file=sys.stderr)
|
|
1435
|
+
return 1
|
|
1436
|
+
except ValidationError as e:
|
|
1437
|
+
print(f"Error: Validation error: {e}", file=sys.stderr)
|
|
1438
|
+
return 1
|
|
1439
|
+
except Exception as e:
|
|
1440
|
+
print(f"Error: Unexpected error during remote execution: {e}", file=sys.stderr)
|
|
1441
|
+
if DEBUG:
|
|
1442
|
+
import traceback
|
|
1443
|
+
traceback.print_exc(file=sys.stderr)
|
|
1444
|
+
return 1
|
|
1445
|
+
|
|
1446
|
+
# Run async main
|
|
1447
|
+
try:
|
|
1448
|
+
return asyncio.run(async_main())
|
|
1449
|
+
except KeyboardInterrupt:
|
|
1450
|
+
print("\n⚠ Interrupted by user", file=sys.stderr)
|
|
1451
|
+
return 130
|
|
1452
|
+
except Exception as e:
|
|
1453
|
+
print(f"Error: Failed to run remote execution: {e}", file=sys.stderr)
|
|
1454
|
+
if DEBUG:
|
|
1455
|
+
import traceback
|
|
1456
|
+
traceback.print_exc(file=sys.stderr)
|
|
1457
|
+
return 1
|
|
1458
|
+
|
|
1459
|
+
|
|
901
1460
|
# =============================================================================
|
|
902
1461
|
# Main entry point
|
|
903
1462
|
# =============================================================================
|
|
@@ -916,6 +1475,21 @@ def main() -> int:
|
|
|
916
1475
|
# Check for updates (daily, silent on errors)
|
|
917
1476
|
check_for_update(force=False)
|
|
918
1477
|
|
|
1478
|
+
# Detect remote mode (SSH-based execution)
|
|
1479
|
+
is_remote_mode = hasattr(args, 'remote_host') and args.remote_host is not None
|
|
1480
|
+
|
|
1481
|
+
if is_remote_mode:
|
|
1482
|
+
# Remote mode: will use SSH to execute on remote server
|
|
1483
|
+
# Import here to avoid import errors if asyncssh not installed in local environment
|
|
1484
|
+
try:
|
|
1485
|
+
import asyncio
|
|
1486
|
+
from mdify.ssh import AsyncSSHClient, SSHConfig
|
|
1487
|
+
return main_async_remote(args)
|
|
1488
|
+
except ImportError:
|
|
1489
|
+
print("Error: Remote mode requires asyncssh and additional dependencies", file=sys.stderr)
|
|
1490
|
+
print("Install with: pip install mdify-cli[remote]", file=sys.stderr)
|
|
1491
|
+
return 1
|
|
1492
|
+
|
|
919
1493
|
# Resolve timeout value: CLI > env > default 1200
|
|
920
1494
|
timeout = args.timeout or int(os.environ.get("MDIFY_TIMEOUT", 1200))
|
|
921
1495
|
|
|
@@ -1082,7 +1656,7 @@ def main() -> int:
|
|
|
1082
1656
|
|
|
1083
1657
|
try:
|
|
1084
1658
|
if not args.quiet:
|
|
1085
|
-
print(f"Starting docling-serve container
|
|
1659
|
+
print(f"Starting docling-serve container...\n")
|
|
1086
1660
|
|
|
1087
1661
|
# Apply resource profile
|
|
1088
1662
|
profile = RESOURCE_PROFILES[args.profile]
|
|
@@ -1092,7 +1666,9 @@ def main() -> int:
|
|
|
1092
1666
|
# Validate memory availability unless skipped
|
|
1093
1667
|
if not args.skip_memory_check:
|
|
1094
1668
|
required_gb = parse_memory_string(memory)
|
|
1095
|
-
is_sufficient, error_msg = validate_memory_availability(
|
|
1669
|
+
is_sufficient, error_msg = validate_memory_availability(
|
|
1670
|
+
required_gb, profile_name=args.profile
|
|
1671
|
+
)
|
|
1096
1672
|
if not is_sufficient:
|
|
1097
1673
|
print(f"Error: {error_msg}", file=sys.stderr)
|
|
1098
1674
|
return 1
|
mdify/container.py
CHANGED
|
@@ -221,10 +221,6 @@ class DoclingContainer:
|
|
|
221
221
|
True if container is healthy, False otherwise
|
|
222
222
|
"""
|
|
223
223
|
try:
|
|
224
|
-
# First check if container is still running
|
|
225
|
-
if not self.is_running():
|
|
226
|
-
return False
|
|
227
|
-
# Then check health endpoint
|
|
228
224
|
return check_health(self.base_url)
|
|
229
225
|
except Exception:
|
|
230
226
|
return False
|
mdify/ssh/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""SSH remote server support for mdify."""
|
|
2
|
+
|
|
3
|
+
from mdify.ssh.models import SSHConfig, TransferSession, RemoteContainerState
|
|
4
|
+
from mdify.ssh.client import AsyncSSHClient
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SSHConfig",
|
|
8
|
+
"TransferSession",
|
|
9
|
+
"RemoteContainerState",
|
|
10
|
+
"AsyncSSHClient",
|
|
11
|
+
]
|