Skip to content

Commit 1b8e69d

Browse files
committed
Adding serious_python_web platform plugin
1 parent 22bf599 commit 1b8e69d

File tree

2 files changed

+324
-0
lines changed

2 files changed

+324
-0
lines changed
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:html' as html;
4+
import 'dart:js_util' as js_util;
5+
6+
import 'package:flutter/services.dart';
7+
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
8+
import 'package:serious_python_platform_interface/serious_python_platform_interface.dart';
9+
import 'package:serious_python_web/pyodide.dart';
10+
11+
class SeriousPythonWeb extends SeriousPythonPlatform {
12+
bool _isInitialized = false;
13+
PyodideInterface? _pyodide;
14+
final Set<String> _loadedModules = {};
15+
16+
final String pyodideVersion = 'v0.27.2';
17+
late final String pyodideBaseURL = 'https://cdn.jsdelivr.net/pyodide/$pyodideVersion/full/';
18+
late final String pyodideJS = '${pyodideBaseURL}pyodide.js';
19+
20+
/// Registers this class as the default instance of [SeriousPythonPlatform]
21+
static void registerWith(Registrar registrar) {
22+
SeriousPythonPlatform.instance = SeriousPythonWeb();
23+
}
24+
25+
@override
26+
Future<String?> getPlatformVersion() async {
27+
return 'web';
28+
}
29+
30+
Future<List<String>> _parseRequirementsFile(String requirementsFile) async {
31+
try {
32+
final content = await rootBundle.loadString(requirementsFile);
33+
return content
34+
.split('\n')
35+
.map((line) => line.trim())
36+
.where((line) =>
37+
line.isNotEmpty &&
38+
!line.startsWith('#') &&
39+
!line.startsWith('-'))
40+
.map((line) => line.split('==')[0].split('>=')[0].trim())
41+
.toList();
42+
} catch (e) {
43+
print('Error parsing requirements.txt: $e');
44+
rethrow;
45+
}
46+
}
47+
48+
Future<String> _getRequirementsFileFromAssets() async {
49+
// Load the asset manifest
50+
// TODO Optimize not to load twice
51+
final manifestContent = await rootBundle.loadString('AssetManifest.json');
52+
final Map<String, dynamic> manifest = json.decode(manifestContent);
53+
54+
// Filter for Python files in the specified directory
55+
return manifest.keys.firstWhere((String key) => key.contains("requirements.txt"));
56+
}
57+
58+
Future<void> _loadPyodidePackages() async {
59+
try {
60+
// Parse requirements.txt
61+
final requirementsFile = await _getRequirementsFileFromAssets();
62+
final packages = await _parseRequirementsFile(requirementsFile);
63+
64+
if (packages.isEmpty) {
65+
print("No packages found in requirements.txt");
66+
return;
67+
}
68+
69+
print("Loading Pyodide packages: ${packages.join(', ')}");
70+
71+
for(final package in packages) {
72+
// Load packages
73+
try {
74+
await js_util.promiseToFuture(
75+
js_util.callMethod(_pyodide!, 'loadPackage', [package])
76+
);
77+
} catch(e) {
78+
print('Could not import package: $package');
79+
}
80+
}
81+
82+
print("Packages loaded successfully");
83+
} catch (e) {
84+
print('Error loading packages: $e');
85+
rethrow;
86+
}
87+
}
88+
89+
Future<void> _initializePyodide() async {
90+
if (_pyodide != null) return;
91+
92+
try {
93+
// Inject required meta tags first
94+
_injectMetaTags();
95+
96+
// Create and add the script element
97+
final scriptElement = html.ScriptElement()
98+
..src = pyodideJS
99+
..type = 'text/javascript';
100+
101+
html.document.head!.append(scriptElement);
102+
103+
// Wait for script to load
104+
await _waitForPyodide();
105+
106+
// Initialize Pyodide with correct base URL
107+
final config = js_util.jsify({
108+
'indexURL': pyodideBaseURL,
109+
'stdout': (String s) => print('Python stdout: $s'),
110+
'stderr': (String s) => print('Python stderr: $s')
111+
});
112+
113+
final pyodidePromise = loadPyodide(config);
114+
_pyodide = await js_util.promiseToFuture<PyodideInterface>(pyodidePromise);
115+
116+
// Test Python initialization
117+
await _runPythonCode("""
118+
import sys
119+
print(f"Python version: {sys.version}")
120+
""");
121+
122+
print("Pyodide initialized successfully");
123+
} catch (e, stackTrace) {
124+
print('Error initializing Pyodide: $e');
125+
print('Stack trace: $stackTrace');
126+
rethrow;
127+
}
128+
}
129+
130+
Future<void> _waitForPyodide() async {
131+
// TODO
132+
var attempts = 0;
133+
while (attempts < 100) {
134+
if (js_util.hasProperty(js_util.globalThis, 'loadPyodide')) {
135+
return;
136+
}
137+
await Future.delayed(const Duration(milliseconds: 100));
138+
attempts++;
139+
}
140+
throw Exception('Timeout waiting for Pyodide to load');
141+
}
142+
143+
static void _injectMetaTags() {
144+
try {
145+
final head = html.document.head;
146+
147+
// Check if meta tags already exist
148+
if (!head!.querySelectorAll('meta[name="cross-origin-opener-policy"]').isNotEmpty) {
149+
final coopMeta = html.MetaElement()
150+
..name = 'cross-origin-opener-policy'
151+
..content = 'same-origin';
152+
head.append(coopMeta);
153+
}
154+
155+
if (!head.querySelectorAll('meta[name="cross-origin-embedder-policy"]').isNotEmpty) {
156+
final coepMeta = html.MetaElement()
157+
..name = 'cross-origin-embedder-policy'
158+
..content = 'require-corp';
159+
head.append(coepMeta);
160+
}
161+
} catch (e) {
162+
print('Error injecting meta tags: $e');
163+
}
164+
}
165+
166+
Future<List<String>> _listPythonFilesInDirectory(String directory) async {
167+
// Load the asset manifest
168+
final manifestContent = await rootBundle.loadString('AssetManifest.json');
169+
final Map<String, dynamic> manifest = json.decode(manifestContent);
170+
171+
// Filter for Python files in the specified directory
172+
return manifest.keys.where((String key) => key.contains(directory) && key.endsWith('.py')).toList();
173+
}
174+
175+
Future<void> _loadModules(String moduleName, List<String> modulePaths) async {
176+
// Create a package directory in Pyodide's virtual filesystem
177+
await _runPythonCode('''
178+
import os
179+
import sys
180+
181+
if not os.path.exists('/package'):
182+
os.makedirs('/package')
183+
184+
if not os.path.exists('/package/$moduleName'):
185+
os.makedirs('/package/$moduleName')
186+
187+
# Create __init__.py to make it a package
188+
with open(f'/package/$moduleName/__init__.py', 'w') as f:
189+
f.write('')
190+
191+
if '/package' not in sys.path:
192+
sys.path.append('/package')
193+
''');
194+
195+
for (final modulePath in modulePaths) {
196+
final moduleCode = await rootBundle.loadString(modulePath);
197+
final fileName = modulePath.split('/').last;
198+
199+
// Use Pyodide's filesystem API to write module Code
200+
await _pyodide!.FS.writeFile('/package/$moduleName/$fileName', moduleCode, {'encoding': 'utf8'});
201+
}
202+
}
203+
204+
Future<void> _loadModuleDirectories(List<String> modulePaths) async {
205+
final List<String> moduleNamesToImport = [];
206+
for(final directory in modulePaths) {
207+
final moduleName = directory.split("/").last;
208+
if(_loadedModules.contains(moduleName)) {
209+
continue;
210+
}
211+
212+
final pythonFiles = await _listPythonFilesInDirectory(directory);
213+
await _loadModules(moduleName, pythonFiles);
214+
_loadedModules.add(moduleName);
215+
moduleNamesToImport.add(moduleName);
216+
}
217+
// Import the modules using pyimport
218+
for(final moduleNameToImport in moduleNamesToImport) {
219+
await _pyodide!.pyimport('$moduleNameToImport');
220+
}
221+
}
222+
223+
Future<void> ensureInitialized(String appPath) async {
224+
if (!_isInitialized) {
225+
// TODO REQUIREMENTS FILE PATH: ARGUMENT?
226+
await _initializePyodide();
227+
await _loadPyodidePackages();
228+
_isInitialized = true;
229+
}
230+
}
231+
232+
@override
233+
Future<String?> run(String appPath,
234+
{String? script, List<String>? modulePaths, Map<String, String>? environmentVariables, bool? sync}) async {
235+
print(environmentVariables);
236+
try {
237+
await ensureInitialized(appPath);
238+
239+
// Load the Python code from the asset
240+
final pythonCode = await rootBundle.loadString(appPath);
241+
242+
// Set environment variables if provided
243+
if (environmentVariables != null) {
244+
await _runPythonCode('''
245+
import os
246+
${environmentVariables.entries.map((e) => "os.environ['${e.key}'] = '${e.value}'").join('\n')}
247+
''');
248+
}
249+
250+
// Add module paths if provided
251+
if (modulePaths != null) {
252+
int oldNModules = _loadedModules.length;
253+
await _loadModuleDirectories(modulePaths);
254+
int newNModules = _loadedModules.length;
255+
print("Loaded ${newNModules - oldNModules} new modules!");
256+
}
257+
258+
final String debugCode = '''
259+
import os
260+
import sys
261+
262+
print("Python version:", sys.version)
263+
print("Python path:", sys.path)
264+
print("Current working directory:", os.getcwd())
265+
print("Directory contents:", os.listdir('/package'))
266+
''';
267+
268+
await _runPythonCode(debugCode + pythonCode);
269+
270+
final result = _pyodide!.globals.get("pyodide_result");
271+
return result.toString();
272+
} catch (e) {
273+
print('Error running Python code: $e');
274+
return 'Error: $e';
275+
}
276+
}
277+
278+
Future<void> _runPythonCode(String code) async {
279+
try {
280+
print("Running Python code: \n$code");
281+
final promise = _pyodide!.runPythonAsync(code);
282+
await js_util.promiseToFuture(promise);
283+
} catch (e) {
284+
print('Error running Python code: $e');
285+
rethrow;
286+
}
287+
}
288+
289+
@override
290+
void terminate() {
291+
// No need to implement for web
292+
}
293+
}

src/serious_python_web/pubspec.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: serious_python_web
2+
description: Web implementations of the serious_python plugin
3+
homepage: https://flet.dev
4+
repository: https://github.com/flet-dev/serious-python
5+
version: 0.8.7
6+
7+
environment:
8+
sdk: '>=3.1.3 <4.0.0'
9+
flutter: '>=3.3.0'
10+
11+
dependencies:
12+
flutter:
13+
sdk: flutter
14+
flutter_web_plugins:
15+
sdk: flutter
16+
js: ^0.6.7
17+
serious_python_platform_interface:
18+
path: ../serious_python_platform_interface
19+
20+
dev_dependencies:
21+
flutter_test:
22+
sdk: flutter
23+
flutter_lints: ^2.0.0
24+
25+
flutter:
26+
plugin:
27+
implements: serious_python
28+
platforms:
29+
web:
30+
pluginClass: SeriousPythonWeb
31+
fileName: serious_python_web.dart

0 commit comments

Comments
 (0)