-
Notifications
You must be signed in to change notification settings - Fork 1
/
NotebookExport.swift
241 lines (206 loc) · 10 KB
/
NotebookExport.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import Foundation
import Path
public struct NotebookExport {
let filepath: Path
public enum NotebookExportError: Error {
case unexpectedNotebookFormat
}
let exportRegexp = NSRegularExpression(#"^\s*//\s*export\s*$"#) // Swift 5 raw String
// %install '.package(url: "https://github.com/mxcl/Path.swift", from: "0.16.1")' Path
let installRegexp = NSRegularExpression(#"^\s*%install '(.*)'\s(.*)$"#) // Swift 5 raw String
/// Parse the notebook and selects the cells of interest,
/// returning the content filtered and transformed by the supplied closure.
/// Parsed data is not cached, so multiple calls will read from the document again.
func processCells(contentTransform: (_ cellSource: [String]) -> [String]?) throws -> [[String]] {
let data = try Data(contentsOf: filepath)
let json = try JSONSerialization.jsonObject(with: data, options: [])
guard let jsonDictionary = json as? [String: Any] else {
//TODO: Accept the payload if it's an array with a single dictionary inside
throw NotebookExportError.unexpectedNotebookFormat
}
guard let cells = jsonDictionary["cells"] as? [[String: Any]] else {
throw NotebookExportError.unexpectedNotebookFormat
}
// Use compactMap to combine the filter and map we need
let selectedSources: [[String]] = cells.compactMap { cell in
guard let source = cell["source"] as? [String] else { return nil }
return contentTransform(source) // nil to ignore this cell
}
return selectedSources
}
/// Parse the notebook and extract the source of the exportable cells (minus the comment line).
/// Parsed data is not cached.
func extractExportableSources() throws -> [[String]] {
return try processCells { source in
guard exportRegexp.matches(source.first) else { return nil }
return Array(source[1...])
}
}
/// Parse the notebook and extract the source of the install cells.
/// Parsed data is not cached.
func extractInstallableSources() throws -> [[String]] {
return try processCells { source in
for line in source {
if installRegexp.matches(line) { return source }
}
return nil
}
}
func dependencyFromInstallLine(_ line: String) -> [DependencyDescription] {
//TODO: is there anything we can do about %install-swiftpm-flags?
// %install '.package(url: "https://github.com/mxcl/Path.swift", from: "0.16.1")' Path
let lineRange = NSRange(line.startIndex..<line.endIndex, in: line)
var dependencies: [DependencyDescription] = []
installRegexp.enumerateMatches(in: line, options: [], range: lineRange) { (match, _, _) in
guard let match = match else { return }
guard match.numberOfRanges == 3 else { return }
guard let specRange = Range(match.range(at: 1), in: line),
let nameRange = Range(match.range(at: 2), in: line) else { return }
let name = String(line[nameRange])
let spec = String(line[specRange]).replacingOccurrences(of: "$cwd", with: Path.cwd.string)
dependencies.append(DependencyDescription(name: name, rawSpec: spec))
}
return dependencies
}
/// Extract dependencies from %install cells
func extractDependencies() throws -> [DependencyDescription] {
var dependencies: [DependencyDescription] = []
for cellSource in try extractInstallableSources() {
for line in cellSource {
dependencies.append(contentsOf: dependencyFromInstallLine(line))
}
}
return dependencies
}
/// Update global Package.swift
func updatePackageSpec(at path: Path, packageName: String, dependencies: [DependencyDescription]) throws {
let dependencyPackages = (dependencies.map { return $0.description }).joined(separator: ",\n ")
let dependencyNames = (dependencies.map { return "\($0.name.quoted)" }).joined(separator: ", ")
let manifest = """
// swift-tools-version:4.2
import PackageDescription
let package = Package(
name: "\(packageName)",
products: [
.library(name: "\(packageName)", targets: ["\(packageName)"]),
],
dependencies: [
\(dependencyPackages)
],
targets: [
.target(
name: "\(packageName)",
dependencies: [\(dependencyNames)]),
]
)
"""
try manifest.write(to: path/"Package.swift")
}
}
// Public API
public extension NotebookExport {
// Using different names than the Python version to avoid conflicts for now
static let defaultPackagePath = "ExportedNotebooks"
static let defaultPackagePrefix = "ExportedNotebook_"
enum ExportResult {
case success
case failure(reason: String)
}
/// Export as an additional source inside the specified package path
/// If mergingDependencies is true and a Package.swift file already exists
/// at the package location, merge the detected dependencies with the ones
/// already in the package.
@discardableResult
func toScript(inside packagePath: Path, mergingDependencies: Bool = true) -> ExportResult {
let newname = filepath.basename(dropExtension: true) + ".swift"
let packageName = packagePath.basename()
let destination = packagePath/"Sources"/packageName/newname
do {
var module = """
/*
THIS FILE WAS AUTOGENERATED! DO NOT EDIT!
file to edit: \(filepath.basename())
*/
"""
for cellSource in try extractExportableSources() {
module.append("\n" + cellSource.joined() + "\n")
}
try destination.parent.mkdir(.p)
try module.write(to: destination, encoding: .utf8)
let notebookDependencies = try extractDependencies()
let packageDependencies: [DependencyDescription]
if mergingDependencies {
let existingDependencies: Set<DependencyDescription> = Set(DependencyDescription.fromPackage(at: packagePath))
packageDependencies = Array(existingDependencies.union(Set(notebookDependencies)))
} else {
packageDependencies = notebookDependencies
}
try updatePackageSpec(at: packagePath, packageName: packageName, dependencies: packageDependencies)
return .success
} catch {
return .failure(reason: "Can't export \(filepath)")
}
}
/// Export as an additional source inside the specified package path
@discardableResult
func toScript(inside packagePath: String = defaultPackagePath, mergingDependencies: Bool = true) -> ExportResult {
return toScript(inside: Path.from(packagePath), mergingDependencies: mergingDependencies)
}
/// Copy sources from previously exported notebooks this one explicitly depends on.
internal
func copySourcesFromLocalDependencies(withPrefix prefix: String, inside packagePath: Path) {
do {
// Parse dependencies again and copy sources from local (i.e., path:) ones
// with the same prefix in the same parent directory.
let localSpec = NSRegularExpression(#"^\s*.package\(path:\s*"([^"]*)"(.*)\)$"#)
try extractDependencies().forEach { dependency in
guard dependency.name.hasPrefix(prefix) else { return }
let spec = dependency.rawSpec
let range = NSRange(spec.startIndex ..< spec.endIndex, in: spec)
localSpec.enumerateMatches(in: spec, options: [], range: range) { (match, _, _) in
guard let match = match else { return }
guard match.numberOfRanges == 3 else { return }
guard let pathRange = Range(match.range(at: 1), in: spec) else { return }
let path = Path.from(String(spec[pathRange]))
guard path.parent == packagePath.parent else { return }
// Do copy files
do {
let packageName = packagePath.basename()
let destination = packagePath/"Sources"/packageName
for entry in try (path/"Sources"/dependency.name).ls() where entry.kind == .file {
try entry.path.copy(into: destination)
}
} catch {
/* Ignore file */
}
}
}
} catch {
// pass
}
}
/// Export as an independent package, prepending the specified prefix to the name
@discardableResult
func toPackage(prefix: String = defaultPackagePrefix) -> ExportResult {
// Create the isolated package
let packagePath = Path.from(prefix + filepath.basename(dropExtension: true))
let packageResult = toScript(inside: packagePath, mergingDependencies: false)
guard case .success = packageResult else { return packageResult }
// Should we do this optional?
copySourcesFromLocalDependencies(withPrefix: prefix, inside: packagePath)
return packageResult
}
/// Perform both toScript() and toPackage()
@discardableResult
func export(inside packagePath: String = defaultPackagePath, independentPackagePrefix: String = defaultPackagePrefix) -> ExportResult {
let firstResult = toScript(inside: packagePath)
guard case .success = firstResult else { return firstResult }
return toPackage(prefix: independentPackagePrefix)
}
init(_ filepath: Path) {
self.filepath = filepath
}
init(_ filename: String) {
self.init(Path.from(filename))
}
}