Skip to content

Commit b5810ce

Browse files
committed
Refactoring and cleaning up pyodide code
Also supports loading multiple requirements files (all found in asset manifest) now
1 parent e19c9ca commit b5810ce

File tree

5 files changed

+342
-253
lines changed

5 files changed

+342
-253
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class PyodideConstants {
2+
static const String pyodideVersion = 'v0.27.2';
3+
static const String pyodideBaseURL = 'https://cdn.jsdelivr.net/pyodide/$pyodideVersion/full/';
4+
static const String pyodideJS = '${pyodideBaseURL}pyodide.js';
5+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import 'dart:html' as html;
2+
import 'dart:js_util' as js_util;
3+
4+
import 'package:flutter/services.dart';
5+
import 'package:serious_python_web/pyodide_constants.dart';
6+
import 'package:serious_python_web/pyodide_interop.dart';
7+
import 'package:serious_python_web/pyodide_utils.dart';
8+
9+
10+
class PyodideStateManager {
11+
PyodideStateInitialize? _initState;
12+
PyodideStateLoadDependencies? _depState;
13+
PyodideStateLoadModuleCode? _moduleState;
14+
15+
Future<PyodideInterface> getPyodide(List<String> modulePaths) async {
16+
if(_initState == null) {
17+
_initState = await PyodideStateInitialize().doSetup();
18+
}
19+
if(_depState == null) {
20+
_depState = await PyodideStateLoadDependencies(_initState!._pyodide!).doSetup();
21+
}
22+
if(_moduleState == null) {
23+
_moduleState = await PyodideStateLoadModuleCode(_depState!._pyodide);
24+
}
25+
_moduleState = await _moduleState!.doSetup(modulePaths);
26+
return _moduleState!._pyodide;
27+
}
28+
29+
}
30+
31+
class PyodideStateInitialize {
32+
PyodideInterface? _pyodide;
33+
34+
Future<void> _initializePyodide() async {
35+
if (_pyodide != null) return;
36+
37+
try {
38+
// Inject required meta tags first
39+
PyodideUtils.injectMetaTags();
40+
41+
// Create and add the script element
42+
final scriptElement = html.ScriptElement()
43+
..src = PyodideConstants.pyodideJS
44+
..type = 'text/javascript';
45+
46+
html.document.head!.append(scriptElement);
47+
48+
// Wait for script to load
49+
await _waitForPyodide();
50+
51+
// Initialize Pyodide with correct base URL
52+
final config = js_util.jsify({
53+
'indexURL': PyodideConstants.pyodideBaseURL,
54+
'stdout': (String s) => print('Python stdout: $s'),
55+
'stderr': (String s) => print('Python stderr: $s')
56+
});
57+
58+
final pyodidePromise = loadPyodide(config);
59+
_pyodide = await js_util.promiseToFuture<PyodideInterface>(pyodidePromise);
60+
61+
// Test Python initialization
62+
await PyodideUtils.runPythonCode(_pyodide, """
63+
import sys
64+
print(f"Python version: {sys.version}")
65+
""");
66+
67+
print("Pyodide initialized successfully");
68+
} catch (e, stackTrace) {
69+
print('Error initializing Pyodide: $e');
70+
print('Stack trace: $stackTrace');
71+
rethrow;
72+
}
73+
}
74+
75+
Future<void> _waitForPyodide() async {
76+
for (int attempts = 0; attempts < 100; attempts++) {
77+
if (js_util.hasProperty(js_util.globalThis, 'loadPyodide')) {
78+
return;
79+
}
80+
await Future.delayed(const Duration(milliseconds: 100));
81+
}
82+
throw Exception('Timeout waiting for Pyodide to load');
83+
}
84+
85+
Future<PyodideStateInitialize> doSetup() async {
86+
try {
87+
await _initializePyodide();
88+
return this;
89+
} catch (e) {
90+
rethrow;
91+
}
92+
}
93+
}
94+
95+
class PyodideStateLoadDependencies {
96+
final PyodideInterface _pyodide;
97+
98+
PyodideStateLoadDependencies(this._pyodide);
99+
100+
Future<void> _loadPythonDependencies() async {
101+
try {
102+
// Parse requirements.txt
103+
final requirementsFile = await PyodideUtils.getRequirementsFilesFromAssets();
104+
final packages = await PyodideUtils.parseRequirementsFiles(requirementsFile);
105+
106+
if (packages.isEmpty) {
107+
print("Nothing to do: No packages found in all requirements.txt files.");
108+
return;
109+
}
110+
111+
print("Loading Pyodide packages: ${packages.join(', ')}");
112+
for (final package in packages) {
113+
try {
114+
await js_util.promiseToFuture(js_util.callMethod(_pyodide, 'loadPackage', [package]));
115+
} catch (e) {
116+
print('Could not import package: $package');
117+
}
118+
}
119+
120+
print("Packages loaded successfully");
121+
} catch (e) {
122+
print('Error loading packages: $e');
123+
rethrow;
124+
}
125+
}
126+
127+
Future<PyodideStateLoadDependencies> doSetup() async {
128+
try {
129+
await _loadPythonDependencies();
130+
return this;
131+
} catch (e) {
132+
rethrow;
133+
}
134+
}
135+
}
136+
137+
class PyodideStateLoadModuleCode {
138+
final PyodideInterface _pyodide;
139+
140+
final Set<String> _loadedModules = {};
141+
142+
PyodideStateLoadModuleCode(this._pyodide);
143+
144+
Future<void> _loadModules(String moduleName, List<String> modulePaths) async {
145+
// Create a package directory in Pyodide's virtual filesystem
146+
await PyodideUtils.runPythonCode(_pyodide, '''
147+
import os
148+
import sys
149+
150+
if not os.path.exists('/package'):
151+
os.makedirs('/package')
152+
153+
if not os.path.exists('/package/$moduleName'):
154+
os.makedirs('/package/$moduleName')
155+
156+
# Create __init__.py to make it a package
157+
with open(f'/package/$moduleName/__init__.py', 'w') as f:
158+
f.write('')
159+
160+
if '/package' not in sys.path:
161+
sys.path.append('/package')
162+
''');
163+
164+
for (final modulePath in modulePaths) {
165+
final moduleCode = await rootBundle.loadString(modulePath);
166+
final fileName = modulePath.split('/').last;
167+
168+
// Use Pyodide's filesystem API to write module Code
169+
await _pyodide.FS.writeFile('/package/$moduleName/$fileName', moduleCode, {'encoding': 'utf8'});
170+
}
171+
}
172+
173+
/// Loads all necessary python code modules and imports them via pyodide
174+
Future<void> _loadModuleDirectories(List<String> modulePaths) async {
175+
final List<String> moduleNamesToImport = [];
176+
for (final directory in modulePaths) {
177+
final moduleName = directory.split("/").last;
178+
if (_loadedModules.contains(moduleName)) {
179+
continue;
180+
}
181+
182+
final pythonFiles = await PyodideUtils.listPythonFilesInDirectory(directory);
183+
await _loadModules(moduleName, pythonFiles);
184+
_loadedModules.add(moduleName);
185+
moduleNamesToImport.add(moduleName);
186+
}
187+
// Import the modules using pyimport
188+
for (final moduleNameToImport in moduleNamesToImport) {
189+
await _pyodide.pyimport('$moduleNameToImport');
190+
}
191+
}
192+
193+
Future<PyodideStateLoadModuleCode> doSetup(List<String> modulePaths) async {
194+
try {
195+
await _loadModuleDirectories(modulePaths);
196+
return this;
197+
} catch(e) {
198+
rethrow;
199+
}
200+
}
201+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import 'dart:convert';
2+
import 'dart:html' as html;
3+
import 'dart:js_util' as js_util;
4+
5+
import 'package:flutter/services.dart';
6+
import 'package:serious_python_web/pyodide_interop.dart';
7+
8+
class PyodideUtils {
9+
static void injectMetaTags() {
10+
try {
11+
final head = html.document.head;
12+
13+
// Check if meta tags already exist
14+
if (!head!.querySelectorAll('meta[name="cross-origin-opener-policy"]').isNotEmpty) {
15+
final coopMeta = html.MetaElement()
16+
..name = 'cross-origin-opener-policy'
17+
..content = 'same-origin';
18+
head.append(coopMeta);
19+
}
20+
21+
if (!head.querySelectorAll('meta[name="cross-origin-embedder-policy"]').isNotEmpty) {
22+
final coepMeta = html.MetaElement()
23+
..name = 'cross-origin-embedder-policy'
24+
..content = 'require-corp';
25+
head.append(coepMeta);
26+
}
27+
} catch (e) {
28+
print('Error injecting meta tags: $e');
29+
}
30+
}
31+
32+
static Future<Set<String>> getRequirementsFilesFromAssets() async {
33+
// Load the asset manifest
34+
final manifestContent = await rootBundle.loadString('AssetManifest.json');
35+
final Map<String, dynamic> manifest = json.decode(manifestContent);
36+
37+
// Filter for Python files in the specified directory
38+
return manifest.keys.where((String key) => key.contains("requirements.txt")).toSet();
39+
}
40+
41+
static Future<List<String>> parseRequirementsFiles(Set<String> requirementsFiles) async {
42+
try {
43+
final List<String> requirements = [];
44+
for(final requirementsFile in requirementsFiles) {
45+
final content = await rootBundle.loadString(requirementsFile);
46+
final parsedRequirements = content
47+
.split('\n')
48+
.map((line) => line.trim())
49+
.where((line) => line.isNotEmpty && !line.startsWith('#') && !line.startsWith('-'))
50+
.map((line) => line.split('==')[0].split('>=')[0].trim())
51+
.toList();
52+
requirements.addAll(parsedRequirements);
53+
}
54+
return requirements;
55+
} catch (e) {
56+
print('Error parsing requirements.txt: $e');
57+
rethrow;
58+
}
59+
}
60+
61+
static Future<List<String>> listPythonFilesInDirectory(String directory) async {
62+
// Load the asset manifest
63+
final manifestContent = await rootBundle.loadString('AssetManifest.json');
64+
final Map<String, dynamic> manifest = json.decode(manifestContent);
65+
66+
// Filter for Python files in the specified directory
67+
return manifest.keys.where((String key) => key.contains(directory) && key.endsWith('.py')).toList();
68+
}
69+
70+
static Future<void> setupEnvironmentVariables(
71+
PyodideInterface? pyodide, Map<String, String>? environmentVariables) async {
72+
if (environmentVariables == null) {
73+
return;
74+
}
75+
print("Running python web command with environment variables: $environmentVariables");
76+
77+
await runPythonCode(pyodide, '''
78+
import os
79+
${environmentVariables.entries.map((e) => "os.environ['${e.key}'] = '${e.value}'").join('\n')}
80+
''');
81+
}
82+
83+
static Future<void> printPythonDebug(PyodideInterface? pyodide) async {
84+
final String debugCode = '''
85+
import os
86+
import sys
87+
88+
print("Python version:", sys.version)
89+
print("Python path:", sys.path)
90+
print("Current working directory:", os.getcwd())
91+
print("Directory contents:", os.listdir('/package'))
92+
''';
93+
await runPythonCode(pyodide, debugCode);
94+
}
95+
96+
static Future<void> runPythonCode(PyodideInterface? pyodide, String code) async {
97+
try {
98+
if (pyodide == null) {
99+
throw Exception("Trying to run python code on non-existing pyodide object!");
100+
}
101+
final promise = pyodide.runPythonAsync(code);
102+
await js_util.promiseToFuture(promise);
103+
} catch (e) {
104+
print('Error running Python code: $e');
105+
rethrow;
106+
}
107+
}
108+
109+
static Future<String> getPyodideResult(PyodideInterface? pyodide) async {
110+
try {
111+
if (pyodide == null) {
112+
throw Exception("Trying to get pyodide result on non-existing pyodide object!");
113+
}
114+
final result = pyodide.globals.get("pyodide_result");
115+
return result.toString();
116+
} catch (e) {
117+
print('Error getting pyodide result: $e');
118+
rethrow;
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)