|
7 | 7 |
|
8 | 8 | import Foundation |
9 | 9 |
|
10 | | -// final class PipPackageManager: PackageManagerProtocol { |
11 | | -// private let installationDirectory: URL |
12 | | -// |
13 | | -// let shellClient: ShellClient |
14 | | -// |
15 | | -// init(installationDirectory: URL) { |
16 | | -// self.installationDirectory = installationDirectory |
17 | | -// self.shellClient = .live() |
18 | | -// } |
19 | | -// |
20 | | -// func initialize(in packagePath: URL) async throws { |
21 | | -// guard await isInstalled() else { |
22 | | -// throw PackageManagerError.packageManagerNotInstalled |
23 | | -// } |
24 | | -// |
25 | | -// do { |
26 | | -// try createDirectoryStructure(for: packagePath) |
27 | | -// _ = try await executeInDirectory( |
28 | | -// in: packagePath.path, ["python -m venv venv"] |
29 | | -// ) |
30 | | -// |
31 | | -// let requirementsPath = packagePath.appending(path: "requirements.txt") |
32 | | -// if !FileManager.default.fileExists(atPath: requirementsPath.path) { |
33 | | -// try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) |
34 | | -// } |
35 | | -// } catch { |
36 | | -// throw PackageManagerError.initializationFailed(error.localizedDescription) |
37 | | -// } |
38 | | -// } |
39 | | -// |
40 | | -// func install(method: InstallationMethod) async throws { |
41 | | -// guard case .standardPackage(let source) = method else { |
42 | | -// throw PackageManagerError.invalidConfiguration |
43 | | -// } |
44 | | -// |
45 | | -// let packagePath = installationDirectory.appending(path: source.entryName) |
46 | | -// try await initialize(in: packagePath) |
47 | | -// |
48 | | -// do { |
49 | | -// let pipCommand = getPipCommand(in: packagePath) |
50 | | -// var installArgs = [pipCommand, "install"] |
51 | | -// |
52 | | -// if source.version.lowercased() != "latest" { |
53 | | -// installArgs.append("\(source.pkgName)==\(source.version)") |
54 | | -// } else { |
55 | | -// installArgs.append(source.pkgName) |
56 | | -// } |
57 | | -// |
58 | | -// let extras = source.options["extra"] |
59 | | -// if let extras { |
60 | | -// if let lastIndex = installArgs.indices.last { |
61 | | -// installArgs[lastIndex] += "[\(extras)]" |
62 | | -// } |
63 | | -// } |
64 | | -// |
65 | | -// _ = try await executeInDirectory(in: packagePath.path, installArgs) |
66 | | -// try await updateRequirements(packagePath: packagePath) |
67 | | -// try await verifyInstallation(packagePath: packagePath, package: source.pkgName) |
68 | | -// } catch { |
69 | | -// throw error |
70 | | -// } |
71 | | -// } |
72 | | -// |
73 | | -// /// Get the binary path for a Python package |
74 | | -// func getBinaryPath(for package: String) -> String { |
75 | | -// let packagePath = installationDirectory.appending(path: package) |
76 | | -// let customBinPath = packagePath.appending(path: "bin").appending(path: package).path |
77 | | -// if FileManager.default.fileExists(atPath: customBinPath) { |
78 | | -// return customBinPath |
79 | | -// } |
80 | | -// return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path |
81 | | -// } |
82 | | -// |
83 | | -// func isInstalled() async -> Bool { |
84 | | -// let pipCommands = ["pip3 --version", "python3 -m pip --version"] |
85 | | -// for command in pipCommands { |
86 | | -// do { |
87 | | -// let versionOutput = try await runCommand(command) |
88 | | -// let versionPattern = #"pip \d+\.\d+"# |
89 | | -// let output = versionOutput.reduce(into: "") { |
90 | | -// $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) |
91 | | -// } |
92 | | -// if output.range(of: versionPattern, options: .regularExpression) != nil { |
93 | | -// return true |
94 | | -// } |
95 | | -// } catch { |
96 | | -// continue |
97 | | -// } |
98 | | -// } |
99 | | -// return false |
100 | | -// } |
101 | | -// |
102 | | -// // MARK: - Helper methods |
103 | | -// |
104 | | -// private func getPipCommand(in packagePath: URL) -> String { |
105 | | -// let venvPip = "venv/bin/pip" |
106 | | -// return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) |
107 | | -// ? venvPip |
108 | | -// : "python -m pip" |
109 | | -// } |
110 | | -// |
111 | | -// /// Update the requirements.txt file with the installed package and extras |
112 | | -// private func updateRequirements(packagePath: URL) async throws { |
113 | | -// let pipCommand = getPipCommand(in: packagePath) |
114 | | -// let requirementsPath = packagePath.appending(path: "requirements.txt") |
115 | | -// |
116 | | -// let freezeOutput = try await executeInDirectory( |
117 | | -// in: packagePath.path, |
118 | | -// ["\(pipCommand)", "freeze"] |
119 | | -// ) |
120 | | -// |
121 | | -// let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" |
122 | | -// try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) |
123 | | -// } |
124 | | -// |
125 | | -// private func verifyInstallation(packagePath: URL, package: String) async throws { |
126 | | -// let pipCommand = getPipCommand(in: packagePath) |
127 | | -// let output = try await executeInDirectory( |
128 | | -// in: packagePath.path, ["\(pipCommand)", "list", "--format=freeze"] |
129 | | -// ) |
130 | | -// |
131 | | -// // Normalize package names for comparison |
132 | | -// let normalizedPackageHyphen = package.replacingOccurrences(of: "_", with: "-").lowercased() |
133 | | -// let normalizedPackageUnderscore = package.replacingOccurrences(of: "-", with: "_").lowercased() |
134 | | -// |
135 | | -// // Check if the package name appears in requirements.txt |
136 | | -// let installedPackages = output.map { line in |
137 | | -// line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) |
138 | | -// } |
139 | | -// let packageFound = installedPackages.contains { installedPackage in |
140 | | -// installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore |
141 | | -// } |
142 | | -// |
143 | | -// guard packageFound else { |
144 | | -// throw PackageManagerError.installationFailed("Package \(package) not found in pip list") |
145 | | -// } |
146 | | -// } |
147 | | -// } |
| 10 | +final class PipPackageManager: PackageManagerProtocol { |
| 11 | + private let installationDirectory: URL |
| 12 | + |
| 13 | + let shellClient: ShellClient |
| 14 | + |
| 15 | + init(installationDirectory: URL) { |
| 16 | + self.installationDirectory = installationDirectory |
| 17 | + self.shellClient = .live() |
| 18 | + } |
| 19 | + |
| 20 | + // MARK: - PackageManagerProtocol |
| 21 | + |
| 22 | + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { |
| 23 | + guard case .standardPackage(let source) = installationMethod else { |
| 24 | + throw PackageManagerError.invalidConfiguration |
| 25 | + } |
| 26 | + |
| 27 | + let packagePath = installationDirectory.appending(path: source.entryName) |
| 28 | + return [ |
| 29 | + initialize(in: packagePath), |
| 30 | + runPipInstall(source, in: packagePath), |
| 31 | + updateRequirements(in: packagePath), |
| 32 | + verifyInstallation(source, in: packagePath) |
| 33 | + ] |
| 34 | + } |
| 35 | + |
| 36 | + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { |
| 37 | + PackageManagerInstallStep(name: "", confirmation: .none) { model in |
| 38 | + let pipCommands = ["pip3 --version", "python3 -m pip --version"] |
| 39 | + var didFindPip = false |
| 40 | + for command in pipCommands { |
| 41 | + do { |
| 42 | + let versionOutput = try await model.runCommand(command) |
| 43 | + let versionPattern = #"pip \d+\.\d+"# |
| 44 | + let output = versionOutput.reduce(into: "") { |
| 45 | + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) |
| 46 | + } |
| 47 | + if output.range(of: versionPattern, options: .regularExpression) != nil { |
| 48 | + didFindPip = true |
| 49 | + break |
| 50 | + } |
| 51 | + } catch { |
| 52 | + continue |
| 53 | + } |
| 54 | + } |
| 55 | + guard didFindPip else { |
| 56 | + throw PackageManagerError.packageManagerNotInstalled |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + } |
| 61 | + |
| 62 | + /// Get the binary path for a Python package |
| 63 | + func getBinaryPath(for package: String) -> String { |
| 64 | + let packagePath = installationDirectory.appending(path: package) |
| 65 | + let customBinPath = packagePath.appending(path: "bin").appending(path: package).path |
| 66 | + if FileManager.default.fileExists(atPath: customBinPath) { |
| 67 | + return customBinPath |
| 68 | + } |
| 69 | + return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path |
| 70 | + } |
| 71 | + |
| 72 | + // MARK: - Initialize |
| 73 | + |
| 74 | + func initialize(in packagePath: URL) -> PackageManagerInstallStep { |
| 75 | + PackageManagerInstallStep(name: "Initialize Directory Structure", confirmation: .none) { model in |
| 76 | + try await model.createDirectoryStructure(for: packagePath) |
| 77 | + try await model.executeInDirectory(in: packagePath.path(percentEncoded: false), ["python -m venv venv"]) |
| 78 | + |
| 79 | + let requirementsPath = packagePath.appending(path: "requirements.txt") |
| 80 | + if !FileManager.default.fileExists(atPath: requirementsPath.path) { |
| 81 | + try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + // MARK: - Pip Install |
| 87 | + |
| 88 | + func runPipInstall(_ source: PackageSource, in packagePath: URL) -> PackageManagerInstallStep { |
| 89 | + let pipCommand = getPipCommand(in: packagePath) |
| 90 | + return PackageManagerInstallStep( |
| 91 | + name: "Install Package Using pip", |
| 92 | + confirmation: .required( |
| 93 | + message: "This requires the pip package \(source.pkgName)." |
| 94 | + + "\nAllow CodeEdit to install this package?" |
| 95 | + ) |
| 96 | + ) { model in |
| 97 | + var installArgs = [pipCommand, "install"] |
| 98 | + |
| 99 | + if source.version.lowercased() != "latest" { |
| 100 | + installArgs.append("\(source.pkgName)==\(source.version)") |
| 101 | + } else { |
| 102 | + installArgs.append(source.pkgName) |
| 103 | + } |
| 104 | + |
| 105 | + let extras = source.options["extra"] |
| 106 | + if let extras { |
| 107 | + if let lastIndex = installArgs.indices.last { |
| 108 | + installArgs[lastIndex] += "[\(extras)]" |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + try await model.executeInDirectory(in: packagePath.path, installArgs) |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + // MARK: - Update Requirements.txt |
| 117 | + |
| 118 | + /// Update the requirements.txt file with the installed package and extras |
| 119 | + private func updateRequirements(in packagePath: URL) -> PackageManagerInstallStep { |
| 120 | + let pipCommand = getPipCommand(in: packagePath) |
| 121 | + return PackageManagerInstallStep( |
| 122 | + name: "Update requirements.txt", |
| 123 | + confirmation: .none |
| 124 | + ) { model in |
| 125 | + let requirementsPath = packagePath.appending(path: "requirements.txt") |
| 126 | + |
| 127 | + let freezeOutput = try await model.executeInDirectory( |
| 128 | + in: packagePath.path(percentEncoded: false), |
| 129 | + ["\(pipCommand)", "freeze"] |
| 130 | + ) |
| 131 | + |
| 132 | + await model.status("Writing requirements to requirements.txt") |
| 133 | + let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" |
| 134 | + try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + // MARK: - Verify Installation |
| 139 | + |
| 140 | + private func verifyInstallation(_ source: PackageSource, in packagePath: URL) -> PackageManagerInstallStep { |
| 141 | + let pipCommand = getPipCommand(in: packagePath) |
| 142 | + return PackageManagerInstallStep( |
| 143 | + name: "Verify Installation", |
| 144 | + confirmation: .none |
| 145 | + ) { model in |
| 146 | + let output = try await model.executeInDirectory( |
| 147 | + in: packagePath.path(percentEncoded: false), |
| 148 | + ["\(pipCommand)", "list", "--format=freeze"] |
| 149 | + ) |
| 150 | + |
| 151 | + // Normalize package names for comparison |
| 152 | + let normalizedPackageHyphen = source.pkgName.replacingOccurrences(of: "_", with: "-").lowercased() |
| 153 | + let normalizedPackageUnderscore = source.pkgName.replacingOccurrences(of: "-", with: "_").lowercased() |
| 154 | + |
| 155 | + // Check if the package name appears in requirements.txt |
| 156 | + let installedPackages = output.map { line in |
| 157 | + line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) |
| 158 | + } |
| 159 | + let packageFound = installedPackages.contains { installedPackage in |
| 160 | + installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore |
| 161 | + } |
| 162 | + |
| 163 | + guard packageFound else { |
| 164 | + throw PackageManagerError.installationFailed("Package \(source.pkgName) not found in pip list") |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + private func getPipCommand(in packagePath: URL) -> String { |
| 170 | + let venvPip = "venv/bin/pip" |
| 171 | + return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) |
| 172 | + ? venvPip |
| 173 | + : "python -m pip" |
| 174 | + } |
| 175 | +} |
0 commit comments